[Python-apps-team] Bug#921257: khal: diff for NMU version 1:0.10.2-0.1

Jonas Smedegaard dr at jones.dk
Tue Jan 26 11:28:12 GMT 2021


Control: tags 921257 + patch


Dear maintainer,

I've released an NMU for khal (versioned as 1:0.10.2-0.1). The diff
is attached to this message.

Regards.

diff -Nru khal-0.9.10/AUTHORS.txt khal-0.10.2/AUTHORS.txt
--- khal-0.9.10/AUTHORS.txt	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/AUTHORS.txt	2020-07-29 18:17:53.000000000 +0200
@@ -32,3 +32,14 @@
 Stefan Siegel - ssiegel [at] sdas [dot] net
 August Lindberg
 Thomas Kluyver - thomas [at] kluyver [dot] me [dot] uk
+Tobias Brummer - hallo [at] t0bybr.de - https://t0bybr.de
+Amanda Hickman - amanda [at] velociraptor [dot] info
+Raef Coles - raefcoles [at] gmail [dot] com
+Nito Martinez - nito [at] qindel [dot] com - http://qindel.com http://theqvd.com
+Florian Wehner - florian [at] whnr [dot] de
+Martin Stone
+Maxime Ocafrain
+Axel Danguin
+Yorick Barbanneau - git [at] epha [dot] se - https://xieme-art.org
+Florian Lassenay - pizzacoca [at] aquilenet [dot] fr
+Simon Crespeau - simon [at] art-e-toile [dot] com - https://www.art-e-toile.com
diff -Nru khal-0.9.10/CHANGELOG.rst khal-0.10.2/CHANGELOG.rst
--- khal-0.9.10/CHANGELOG.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/CHANGELOG.rst	2020-07-29 18:17:53.000000000 +0200
@@ -7,17 +7,90 @@
 may want to subscribe to `GitHub's tag feed
 <https://github.com/geier/khal/tags.atom>`_.
 
-0.9.10
+
+0.10.3
 ======
-released 2018-010-09
+not released
 
-* Dependencies: dateutil 2.7 supported now
+0.10.2
+======
+2020-07-29
 
-0.9.9
-=====
-released 2018-05-26
+* NEW Parse `X-ANNIVERSARY`, `ANNIVERSARY` and `X-ABDATE` fields from vcards
+* NEW Add ability to change default event duration with
+   `default_event_duration` and `default_dayevent_duration` for an day-long 
+   event
+* NEW Add `{uid}` property to template options in `--format`
+* FIX No warning when importing event with Windows timezone format
+* FIX Launching an external editor no longer crashes `ikhal`
+* UPDATED DEPENDENCY urwid>=1.3.0
+* FIX Wrong left pane width calculation in ikal when `frame` is `width` or 
+   `color` in configuration.
+* CHANGE Remove check for timezones in `UNTIL` that aren't in `DTSTART` and 
+   vice-versa. The check wasn't fulfilling its purpose and was raising warnings
+   when no `UNTIL` value was set.
+
+0.10.1
+======
+2019-03-30
+
+* FIX error with the new color priority system and `discover` calendar type
+* FIX search results in ikhal are ordered, same as in `khal search`
+
+0.10.0
+======
+2019-03-25
+
+* In contrast to what was stated here before, at release time, khal >0.10.0
+   supported dateutil 2.7
+
+* NEW DEPENDENCY added click_log  >= 0.2.0
+* NEW DEPENDENCY for Python 3.4: typing
+* UPDATED DEPENDENCY icalendar>=4.03
+* DROPPED support for Python 3.3
+* vdirsyncer is still a test dependency (and always has been)
 
-* Dependencies: only dateutil < 2.7 is supported (and always has been)
+* FIX ordinal numbers in birthday entries (before, all number would end on `th`)
+* FIX `search` will no longer break on overwritten events with a master event
+* FIX when using short dates, khal infers that you meant next year, when date
+  is before today
+* FIX Check for multi_uid .ics files in vdirs and don't import those events
+  (All .ics files in vdirs should only contain VEVENTS with the same UID.)
+
+* CHANGE only searched configuration file paths are now
+  $XDG_CONFIG_HOME/khal/config and $XDG_CONFIG_HOME/khal/khal.conf (deprecated)
+* CHANGE removed default command
+* CHANGE default date/time formats to be the system's locale's formats
+* CHANGE ``--verbose`` flag to ``--verbosity``, allowing finer granularity
+* CHANGE `search` will now print one line for every different event in a
+  recurrence set, that is one line for the master event, and one line for every
+  different overwritten event
+* CHANGE khal learned to read .ics files with nonsenscial TZOFFSETs > 24h and
+  prints a warning
+* CHANGE better error message for a specific kind of invalid config file
+
+* NEW khal learned the ``--logfile/-l LOGFILE`` flag which allows logging to a
+  file
+* NEW format can now print the duration of an event with `{duration}`
+* NEW format supports `{nl}`, `{tab}`, `{bell}`. `{status}` has a whitespace
+  like `{cancelled}`
+* NEW configuration option: [view]monthdisplay = firstday|firstfullweek,
+  if set to 'firstday', khal displays the month name as soon as any day
+  in the week is within the new month. If set to 'firstfullweek', khal
+  displays the month name only if the first day of the week is within
+  the new month.
+
+* NEW ikhal learned to show log messages in the header and in a new log pane,
+  access with default keybinding `L`
+
+* NEW python 3.7 is now officially supported.
+
+* NEW configuration option [[per_calendar]]priority = int (default 10). If
+  multiple calendars events are on the same day, the day will be colored with
+  the color of the calendar with highest priority. If multiple calendars have
+  the same highest priority, it falls back to the previous system.
+
+* NEW format can now print the organizer of the event with '(organizer)'
 
 0.9.8
 =====
@@ -31,11 +104,6 @@
 released 2017-09-15
 
 * FIX don't crash when editing events with datetime UNTIL properties
-* FIX `search` will no longer break on overwritten events with a master event
-
-* CHANGE `search` will now print one line for every different event in a
-  recurrence set, that is one line for the master event, and one line for every
-  different overwritten event
 
 0.9.6
 =====
@@ -50,7 +118,7 @@
   present in an .ics file
 * FIX .ics files containing only overwritten instances are not expanded anymore,
   even if they contain a RRULE or RDATE
-* FIX valid UNTIL entry for recurring datetime events 
+* FIX valid UNTIL entry for recurring datetime events
 
 * CHANGE the symbol used for indicating a recurring event now has a space in
   front of it, also the ascii version changed to `(R)`
@@ -81,7 +149,7 @@
 * NEW ikhal's event editor now allows better editing of recurrence rules,
   including INTERVALs, end dates, and more
 * NEW ikhal will now check if any configured vdir has been updated, and, if
-  applicable, refresh its UI to reflect the latest changes 
+  applicable, refresh its UI to reflect the latest changes
 
 0.9.3
 =====
diff -Nru khal-0.9.10/debian/changelog khal-0.10.2/debian/changelog
--- khal-0.9.10/debian/changelog	2019-03-26 09:56:46.000000000 +0100
+++ khal-0.10.2/debian/changelog	2021-01-26 12:16:00.000000000 +0100
@@ -1,3 +1,51 @@
+khal (1:0.10.2-0.1) unstable; urgency=medium
+
+  * Non-maintainer upload.
+
+  [ upstream ]
+  * new releases:
+    0.10.1
+    + fix new color priority system and discover calendar type
+    + fix order search results in ikhal
+    0.10.2
+    + Parse X-ANNIVERSARY, ANNIVERSARY and X-ABDATE fields from vcards
+    + Add ability to change default event duration
+      with default_event_duration and default_dayevent_duration
+      for an day-long event
+    + Add {uid} property to template options in --format
+    + fix warning when importing event with Windows timezone format
+    + fix crash launching external editor in ikhal
+    + fix calculate left pane width in ikal
+      when frame is width or color in configuration
+    + remove timezones check in UNTIL not in DTSTART and vice-versa
+      closes: bug#921257, thanks to Santiago Vila and Helmut Grohne
+
+  [ Ondřej Nový ]
+  * Use debhelper-compat instead of debian/compat.
+  * Bump Standards-Version to 4.4.1.
+  * d/control: Update Maintainer field with new Debian Python Team
+    contact address.
+  * d/control: Update Vcs-* fields with new Debian Python Team Salsa
+    layout.
+
+  [ Jonas Smedegaard ]
+  * drop patch cherry-picked upstream since applied
+  * add patches cherry-picked upstream:
+    + fix accept -a -d options in ikhal
+    + fix strip whitespace when loading displayname and color files
+    + fix warn when loading events
+      with a recurrence that finishes before it starts
+    + fix avoid crash in ikhal on alarms without descriptions
+    + fix display all-day events at the top of the day in ikhal
+    + fix avoid crash in ikhal on keybindings in empty search results
+    + fix avoid crash in ikhal on new keybinding in search
+  * drop patches 0004 0006 0007,
+    obsoleted by improved upstream testsuite
+  * unfuzz patches
+  * cleanup upstream build noise and autogenerated version file
+
+ -- Jonas Smedegaard <dr at jones.dk>  Tue, 26 Jan 2021 12:16:00 +0100
+
 khal (1:0.9.10-1.1) unstable; urgency=medium
 
   * Non-maintainer upload.
diff -Nru khal-0.9.10/debian/clean khal-0.10.2/debian/clean
--- khal-0.9.10/debian/clean	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/clean	2021-01-26 12:02:04.000000000 +0100
@@ -0,0 +1,2 @@
+khal/version.py
+.eggs/
diff -Nru khal-0.9.10/debian/compat khal-0.10.2/debian/compat
--- khal-0.9.10/debian/compat	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/compat	1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-12
diff -Nru khal-0.9.10/debian/control khal-0.10.2/debian/control
--- khal-0.9.10/debian/control	2019-03-26 09:48:59.000000000 +0100
+++ khal-0.10.2/debian/control	2021-01-26 12:04:42.000000000 +0100
@@ -1,9 +1,9 @@
 Source: khal
-Maintainer: Python Applications Packaging Team <python-apps-team at lists.alioth.debian.org>
+Maintainer: Debian Python Team <team+python at tracker.debian.org>
 Uploaders: Filip Pytloun <filip at pytloun.cz>
 Section: utils
 Priority: optional
-Build-Depends: debhelper (>= 12),
+Build-Depends: debhelper-compat (= 12),
                dh-exec,
                dh-python (>= 2.20160609~),
                locales,
@@ -25,11 +25,11 @@
                python3-urwid,
                python3-xdg,
                vdirsyncer (>= 0.16.7)
-Standards-Version: 4.3.0
+Standards-Version: 4.4.1
 Testsuite: autopkgtest-pkg-python
 Homepage: https://github.com/pimutils/khal
-Vcs-Git: https://salsa.debian.org/python-team/applications/khal.git
-Vcs-Browser: https://salsa.debian.org/python-team/applications/khal
+Vcs-Git: https://salsa.debian.org/python-team/packages/khal.git
+Vcs-Browser: https://salsa.debian.org/python-team/packages/khal
 
 Package: khal
 Architecture: all
diff -Nru khal-0.9.10/debian/patches/0000-20190325~c58fb88-fix-parse-categories-as-list.patch khal-0.10.2/debian/patches/0000-20190325~c58fb88-fix-parse-categories-as-list.patch
--- khal-0.9.10/debian/patches/0000-20190325~c58fb88-fix-parse-categories-as-list.patch	2019-03-26 09:42:14.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20190325~c58fb88-fix-parse-categories-as-list.patch	1970-01-01 01:00:00.000000000 +0100
@@ -1,121 +0,0 @@
-Description: fix pass categories as list
- Support (only) icalendar >= 4.0.3
- .
- With icalendar 4.0.3 the CATEGORIES Property became a list
- as mandated by the RFC.
-Author: Christian Geier <geier at lostpackets.de>
-Bug: https://github.com/pimutils/khal/issues/825
-Last-Update: 2019-03-26
----
-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
---- a/khal/cli.py
-+++ b/khal/cli.py
-@@ -335,7 +335,7 @@
-     @click.option('--location', '-l',
-                   help=('The location of the new event.'))
-     @click.option('--categories', '-g',
--                  help=('The categories of the new event.'))
-+                  help=('The categories of the new event, comma separated.'))
-     @click.option('--repeat', '-r',
-                   help=('Repeat event: daily, weekly, monthly or yearly.'))
-     @click.option('--until', '-u',
---- a/khal/controllers.py
-+++ b/khal/controllers.py
-@@ -349,6 +349,8 @@
-                   categories=None, repeat=None, until=None, alarms=None,
-                   timezone=None, format=None, env=None):
-     """Create a new event from arguments and add to vdirs"""
-+    if isinstance(categories, str):
-+        categories = list([category.strip() for category in categories.split(',')])
-     try:
-         event = utils.new_event(
-             locale=conf['locale'], location=location, categories=categories,
-@@ -489,7 +491,10 @@
-             value = prompt(question, default)
-             if allow_none and value == "None":
-                 value = ""
--            getattr(event, "update_" + attr)(value)
-+            if attr == 'categories':
-+                getattr(event, "update_" + attr)(list([cat.strip() for cat in value.split(',')]))
-+            else:
-+                getattr(event, "update_" + attr)(value)
-             edited = True
- 
-         if edited:
---- a/khal/khalendar/event.py
-+++ b/khal/khalendar/event.py
-@@ -409,13 +409,16 @@
- 
-     @property
-     def categories(self):
--        return self._vevents[self.ref].get('CATEGORIES', '')
-+        try:
-+            return self._vevents[self.ref].get('CATEGORIES', '').to_ical().decode('utf-8')
-+        except AttributeError:
-+            return ''
- 
-     def update_categories(self, categories):
--        if categories.strip():
--            self._vevents[self.ref]['CATEGORIES'] = categories
--        else:
--            self._vevents[self.ref].pop('CATEGORIES', False)
-+        assert isinstance(categories, list)
-+        self._vevents[self.ref].pop('CATEGORIES', False)
-+        if categories:
-+            self._vevents[self.ref].add('CATEGORIES', categories)
- 
-     @property
-     def description(self):
---- a/khal/ui/editor.py
-+++ b/khal/ui/editor.py
-@@ -414,7 +414,7 @@
-         self.event.update_summary(self.summary.get_edit_text())
-         self.event.update_description(self.description.get_edit_text())
-         self.event.update_location(self.location.get_edit_text())
--        self.event.update_categories(self.categories.get_edit_text())
-+        self.event.update_categories(self.categories.get_edit_text().split(','))
- 
-         if self.startendeditor.changed:
-             self.event.update_start_end(
---- a/setup.py
-+++ b/setup.py
-@@ -11,7 +11,7 @@
- 
- requirements = [
-     'click>=3.2',
--    'icalendar',
-+    'icalendar>=4.0.3',
-     'urwid',
-     'pyxdg',
-     'pytz',
---- a/tests/event_test.py
-+++ b/tests/event_test.py
-@@ -55,7 +55,7 @@
-     event.update_summary('A not so simple Event')
-     event.update_description('Everything has changed')
-     event.update_location('anywhere')
--    event.update_categories('meeting')
-+    event.update_categories(['meeting'])
-     assert normalize_component(event.raw) == normalize_component(event_updated.raw)
- 
- 
-@@ -95,7 +95,7 @@
- def test_update_remove_categories():
-     event = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS)
-     event_nocat = Event.fromString(_get_text('event_dt_simple_nocat'), **EVENT_KWARGS)
--    event.update_categories('    ')
-+    event.update_categories([])
-     assert normalize_component(event.raw) == normalize_component(event_nocat.raw)
- 
- 
---- a/tests/utils_test.py
-+++ b/tests/utils_test.py
-@@ -564,7 +564,7 @@
-             event = _construct_event(data_list.split(),
-                                      description='please describe the event',
-                                      location='in the office',
--                                     categories='boring meeting',
-+                                     categories=['boring meeting'],
-                                      locale=LOCALE_BERLIN)
-             assert _replace_uid(event).to_ical() == vevent
- 
diff -Nru khal-0.9.10/debian/patches/0000-20201002~36a891a-create-default-calendar-in-config-wizard.patch khal-0.10.2/debian/patches/0000-20201002~36a891a-create-default-calendar-in-config-wizard.patch
--- khal-0.9.10/debian/patches/0000-20201002~36a891a-create-default-calendar-in-config-wizard.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201002~36a891a-create-default-calendar-in-config-wizard.patch	2021-01-26 10:51:07.000000000 +0100
@@ -0,0 +1,121 @@
+commit 36a891a2173a598e801382be85a214118ed68901
+Author: Christian Geier <geier at lostpackets.de>
+Date:   Fri Oct 2 21:57:56 2020 +0200
+
+    Create a default calendar in config wizard
+    
+    Should improve the experience for first time users.
+    
+    fix #935
+
+--- a/khal/configwizard.py
++++ b/khal/configwizard.py
+@@ -28,7 +28,7 @@
+ from os.path import exists, expanduser, expandvars, isdir, join, normpath
+ 
+ import xdg
+-from click import UsageError, confirm, prompt
++from click import UsageError, confirm, prompt, Choice
+ 
+ from .exceptions import FatalError
+ from .settings import settings
+@@ -110,6 +110,19 @@
+     return timeformat
+ 
+ 
++def choose_default_calendar(vdirs):
++    names = [name for name, _, _ in sorted(vdirs or ())]
++    print("Which calendar do you want as a default calendar?")
++    print("(The default calendar is specified, when no calendar is specified.)")
++    print("Configured calendars: {}".format(', '.join(names)))
++    default_calendar = prompt(
++        "Please type one of the above options",
++        default=names[0],
++        type=Choice(names),
++    )
++    return default_calendar
++
++
+ def get_vdirs_from_vdirsyncer_config():
+     """trying to load vdirsyncer's config and read all vdirs from it"""
+     print("If you use vdirsyncer to sync with CalDAV servers, we can try to "
+@@ -171,7 +184,7 @@
+     return [(name, path, 'calendar')]
+ 
+ 
+-def create_config(vdirs, dateformat, timeformat):
++def create_config(vdirs, dateformat, timeformat, default_calendar=None):
+     config = ['[calendars]']
+     for name, path, type_ in sorted(vdirs or ()):
+         config.append('\n[[{name}]]'.format(name=name))
+@@ -187,8 +200,11 @@
+                   .format(timeformat=timeformat,
+                           dateformat=dateformat,
+                           longdateformat=dateformat))
+-
++    if default_calendar:
++        config.append('[default]')
++        config.append('default_calendar = {}\n'.format(default_calendar))
+     config = '\n'.join(config)
++
+     return config
+ 
+ 
+@@ -215,7 +231,16 @@
+     if not vdirs:
+         print("\nWARNING: no vdir configured, khal will not be usable like this!\n")
+ 
+-    config = create_config(vdirs, dateformat=dateformat, timeformat=timeformat)
++    print()
++    if vdirs:
++        default_calendar = choose_default_calendar(vdirs)
++    else:
++        default_calendar = None
++
++    config = create_config(
++        vdirs, dateformat=dateformat, timeformat=timeformat,
++        default_calendar=default_calendar,
++    )
+     config_path = join(xdg.BaseDirectory.xdg_config_home, 'khal', 'config')
+     if not confirm(
+             "Do you want to write the config to {}? "
+--- a/tests/cli_test.py
++++ b/tests/cli_test.py
+@@ -553,6 +553,7 @@
+ def choices(dateformat=0, timeformat=0,
+             parse_vdirsyncer_conf=True,
+             create_vdir=False,
++            default_calendar='',
+             write_config=True):
+     """helper function to generate input for testing `configure`"""
+     confirm = {True: 'y', False: 'n'}
+@@ -563,7 +564,9 @@
+     ]
+     if not parse_vdirsyncer_conf:
+         out.append(confirm[create_vdir])
++    out.append(default_calendar)
+     out.append(confirm[write_config])
++    out.append('')
+     return '\n'.join(out)
+ 
+ 
+@@ -642,6 +645,9 @@
+ longdateformat = %Y-%m-%d
+ datetimeformat = %Y-%m-%d %H:%M
+ longdatetimeformat = %Y-%m-%d %H:%M
++
++[default]
++default_calendar = events_local
+ '''
+ 
+     # if aborting, no config file should be written
+@@ -735,6 +741,9 @@
+ longdateformat = %Y-%m-%d
+ datetimeformat = %Y-%m-%d %H:%M
+ longdatetimeformat = %Y-%m-%d %H:%M
++
++[default]
++default_calendar = private
+ '''.format(runner.xdg_data_home)
+ 
+     # running configure again, should yield another vdir path, as the old
diff -Nru khal-0.9.10/debian/patches/0000-20201026~1e7971e-change-default-format-of-printics-to-event_format.patch khal-0.10.2/debian/patches/0000-20201026~1e7971e-change-default-format-of-printics-to-event_format.patch
--- khal-0.9.10/debian/patches/0000-20201026~1e7971e-change-default-format-of-printics-to-event_format.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201026~1e7971e-change-default-format-of-printics-to-event_format.patch	2021-01-26 10:51:08.000000000 +0100
@@ -0,0 +1,28 @@
+commit 1e7971e9867a070098187f19d23dba4303f39a76
+Author: Dennis Dast <mail at ddast.de>
+Date:   Mon Oct 26 22:54:43 2020 +0100
+
+    Change default format of printics to event_format
+
+--- a/khal/controllers.py
++++ b/khal/controllers.py
+@@ -622,7 +622,7 @@
+ 
+ def print_ics(conf, name, ics, format):
+     if format is None:
+-        format = conf['view']['agenda_event_format']
++        format = conf['view']['event_format']
+     cal = cal_from_ics(ics)
+     events = [item for item in cal.walk() if item.name == 'VEVENT']
+     events_grouped = defaultdict(list)
+--- a/tests/cli_test.py
++++ b/tests/cli_test.py
+@@ -705,7 +705,7 @@
+     runner = runner(command='printics')
+     result = runner.invoke(main_khal, ['printics'], input=_get_text('cal_d'))
+     assert not result.exception
+-    assert '1 events found in stdin input\n An Event\n' in result.output
++    assert '1 events found in stdin input\n09.04.-09.04. An Event\n' in result.output
+ 
+ 
+ def test_configure_command_config_exists(runner):
diff -Nru khal-0.9.10/debian/patches/0000-20201106~235c796-editfc-replaced-by-edit-focused.patch khal-0.10.2/debian/patches/0000-20201106~235c796-editfc-replaced-by-edit-focused.patch
--- khal-0.9.10/debian/patches/0000-20201106~235c796-editfc-replaced-by-edit-focused.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201106~235c796-editfc-replaced-by-edit-focused.patch	2021-01-26 10:51:10.000000000 +0100
@@ -0,0 +1,46 @@
+commit 235c796203b4c152b915d643fd6f38f76ee3b1f1
+Author: pizzacoca <pizzacoca at aquilenet.fr>
+Date:   Wed Nov 6 19:13:53 2019 +0100
+
+    issue 895 'editfc' replaced by 'edit focused'
+
+--- a/khal/ui/colors.py
++++ b/khal/ui/colors.py
+@@ -51,7 +51,6 @@
+     ('frame focus color', 'dark blue', 'black'),
+     ('frame focus top', 'dark magenta', 'black'),
+ 
+-    ('editfc', 'white', 'dark blue', 'bold'),
+     ('editbx', 'light gray', 'dark blue'),
+     ('editcp', 'black', 'light gray', 'standout'),
+     ('popupbg', 'white', 'black', 'bold'),
+@@ -87,7 +86,6 @@
+     ('frame focus color', 'dark blue', 'white'),
+     ('frame focus top', 'dark magenta', 'white'),
+ 
+-    ('editfc', 'white', 'dark blue', 'bold'),
+     ('editbx', 'light gray', 'dark blue'),
+     ('editcp', 'black', 'light gray', 'standout'),
+     ('popupbg', 'white', 'black', 'bold'),
+--- a/khal/ui/widgets.py
++++ b/khal/ui/widgets.py
+@@ -392,7 +392,7 @@
+     def __init__(self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs):
+         assert validate
+         self._validate_func = validate
+-        self._original_widget = urwid.AttrMap(EditWidget(*args, **kwargs), 'edit', 'editf')
++        self._original_widget = urwid.AttrMap(EditWidget(*args, **kwargs), 'edit', 'edit focused')
+         super().__init__(self._original_widget)
+ 
+     @property
+--- a/tests/ui/test_editor.py
++++ b/tests/ui/test_editor.py
+@@ -15,7 +15,7 @@
+     'date header focused': 'blue',
+     'date header': 'green',
+     'default': 'black',
+-    'editf': 'red',
++    'edit focused': 'red',
+     'edit': 'blue',
+ }
+ 
diff -Nru khal-0.9.10/debian/patches/0000-20201109.1~e937dc4-support--a-and--d-in-khal-interactive.patch khal-0.10.2/debian/patches/0000-20201109.1~e937dc4-support--a-and--d-in-khal-interactive.patch
--- khal-0.9.10/debian/patches/0000-20201109.1~e937dc4-support--a-and--d-in-khal-interactive.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201109.1~e937dc4-support--a-and--d-in-khal-interactive.patch	2021-01-26 10:55:06.000000000 +0100
@@ -0,0 +1,31 @@
+commit e937dc47595314d39f784957e4cb712376acbefc
+Author: Martin Stone <martin at d7415.co.uk>
+Date:   Mon Nov 9 13:15:32 2020 +0000
+
+    Add support for -a/-d in khal interactive. Fixes #974.
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -12,6 +12,8 @@
+ ======
+ not released
+ 
++* FIX `khal interactive` now accepts -a/-d options (as documented)
++
+ 0.10.2
+ ======
+ 2020-07-29
+--- a/khal/cli.py
++++ b/khal/cli.py
+@@ -465,7 +465,10 @@
+     def interactive(ctx, include_calendar, exclude_calendar):
+         '''Interactive UI. Also launchable via `ikhal`.'''
+         controllers.interactive(
+-            build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
++            build_collection(
++                ctx.obj['conf'],
++                multi_calendar_select(ctx, include_calendar, exclude_calendar)
++            ),
+             ctx.obj['conf']
+         )
+ 
diff -Nru khal-0.9.10/debian/patches/0000-20201109.2~4621f30-strip-whitespace-from-displayname-and-color-metadata-files.patch khal-0.10.2/debian/patches/0000-20201109.2~4621f30-strip-whitespace-from-displayname-and-color-metadata-files.patch
--- khal-0.9.10/debian/patches/0000-20201109.2~4621f30-strip-whitespace-from-displayname-and-color-metadata-files.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201109.2~4621f30-strip-whitespace-from-displayname-and-color-metadata-files.patch	2021-01-26 10:55:07.000000000 +0100
@@ -0,0 +1,27 @@
+commit 4621f309dfb5945ee79665f8c3461b49dbcebcd8
+Author: Martin Stone <martin at d7415.co.uk>
+Date:   Mon Nov 9 21:48:48 2020 +0000
+
+    Strip whitespace from displayname and color metadata files. Fixes #783.
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -13,6 +13,7 @@
+ not released
+ 
+ * FIX `khal interactive` now accepts -a/-d options (as documented)
++* FIX Strip whitespace when loading `displayname` and `color` files
+ 
+ 0.10.2
+ ======
+--- a/khal/khalendar/vdir.py
++++ b/khal/khalendar/vdir.py
+@@ -267,7 +267,7 @@
+         fpath = os.path.join(self.path, key)
+         try:
+             with open(fpath, 'rb') as f:
+-                return f.read().decode(self.encoding) or None
++                return f.read().decode(self.encoding).strip() or None
+         except IOError as e:
+             if e.errno == errno.ENOENT:
+                 return None
diff -Nru khal-0.9.10/debian/patches/0000-20201109.3~4e9bdf7-warn-when-RRUL:UNTIL-is-before-DTSTART.patch khal-0.10.2/debian/patches/0000-20201109.3~4e9bdf7-warn-when-RRUL:UNTIL-is-before-DTSTART.patch
--- khal-0.9.10/debian/patches/0000-20201109.3~4e9bdf7-warn-when-RRUL:UNTIL-is-before-DTSTART.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201109.3~4e9bdf7-warn-when-RRUL:UNTIL-is-before-DTSTART.patch	2021-01-26 10:55:08.000000000 +0100
@@ -0,0 +1,74 @@
+commit 4e9bdf751cc4a73e09d34f86ef9be72bcdd6ee43
+Author: Martin Stone <martin at d7415.co.uk>
+Date:   Mon Nov 9 18:57:49 2020 +0000
+
+    Add a warning for the special case where RRULE:UNTIL is before DTSTART (#771,#790,#972)
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -14,6 +14,7 @@
+ 
+ * FIX `khal interactive` now accepts -a/-d options (as documented)
+ * FIX Strip whitespace when loading `displayname` and `color` files
++* FIX Warn when loading events with a recurrence that finishes before it starts
+ 
+ 0.10.2
+ ======
+--- a/khal/icalendar.py
++++ b/khal/icalendar.py
+@@ -257,9 +257,25 @@
+             # if python can deal with larger datetime values yet and b) pytz
+             # doesn't know any larger transition times
+             rrule._until = dt.datetime(2037, 12, 31)
+-        elif events_tz and 'Z' in rrule_param.to_ical().decode():
+-            rrule._until = pytz.UTC.localize(
+-                rrule._until).astimezone(events_tz).replace(tzinfo=None)
++        else:
++            if events_tz and 'Z' in rrule_param.to_ical().decode():
++                rrule._until = pytz.UTC.localize(
++                    rrule._until).astimezone(events_tz).replace(tzinfo=None)
++
++            # rrule._until and dtstart could be dt.date or dt.datetime. They
++            # need to be the same for comparison
++            testuntil = rrule._until
++            if (type(dtstart) == dt.date and type(testuntil) == dt.datetime):
++                testuntil = testuntil.date()
++            teststart = dtstart
++            if (type(testuntil) == dt.date and type(teststart) == dt.datetime):
++                teststart = teststart.date()
++
++            if testuntil < teststart:
++                logger.warning(
++                    '{0}: Unsupported recurrence. UNTIL is before DTSTART.\n'
++                    'This event will not be available in khal.'.format(href))
++                return False
+ 
+         rrule = map(sanitize_datetime, rrule)
+ 
+--- /dev/null
++++ b/tests/ics/event_dt_rrule_until_before_start.ics
+@@ -0,0 +1,8 @@
++BEGIN:VEVENT
++SUMMARY:Stop recurring before we start
++DTSTART;VALUE=DATE-TIME:19801109T193000
++DTEND;VALUE=DATE-TIME:19801109T203000
++RRULE:FREQ=DAILY;UNTIL=19701119T203000Z
++DTSTAMP;VALUE=DATE-TIME:20140401T234817Z
++UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOV
++END:VEVENT
+--- a/tests/khalendar_utils_test.py
++++ b/tests/khalendar_utils_test.py
+@@ -683,6 +683,13 @@
+         assert dtstart[-1] == (berlin.localize(dt.datetime(2014, 12, 3, 9, 30)),
+                                berlin.localize(dt.datetime(2014, 12, 3, 10, 30)))
+ 
++    def test_event_dt_rrule_until_before_start(self):
++        """test handling if an RRULE's UNTIL is before the event's DTSTART"""
++        vevent = _get_vevent(_get_text('event_dt_rrule_until_before_start'))
++        dtstart = icalendar_helpers.expand(vevent, berlin)
++        # TODO test for logging message
++        assert dtstart is False
++
+ 
+ simple_rdate = """BEGIN:VEVENT
+ SUMMARY:Simple Rdate
diff -Nru khal-0.9.10/debian/patches/0000-20201110~7f288f5-handle-undefined-alarm-desc.patch khal-0.10.2/debian/patches/0000-20201110~7f288f5-handle-undefined-alarm-desc.patch
--- khal-0.9.10/debian/patches/0000-20201110~7f288f5-handle-undefined-alarm-desc.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201110~7f288f5-handle-undefined-alarm-desc.patch	2021-01-26 10:55:09.000000000 +0100
@@ -0,0 +1,18 @@
+commit 7f288f511e7b444f021299400aeb3870e3829e87
+Author: Yorick Barbanneau <ephase at xieme-art.org>
+Date:   Tue Nov 10 23:23:48 2020 +0100
+
+    ikhal : Put '' in alarm desc. if it's NoneType, fix #971
+
+--- a/khal/ui/widgets.py
++++ b/khal/ui/widgets.py
+@@ -515,7 +515,8 @@
+                 duration = -1 * duration
+ 
+             self.duration = DurationWidget(duration)
+-            self.description = ExtendedEdit(edit_text=description)
++            self.description = ExtendedEdit(
++                edit_text=description if description is not None else "")
+             self.direction = Choice(
+                 ['before', 'after'], active=direction, overlay_width=10)
+             self.columns = NColumns([
diff -Nru khal-0.9.10/debian/patches/0000-20201117~0ab5247-handle-undefined-alarm-desc-update-CHANGELOG.patch khal-0.10.2/debian/patches/0000-20201117~0ab5247-handle-undefined-alarm-desc-update-CHANGELOG.patch
--- khal-0.9.10/debian/patches/0000-20201117~0ab5247-handle-undefined-alarm-desc-update-CHANGELOG.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201117~0ab5247-handle-undefined-alarm-desc-update-CHANGELOG.patch	2021-01-26 10:55:10.000000000 +0100
@@ -0,0 +1,16 @@
+commit 0ab524720ddae68e42f5d97031dc4bade2a49963
+Author: Martin Stone <martin at d7415.co.uk>
+Date:   Tue Nov 17 12:11:17 2020 +0000
+
+    Update CHANGELOG
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -15,6 +15,7 @@
+ * FIX `khal interactive` now accepts -a/-d options (as documented)
+ * FIX Strip whitespace when loading `displayname` and `color` files
+ * FIX Warn when loading events with a recurrence that finishes before it starts
++* FIX Alarms without descriptions no longer crash `ikhal`
+ 
+ 0.10.2
+ ======
diff -Nru khal-0.9.10/debian/patches/0000-20201128~3de2a46-move-all-day-events-above-other-events-in-ikhal.patch khal-0.10.2/debian/patches/0000-20201128~3de2a46-move-all-day-events-above-other-events-in-ikhal.patch
--- khal-0.9.10/debian/patches/0000-20201128~3de2a46-move-all-day-events-above-other-events-in-ikhal.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20201128~3de2a46-move-all-day-events-above-other-events-in-ikhal.patch	2021-01-26 10:55:11.000000000 +0100
@@ -0,0 +1,29 @@
+commit 3de2a4630ef6d049e4e50026b707206a213303a4
+Author: Martin Stone <martin at d7415.co.uk>
+Date:   Sat Nov 28 00:38:41 2020 +0000
+
+    Move all-day events above other events in ikhal (to match khal).
+    
+    Fixes #976. Closes #980.
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -16,6 +16,7 @@
+ * FIX Strip whitespace when loading `displayname` and `color` files
+ * FIX Warn when loading events with a recurrence that finishes before it starts
+ * FIX Alarms without descriptions no longer crash `ikhal`
++* FIX Display all-day events at the top of the day in `ikhal`
+ 
+ 0.10.2
+ ======
+--- a/khal/khalendar/khalendar.py
++++ b/khal/khalendar/khalendar.py
+@@ -150,7 +150,7 @@
+         floating_events = self.get_floating(start, end)
+         localize = self._locale['local_timezone'].localize
+         localized_events = self.get_localized(localize(start), localize(end))
+-        return itertools.chain(floating_events, localized_events)
++        return itertools.chain(localized_events, floating_events)
+ 
+     def get_calendars_on(self, day: dt.date) -> List[str]:
+         start = dt.datetime.combine(day, dt.time.min)
diff -Nru khal-0.9.10/debian/patches/0000-20210103~5e5db0b-fix-empty-search-focus-keybinding-issue-update-AUTHORS.patch khal-0.10.2/debian/patches/0000-20210103~5e5db0b-fix-empty-search-focus-keybinding-issue-update-AUTHORS.patch
--- khal-0.9.10/debian/patches/0000-20210103~5e5db0b-fix-empty-search-focus-keybinding-issue-update-AUTHORS.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20210103~5e5db0b-fix-empty-search-focus-keybinding-issue-update-AUTHORS.patch	2021-01-26 10:55:12.000000000 +0100
@@ -0,0 +1,13 @@
+commit 5e5db0b0e95bdcc78c63f565f05dcdc8a57e1376
+Author: Fred Thomsen <me at fredthomsen.net>
+Date:   Sun Jan 3 01:35:26 2021 -0500
+
+    Add name to authors
+
+--- a/AUTHORS.txt
++++ b/AUTHORS.txt
+@@ -43,3 +43,4 @@
+ Yorick Barbanneau - git [at] epha [dot] se - https://xieme-art.org
+ Florian Lassenay - pizzacoca [at] aquilenet [dot] fr
+ Simon Crespeau - simon [at] art-e-toile [dot] com - https://www.art-e-toile.com
++Fred Thomsen - me [at] fredthomsen [dot] net - http://fredthomsen.net
diff -Nru khal-0.9.10/debian/patches/0000-20210103~908ca53-fix-empty-search-focus-keybinding-issue.patch khal-0.10.2/debian/patches/0000-20210103~908ca53-fix-empty-search-focus-keybinding-issue.patch
--- khal-0.9.10/debian/patches/0000-20210103~908ca53-fix-empty-search-focus-keybinding-issue.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20210103~908ca53-fix-empty-search-focus-keybinding-issue.patch	2021-01-26 10:55:14.000000000 +0100
@@ -0,0 +1,26 @@
+commit 908ca536a998587b80596974d85a4e564c5acabb
+Author: Fred Thomsen <me at fredthomsen.net>
+Date:   Sun Jan 3 01:25:43 2021 -0500
+
+    Empty search has keybindings handled properly
+    
+    When searching for calendar events ensure that there is an event in
+    focus before trying to performing operations triggered by keybindings on
+    that event.
+    
+    Addresses issue #986.
+
+--- a/khal/ui/__init__.py
++++ b/khal/ui/__init__.py
+@@ -255,7 +255,10 @@
+ 
+     @property
+     def focus_event(self):
+-        return self.focus.original_widget
++        if self.focus is None:
++            return None
++        else:
++            return self.focus.original_widget
+ 
+     def refresh_titles(self, min_date, max_date, everything):
+         """Refresh only the currently focused event's title
diff -Nru khal-0.9.10/debian/patches/0000-20210103~da8acfb-fix-empty-search-focus-keybinding-issue-update-CHANGELOG.patch khal-0.10.2/debian/patches/0000-20210103~da8acfb-fix-empty-search-focus-keybinding-issue-update-CHANGELOG.patch
--- khal-0.9.10/debian/patches/0000-20210103~da8acfb-fix-empty-search-focus-keybinding-issue-update-CHANGELOG.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20210103~da8acfb-fix-empty-search-focus-keybinding-issue-update-CHANGELOG.patch	2021-01-26 10:55:15.000000000 +0100
@@ -0,0 +1,16 @@
+commit da8acfb8b30bc03f5026462928de5f1430566ed5
+Author: Martin Stone <1611702+d7415 at users.noreply.github.com>
+Date:   Sun Jan 3 10:32:10 2021 +0000
+
+    Update CHANGELOG.rst
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -17,6 +17,7 @@
+ * FIX Warn when loading events with a recurrence that finishes before it starts
+ * FIX Alarms without descriptions no longer crash `ikhal`
+ * FIX Display all-day events at the top of the day in `ikhal`
++* FIX Keybindings in empty search results no longer crash `ikhal`
+ 
+ 0.10.2
+ ======
diff -Nru khal-0.9.10/debian/patches/0000-20210104~645bd6b-fix-crash-on-ikhal-when-create-a-new-event.patch khal-0.10.2/debian/patches/0000-20210104~645bd6b-fix-crash-on-ikhal-when-create-a-new-event.patch
--- khal-0.9.10/debian/patches/0000-20210104~645bd6b-fix-crash-on-ikhal-when-create-a-new-event.patch	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/debian/patches/0000-20210104~645bd6b-fix-crash-on-ikhal-when-create-a-new-event.patch	2021-01-26 10:56:19.000000000 +0100
@@ -0,0 +1,29 @@
+commit 645bd6bc0e56accabb238c365e83b99a6f57a5a1
+Author: Yorick Barbanneau <ephase at xieme-art.org>
+Date:   Mon Jan 4 22:54:24 2021 +0100
+
+    Fix crash on ikhal when create a new event
+    
+     When search result is active
+
+--- a/CHANGELOG.rst
++++ b/CHANGELOG.rst
+@@ -18,6 +18,7 @@
+ * FIX Alarms without descriptions no longer crash `ikhal`
+ * FIX Display all-day events at the top of the day in `ikhal`
+ * FIX Keybindings in empty search results no longer crash `ikhal`
++* FIX `new` keybinding in search no longer crash `ikhal`
+ 
+ 0.10.2
+ ======
+--- a/khal/ui/__init__.py
++++ b/khal/ui/__init__.py
+@@ -874,6 +874,8 @@
+         if not self.pane.collection.writable_names:
+             self.pane.window.alert(('alert', 'No writable calendar.'))
+             return
++        if date is None:
++            date = dt.datetime.now()
+         if end is None:
+             start = dt.datetime.combine(date, dt.time(dt.datetime.now().hour))
+             end = start + dt.timedelta(minutes=60)
diff -Nru khal-0.9.10/debian/patches/0001-No-RSS-news-in-docs.patch khal-0.10.2/debian/patches/0001-No-RSS-news-in-docs.patch
--- khal-0.9.10/debian/patches/0001-No-RSS-news-in-docs.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0001-No-RSS-news-in-docs.patch	2021-01-26 10:56:20.000000000 +0100
@@ -8,11 +8,9 @@
  doc/source/index.rst | 1 -
  2 files changed, 1 insertion(+), 3 deletions(-)
 
-diff --git a/doc/source/conf.py b/doc/source/conf.py
-index d544c1e..858faf0 100644
 --- a/doc/source/conf.py
 +++ b/doc/source/conf.py
-@@ -102,8 +102,7 @@ with open('configspec.rst', 'w') as f:
+@@ -101,8 +101,7 @@
  extensions = [
      'sphinx.ext.autodoc',
      'sphinx.ext.intersphinx',
@@ -22,15 +20,10 @@
  ]
  
  # Add any paths that contain templates here, relative to this directory.
-diff --git a/doc/source/index.rst b/doc/source/index.rst
-index 8118c65..5b90dee 100644
 --- a/doc/source/index.rst
 +++ b/doc/source/index.rst
-@@ -45,4 +45,3 @@ Table of Contents
+@@ -45,4 +45,3 @@
     changelog
     faq
     license
 -   news
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/0003-Fix-intersphinx-mapping.patch khal-0.10.2/debian/patches/0003-Fix-intersphinx-mapping.patch
--- khal-0.9.10/debian/patches/0003-Fix-intersphinx-mapping.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0003-Fix-intersphinx-mapping.patch	2021-01-26 10:56:21.000000000 +0100
@@ -7,16 +7,11 @@
  doc/source/conf.py | 2 +-
  1 file changed, 1 insertion(+), 1 deletion(-)
 
-diff --git a/doc/source/conf.py b/doc/source/conf.py
-index 858faf0..8e37071 100644
 --- a/doc/source/conf.py
 +++ b/doc/source/conf.py
-@@ -348,4 +348,4 @@ texinfo_documents = [
+@@ -347,4 +347,4 @@
  
  
  # Example configuration for intersphinx: refer to the Python standard library.
 -intersphinx_mapping = {'http://docs.python.org/': None}
 +intersphinx_mapping = {'python': ('/usr/share/doc/python3-doc/html', None)}
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/0004-Fix-tests-failing-due-to-timezone.patch khal-0.10.2/debian/patches/0004-Fix-tests-failing-due-to-timezone.patch
--- khal-0.9.10/debian/patches/0004-Fix-tests-failing-due-to-timezone.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0004-Fix-tests-failing-due-to-timezone.patch	1970-01-01 01:00:00.000000000 +0100
@@ -1,103 +0,0 @@
-From 89ae1a80ef67f5cf5c99e974a33e6fede2cb647a Mon Sep 17 00:00:00 2001
-From: Filip Pytloun <filip at pytloun.cz>
-Date: Fri, 12 Aug 2016 21:06:36 +0200
-Subject: [PATCH 4/6] Fix tests failing due to timezone
-
----
- tests/cal_display_test.py       | 1 +
- tests/khalendar_utils_test.py   | 4 ++++
- tests/ui/test_calendarwidget.py | 2 ++
- tests/utils_test.py             | 2 ++
- 4 files changed, 9 insertions(+)
-
-diff --git a/tests/cal_display_test.py b/tests/cal_display_test.py
-index 74ca501..4ff3e5e 100644
---- a/tests/cal_display_test.py
-+++ b/tests/cal_display_test.py
-@@ -190,6 +190,7 @@ example_fr = [
-     '\x1b[1mmars  \x1b[0m27 28 29  1  2  3  4 ']
- 
- 
-+ at pytest.mark.skip
- def test_vertical_month():
-     try:
-         locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
-diff --git a/tests/khalendar_utils_test.py b/tests/khalendar_utils_test.py
-index f3170a6..b1932b7 100644
---- a/tests/khalendar_utils_test.py
-+++ b/tests/khalendar_utils_test.py
-@@ -1,6 +1,7 @@
- from datetime import date, datetime, timedelta
- import icalendar
- import pytz
-+import pytest
- 
- from khal.khalendar import utils
- 
-@@ -317,6 +318,7 @@ class TestExpand(object):
-                 for start, _ in dtstart] == self.offset_berlin
-         assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin
- 
-+    @pytest.mark.skip
-     def test_expand_dtb(self):
-         vevent = _get_vevent(event_dtb)
-         dtstart = utils.expand(vevent, berlin)
-@@ -403,6 +405,7 @@ class TestExpandNoRR(object):
-                 for start, _ in dtstart] == self.offset_berlin
-         assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin
- 
-+    @pytest.mark.skip
-     def test_expand_dtb(self):
-         vevent = _get_vevent(event_dtb_norr)
-         dtstart = utils.expand(vevent, berlin)
-@@ -610,6 +613,7 @@ class TestSpecial(object):
-         assert dtstart[0][0] == date(2009, 10, 31)
-         assert dtstart[-1][0] == date(2037, 10, 31)
- 
-+    @pytest.mark.skip
-     def test_recurrence_id_with_timezone(self):
-         vevent = _get_vevent(recurrence_id_with_timezone)
-         dtstart = utils.expand(vevent, berlin)
-diff --git a/tests/ui/test_calendarwidget.py b/tests/ui/test_calendarwidget.py
-index 25cc58a..123b2f9 100644
---- a/tests/ui/test_calendarwidget.py
-+++ b/tests/ui/test_calendarwidget.py
-@@ -1,6 +1,7 @@
- from datetime import date, timedelta
- 
- from freezegun import freeze_time
-+import pytest
- 
- from khal.ui.calendarwidget import CalendarWidget
- 
-@@ -36,6 +37,7 @@ def test_set_focus_date():
-         assert frame.focus_date == day
- 
- 
-+ at pytest.mark.skip
- def test_set_focus_date_weekstart_6():
- 
-     with freeze_time('2016-04-10'):
-diff --git a/tests/utils_test.py b/tests/utils_test.py
-index 6e1fe53..5e4a0fd 100644
---- a/tests/utils_test.py
-+++ b/tests/utils_test.py
-@@ -569,6 +569,7 @@ def test_description_and_location_and_categories():
-             assert _replace_uid(event).to_ical() == vevent
- 
- 
-+ at pytest.mark.skip
- def test_split_ics():
-     cal = _get_text('cal_lots_of_timezones')
-     vevents = utils.split_ics(cal)
-@@ -586,6 +587,7 @@ def test_split_ics():
-     assert sorted(vevents1) == sorted(part1)
- 
- 
-+ at pytest.mark.skip
- def test_split_ics_random_uid():
-     random.seed(123)
-     cal = _get_text('cal_lots_of_timezones')
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/0005-Avoid-privacy-breach-in-sphinx-doc.patch khal-0.10.2/debian/patches/0005-Avoid-privacy-breach-in-sphinx-doc.patch
--- khal-0.9.10/debian/patches/0005-Avoid-privacy-breach-in-sphinx-doc.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0005-Avoid-privacy-breach-in-sphinx-doc.patch	2021-01-26 10:57:28.000000000 +0100
@@ -8,11 +8,9 @@
  doc/source/index.rst |  2 --
  2 files changed, 5 insertions(+), 7 deletions(-)
 
-diff --git a/doc/source/conf.py b/doc/source/conf.py
-index 8e37071..0f94c3f 100644
 --- a/doc/source/conf.py
 +++ b/doc/source/conf.py
-@@ -180,11 +180,11 @@ html_theme = 'alabaster'
+@@ -179,11 +179,11 @@
  # Theme options are theme-specific and customize the look and feel of a theme
  # further.  For a list of options available for each theme, see the
  # documentation.
@@ -29,11 +27,9 @@
  
  # Add any paths that contain custom themes here, relative to this directory.
  #html_theme_path = []
-diff --git a/doc/source/index.rst b/doc/source/index.rst
-index 5b90dee..8e684e0 100644
 --- a/doc/source/index.rst
 +++ b/doc/source/index.rst
-@@ -4,8 +4,6 @@ khal
+@@ -4,8 +4,6 @@
  *Khal* is a standards based CLI (console) calendar program, able to synchronize
  with CalDAV_ servers through vdirsyncer_.
  
@@ -42,6 +38,3 @@
  Features
  --------
  (or rather: limitations)
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/0006-Timezone-tests-may-fail-due-to-older-pytz-with-newer.patch khal-0.10.2/debian/patches/0006-Timezone-tests-may-fail-due-to-older-pytz-with-newer.patch
--- khal-0.9.10/debian/patches/0006-Timezone-tests-may-fail-due-to-older-pytz-with-newer.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0006-Timezone-tests-may-fail-due-to-older-pytz-with-newer.patch	1970-01-01 01:00:00.000000000 +0100
@@ -1,45 +0,0 @@
-From 22cb5d66d02464770eb402ec66850864632d653f Mon Sep 17 00:00:00 2001
-From: Filip Pytloun <filip at pytloun.cz>
-Date: Thu, 20 Apr 2017 20:54:05 +0200
-Subject: [PATCH 6/6] Timezone tests may fail due to older pytz with newer TZ
- definitions
-
----
- tests/event_test.py     | 1 +
- tests/vtimezone_test.py | 2 ++
- 2 files changed, 3 insertions(+)
-
-diff --git a/tests/event_test.py b/tests/event_test.py
-index 78ad1f0..3b83aa4 100644
---- a/tests/event_test.py
-+++ b/tests/event_test.py
-@@ -299,6 +299,7 @@ def test_event_dt_long():
-         '09.04.2014 09:30-12.04.2014 10:30 An Event\x1b[0m'
- 
- 
-+ at pytest.mark.xfail
- def test_event_no_dst(pytz_version):
-     """test the creation of a corect VTIMEZONE for timezones with no dst"""
-     event_no_dst = _get_text('event_no_dst')
-diff --git a/tests/vtimezone_test.py b/tests/vtimezone_test.py
-index d810c64..e4d0fb8 100644
---- a/tests/vtimezone_test.py
-+++ b/tests/vtimezone_test.py
-@@ -1,5 +1,6 @@
- from datetime import datetime as datetime
- import pytz
-+import pytest
- from khal.khalendar.event import create_timezone
- 
- berlin = pytz.timezone('Europe/Berlin')
-@@ -61,6 +62,7 @@ def test_berlin_rdate():
-     assert vberlin_dst in vberlin
- 
- 
-+ at pytest.mark.xfail
- def test_bogota(pytz_version):
-     vbogota = [b'BEGIN:VTIMEZONE',
-                b'TZID:America/Bogota',
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/0007-Workaround-test-of-stdin-input.patch khal-0.10.2/debian/patches/0007-Workaround-test-of-stdin-input.patch
--- khal-0.9.10/debian/patches/0007-Workaround-test-of-stdin-input.patch	2019-03-26 09:10:36.000000000 +0100
+++ khal-0.10.2/debian/patches/0007-Workaround-test-of-stdin-input.patch	1970-01-01 01:00:00.000000000 +0100
@@ -1,32 +0,0 @@
-From fa15c88cf67ab860b3f60761dea11b39b53d980a Mon Sep 17 00:00:00 2001
-From: Filip Pytloun <filip at pytloun.cz>
-Date: Tue, 18 Jul 2017 22:50:57 +0200
-Subject: [PATCH 7/7] Workaround test of stdin input
-
----
- tests/cli_test.py | 2 ++
- 1 file changed, 2 insertions(+)
-
-diff --git a/tests/cli_test.py b/tests/cli_test.py
-index 9c98899..140455e 100644
---- a/tests/cli_test.py
-+++ b/tests/cli_test.py
-@@ -497,6 +497,7 @@ def test_import_invalid_choice_and_prefix(runner):
-     assert result.output == '09.04.-09.04. An Event\n'
- 
- 
-+ at pytest.mark.xfail
- def test_import_from_stdin(runner):
-     ics_data = 'This is some really fake icalendar data'
- 
-@@ -681,6 +682,7 @@ def test_print_ics_command(runner):
-     assert 24 == len(result.output.split('\t'))
- 
- 
-+ at pytest.mark.xfail
- def test_printics_read_from_stdin(runner):
-     runner = runner(command='printics')
-     result = runner.invoke(main_khal, ['printics'], input=_get_text('cal_d'))
--- 
-2.11.0
-
diff -Nru khal-0.9.10/debian/patches/series khal-0.10.2/debian/patches/series
--- khal-0.9.10/debian/patches/series	2019-03-26 09:25:59.000000000 +0100
+++ khal-0.10.2/debian/patches/series	2021-01-26 12:11:55.000000000 +0100
@@ -1,7 +1,16 @@
-0000-20190325~c58fb88-fix-parse-categories-as-list.patch
+0000-20201002~36a891a-create-default-calendar-in-config-wizard.patch
+0000-20201026~1e7971e-change-default-format-of-printics-to-event_format.patch
+0000-20201106~235c796-editfc-replaced-by-edit-focused.patch
+0000-20201109.1~e937dc4-support--a-and--d-in-khal-interactive.patch
+0000-20201109.2~4621f30-strip-whitespace-from-displayname-and-color-metadata-files.patch
+0000-20201109.3~4e9bdf7-warn-when-RRUL:UNTIL-is-before-DTSTART.patch
+0000-20201110~7f288f5-handle-undefined-alarm-desc.patch
+0000-20201117~0ab5247-handle-undefined-alarm-desc-update-CHANGELOG.patch
+0000-20201128~3de2a46-move-all-day-events-above-other-events-in-ikhal.patch
+0000-20210103~5e5db0b-fix-empty-search-focus-keybinding-issue-update-AUTHORS.patch
+0000-20210103~908ca53-fix-empty-search-focus-keybinding-issue.patch
+0000-20210103~da8acfb-fix-empty-search-focus-keybinding-issue-update-CHANGELOG.patch
+0000-20210104~645bd6b-fix-crash-on-ikhal-when-create-a-new-event.patch
 0001-No-RSS-news-in-docs.patch
 0003-Fix-intersphinx-mapping.patch
-0004-Fix-tests-failing-due-to-timezone.patch
 0005-Avoid-privacy-breach-in-sphinx-doc.patch
-0006-Timezone-tests-may-fail-due-to-older-pytz-with-newer.patch
-0007-Workaround-test-of-stdin-input.patch
diff -Nru khal-0.9.10/doc/source/configspec.rst khal-0.10.2/doc/source/configspec.rst
--- khal-0.9.10/doc/source/configspec.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/configspec.rst	1970-01-01 01:00:00.000000000 +0100
@@ -1,645 +0,0 @@
-
-The [calendars] section
-~~~~~~~~~~~~~~~~~~~~~~~
-
-The *[calendars]* section is mandatory and must contain at least one subsection.
-Every subsection must have a unique name (enclosed by two square brackets).
-Each subsection needs exactly one *path* setting, everything else is optional.
-Here is a small example:
-
-.. literalinclude:: ../../tests/configs/small.conf
- :language: ini
-
-.. _calendars-color:
-
-.. object:: color
-
-    
-    khal will use this color for coloring this calendar's event.
-    The following color names are supported: *black*, *white*, *brown*, *yellow*,
-    *dark gray*, *dark green*, *dark blue*, *light gray*, *light green*, *light
-    blue*, *dark magenta*, *dark cyan*, *dark red*, *light magenta*, *light
-    cyan*, *light red*.
-    Depending on your terminal emulator's settings, they might look different
-    than what their name implies.
-    In addition to the 16 named colors an index from the 256-color palette or a
-    24-bit color code can be used, if your terminal supports this.
-    The 256-color palette index is simply a number between 0 and 255.
-    The 24-bit color must be given as #RRGGBB, where RR, GG, BB is the
-    hexadecimal value of the red, green and blue component, respectively.
-    When using a 24-bit color, make sure to enclose the color value in ' or "!
-    If the color is set to *auto* (the default), khal tries to read the file
-    *color* from this calendar's vdir, if this fails the default_color (see
-    below) is used. If color is set to '', the default_color is always used.
-
-      :type: color
-      :default: auto
-
-.. _calendars-path:
-
-.. object:: path
-
-    The path to an existing directory where this calendar is saved as a *vdir*.
-    The directory is searched for events or birthdays (see ``type``). The path
-    also accepts glob expansion via `*` or `?` when type is set to discover.
-    This allows for paths such as `~/accounts/*/calendars/*`, where the
-    calendars directory contains vdir directories. In addition, `~/calendars/*`
-    and `~/calendars/default` are valid paths if there exists a vdir in the
-    `default` directory. (The previous behavior of recursively searching
-    directories has been replaced with globbing).
-
-      :type: string
-      :default: None
-
-.. _calendars-readonly:
-
-.. object:: readonly
-
-    
-    setting this to *True*, will keep khal from making any changes to this
-    calendar
-
-      :type: boolean
-      :default: False
-
-.. _calendars-type:
-
-.. object:: type
-
-    
-    Setting the type of this collection (default ``calendar``).
-    
-    If set to ``calendar`` (the default), this collection will be used as a
-    standard calendar, that is, only files with the ``.ics`` extension will be
-    considered, all other files are ignored (except for a possible `color` file).
-    
-    If set to ``birthdays`` khal will expect a VCARD collection and extract
-    birthdays from those VCARDS, that is only files with ``.ics`` extension will
-    be considered, all other files will be ignored.  ``birthdays`` also implies
-    ``readonly=True``.
-    
-    If set to ``discover``, khal will use
-    `globbing <https://en.wikipedia.org/wiki/Glob_(programming)>`_ to expand this
-    calendar's `path` to (possibly) several paths and use those as individual
-    calendars (this cannot be used with `birthday` collections`). See `Exemplary
-    discover usage`_ for an example.
-    
-    If an individual calendar vdir has a `color` file, the calendar's color will
-    be set to the one specified in the `color` file, otherwise the color from the
-    *calendars* subsection will be used.
-
-      :type: option, allowed values are *calendar*, *birthdays* and *discover*
-      :default: calendar
-
-The [default] section
-~~~~~~~~~~~~~~~~~~~~~
-
-
-Some default values and behaviors are set here.
-
-.. _default-default_calendar:
-
-.. object:: default_calendar
-
-    
-    The calendar to use if none is specified for some operation (e.g. if adding a
-    new event). If this is not set, such operations require an explicit value.
-
-      :type: string
-      :default: None
-
-.. _default-default_command:
-
-.. object:: default_command
-
-    
-    Command to be executed if no command is given when executing khal.
-
-      :type: option, allowed values are *calendar*, *list*, *interactive*, *printformats*, *printcalendars*, *printics* and **
-      :default: calendar
-
-.. _default-highlight_event_days:
-
-.. object:: highlight_event_days
-
-    
-    If true, khal will highlight days with events. Options for
-    highlighting are in [highlight_days] section.
-
-      :type: boolean
-      :default: False
-
-.. _default-print_new:
-
-.. object:: print_new
-
-    
-    After adding a new event, what should be printed to standard out? The whole
-    event in text form, the path to where the event is now saved or nothing?
-
-      :type: option, allowed values are *event*, *path* and *False*
-      :default: False
-
-.. _default-show_all_days:
-
-.. object:: show_all_days
-
-    
-    By default, khal displays only dates with events in `list` or `calendar`
-    view.  Setting this to *True* will show all days, even when there is no event
-    scheduled on that day.
-
-      :type: boolean
-      :default: False
-
-.. _default-timedelta:
-
-.. object:: timedelta
-
-    
-    Controls for how many days into the future we show events (for example, in
-    `khal list`) by default.
-
-      :type: timedelta
-      :default: 2d
-
-The [highlight_days] section
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-When highlight_event_days is enabled, this section specifies how
-the highlighting/coloring of days is handled.
-
-.. _highlight_days-color:
-
-.. object:: color
-
-    
-    What color to use when highlighting -- explicit color or use calendar
-    color when set to ''
-
-      :type: color
-      :default: 
-
-.. _highlight_days-default_color:
-
-.. object:: default_color
-
-    
-    Default color for calendars without color -- when set to '' it
-    actually disables highlighting for events that should use the
-    default color.
-
-      :type: color
-      :default: 
-
-.. _highlight_days-method:
-
-.. object:: method
-
-    
-    Highlighting method to use -- foreground or background
-
-      :type: option, allowed values are *foreground*, *fg*, *background* and *bg*
-      :default: fg
-
-.. _highlight_days-multiple:
-
-.. object:: multiple
-
-    
-    How to color days with events from multiple calendars -- either
-    explicit color or use calendars' colors when set to ''
-
-      :type: color
-      :default: 
-
-The [keybindings] section
-~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Keybindings for :command:`ikhal` are set here. You can bind more then one key
-(combination) to a command by supplying a comma-separated list of keys.
-For binding key combinations concatenate them keys (with a space in
-between), e.g. **ctrl n**.
-
-.. _keybindings-delete:
-
-.. object:: delete
-
-    
-    delete the currently selected event
-
-      :type: list
-      :default: d
-
-.. _keybindings-down:
-
-.. object:: down
-
-    
-    move the cursor down (in the calendar browser)
-
-      :type: list
-      :default: down, j
-
-.. _keybindings-duplicate:
-
-.. object:: duplicate
-
-    
-    duplicate the currently selected event
-
-      :type: list
-      :default: p
-
-.. _keybindings-export:
-
-.. object:: export
-
-    
-    export event as a .ics file
-
-      :type: list
-      :default: e
-
-.. _keybindings-external_edit:
-
-.. object:: external_edit
-
-    
-    edit the currently selected events' raw .ics file with $EDITOR
-    Only use this, if you know what you are doing, the icalendar library we use
-    doesn't do a lot of validation, it silently disregards most invalid data.
-
-      :type: list
-      :default: meta E
-
-.. _keybindings-left:
-
-.. object:: left
-
-    
-    move the cursor left (in the calendar browser)
-
-      :type: list
-      :default: left, h, backspace
-
-.. _keybindings-mark:
-
-.. object:: mark
-
-    
-    go into highlight (visual) mode to choose a date range
-
-      :type: list
-      :default: v
-
-.. _keybindings-new:
-
-.. object:: new
-
-    
-    create a new event on the selected date
-
-      :type: list
-      :default: n
-
-.. _keybindings-other:
-
-.. object:: other
-
-    
-    in highlight mode go to the other end of the highlighted date range
-
-      :type: list
-      :default: o
-
-.. _keybindings-quit:
-
-.. object:: quit
-
-    
-    quit
-
-      :type: list
-      :default: q, Q
-
-.. _keybindings-right:
-
-.. object:: right
-
-    
-    move the cursor right (in the calendar browser)
-
-      :type: list
-      :default: right, l, space
-
-.. _keybindings-save:
-
-.. object:: save
-
-    
-    save the currently edited event and leave the event editor
-
-      :type: list
-      :default: meta enter
-
-.. _keybindings-search:
-
-.. object:: search
-
-    
-    open a text field to start a search for events
-
-      :type: list
-      :default: /
-
-.. _keybindings-today:
-
-.. object:: today
-
-    
-    focus the calendar browser on today
-
-      :type: list
-      :default: t
-
-.. _keybindings-up:
-
-.. object:: up
-
-    
-    move the cursor up (in the calendar browser)
-
-      :type: list
-      :default: up, k
-
-.. _keybindings-view:
-
-.. object:: view
-
-    
-    show details or edit (if details are already shown) the currently selected event
-
-      :type: list
-      :default: enter
-
-The [locale] section
-~~~~~~~~~~~~~~~~~~~~
-
-It is mandatory to set (long)date-, time-, and datetimeformat options, all others options in the **[locale]** section are optional and have (sensible) defaults.
-
-.. _locale-dateformat:
-
-.. object:: dateformat
-
-    
-    khal will display and understand all dates in this format, see :ref:`timeformat <locale-timeformat>` for the format
-
-      :type: string
-      :default: %d.%m.
-
-.. _locale-datetimeformat:
-
-.. object:: datetimeformat
-
-    
-    khal will display and understand all datetimes in this format, see
-    :ref:`timeformat <locale-timeformat>` for the format.
-
-      :type: string
-      :default: %d.%m. %H:%M
-
-.. _locale-default_timezone:
-
-.. object:: default_timezone
-
-    
-    this timezone will be used for new events (when no timezone is specified) and
-    when khal does not understand the timezone specified in the icalendar file.
-    If no timezone is set, the timezone your computer is set to will be used.
-
-      :type: timezone
-      :default: None
-
-.. _locale-firstweekday:
-
-.. object:: firstweekday
-
-    
-    the first day of the week, were Monday is 0 and Sunday is 6
-
-      :type: integer, allowed values are between 0 and 6
-      :default: 0
-
-.. _locale-local_timezone:
-
-.. object:: local_timezone
-
-    
-    khal will show all times in this timezone
-    If no timezone is set, the timezone your computer is set to will be used.
-
-      :type: timezone
-      :default: None
-
-.. _locale-longdateformat:
-
-.. object:: longdateformat
-
-    
-    khal will display and understand all dates in this format, it should
-    contain a year (e.g. *%Y*) see :ref:`timeformat <locale-timeformat>` for the format.
-
-      :type: string
-      :default: %d.%m.%Y
-
-.. _locale-longdatetimeformat:
-
-.. object:: longdatetimeformat
-
-    
-    khal will display and understand all datetimes in this format, it should
-    contain a year (e.g. *%Y*) see :ref:`timeformat <locale-timeformat>` for the format.
-
-      :type: string
-      :default: %d.%m.%Y %H:%M
-
-.. _locale-timeformat:
-
-.. object:: timeformat
-
-    
-    khal will display and understand all times in this format.
-    
-    The formatting string is interpreted as defined by Python's `strftime
-    <https://docs.python.org/2/library/time.html#time.strftime>`_, which is
-    similar to the format specified in ``man strftime``.
-
-      :type: string
-      :default: %H:%M
-
-.. _locale-unicode_symbols:
-
-.. object:: unicode_symbols
-
-    
-    by default khal uses some unicode symbols (as in 'non-ascii') as indicators for things like repeating events,
-    if your font, encoding etc. does not support those symbols, set this to *False* (this will enable ascii based replacements).
-
-      :type: boolean
-      :default: True
-
-.. _locale-weeknumbers:
-
-.. object:: weeknumbers
-
-    
-    
-    Enable weeknumbers in `calendar` and `interactive` (ikhal) mode. As those are
-    iso weeknumbers, they only work properly if `firstweekday` is set to 0
-
-      :type: weeknumbers
-      :default: off
-
-The [sqlite] section
-~~~~~~~~~~~~~~~~~~~~
-
-
-.. _sqlite-path:
-
-.. object:: path
-
-    khal stores its internal caching database here, by default this will be in the *$XDG_DATA_HOME/khal/khal.db* (this will most likely be *~/.local/share/khal/khal.db*).
-
-      :type: string
-      :default: None
-
-The [view] section
-~~~~~~~~~~~~~~~~~~
-
-The view section contains configuration options that effect the visual appearance
-when using khal and ikhal.
-
-.. _view-agenda_day_format:
-
-.. object:: agenda_day_format
-
-    
-    Specifies how each *day header* is formatted.
-
-      :type: string
-      :default: {bold}{name}, {date-long}{reset}
-
-.. _view-agenda_event_format:
-
-.. object:: agenda_event_format
-
-    
-    Default formatting for events used when the user asks for all events in a
-    given time range, used for :command:`list`, :command:`calendar` and in
-    :command:`interactive` (ikhal). Please note, that any color styling will be
-    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`.
-
-      :type: string
-      :default: {calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{description-separator}{description}{reset}
-
-.. _view-bold_for_light_color:
-
-.. object:: bold_for_light_color
-
-    
-    Whether to use bold text for light colors or not. Non-bold light colors may
-    not work on all terminals but allow using light background colors.
-
-      :type: boolean
-      :default: True
-
-.. _view-dynamic_days:
-
-.. object:: dynamic_days
-
-    
-    Defines the behaviour of ikhal's right column. If `True`, the right column
-    will show events for as many days as fit, moving the cursor through the list
-    will also select the appropriate day in the calendar column on the left. If
-    `False`, only a fixed ([default] timedelta) amount of days' events will be
-    shown, moving through events will not change the focus in the left column.
-
-      :type: boolean
-      :default: True
-
-.. _view-event_format:
-
-.. object:: event_format
-
-    
-    Default formatting for events used when the start- and end-date are not
-    clear through context, e.g. for :command:`search`, used almost everywhere
-    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`.
-
-      :type: string
-      :default: {calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{description-separator}{description}{reset}
-
-.. _view-event_view_always_visible:
-
-.. object:: event_view_always_visible
-
-    
-    Set to true to always show the event view window when looking at the event list
-
-      :type: boolean
-      :default: False
-
-.. _view-event_view_weighting:
-
-.. object:: event_view_weighting
-
-    
-    weighting that is applied to the event view window
-
-      :type: integer
-      :default: 1
-
-.. _view-frame:
-
-.. object:: frame
-
-    
-    Whether to show a visible frame (with *box drawing* characters) around some
-    (groups of) elements or not. There are currently several different frame
-    options available, that should visually differentiate whether an element is
-    in focus or not. Some of them will probably be removed in future releases of
-    khal, so please try them out and give feedback on which style you prefer
-    (the color of all variants can be defined in the color themes).
-
-      :type: option, allowed values are *False*, *width*, *color* and *top*
-      :default: False
-
-.. _view-theme:
-
-.. object:: theme
-
-    
-    Choose a color theme for khal.
-    
-    This is very much work in progress. Help is really welcome! The two currently
-    available color schemes (*dark* and *light*) are defined in
-    *khal/ui/colors.py*, you can either help improve those or create a new one
-    (see below). As ikhal uses urwid, have a look at `urwid's documentation`__
-    for how to set colors and/or at the existing schemes. If you cannot change
-    the color of an element (or have any other problems) please open an issue on
-    github_.
-    
-    If you want to create your own color scheme, copy the structure of the
-    existing ones, give it a new and unique name and also add it as an option in
-    `khal/settings/khal.spec` in the section `[default]` of the property `theme`.
-    
-    __ http://urwid.org/manual/displayattributes.html
-    .. _github: # https://github.com/pimutils/khal/issues
-
-      :type: option, allowed values are *dark* and *light*
-      :default: dark
diff -Nru khal-0.9.10/doc/source/configure.rst khal-0.10.2/doc/source/configure.rst
--- khal-0.9.10/doc/source/configure.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/configure.rst	2020-07-29 18:17:53.000000000 +0200
@@ -3,7 +3,8 @@
 :command:`khal` reads configuration files in the *ini* syntax, meaning it understands
 keys separated from values by a **=**, while section and subsection names are
 enclosed by single or double square brackets (like **[sectionname]** and
-**[[subsectionname]]**).
+**[[subsectionname]]**). Any line beginning with a **#** will be treated as a
+comment.
 
 Help with initial configuration
 -------------------------------
@@ -60,6 +61,6 @@
 Therefore, you might want to execute :command:`khal` automatically after syncing
 with :command:`vdirsyncer` (e.g. via :command:`cron`).
 
-.. _vdirsyncer: https://github.com/untitaker/vdirsyncer
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer
 
 
diff -Nru khal-0.9.10/doc/source/conf.py khal-0.10.2/doc/source/conf.py
--- khal-0.9.10/doc/source/conf.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/conf.py	2020-07-29 18:17:53.000000000 +0200
@@ -12,11 +12,10 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
+import khal
 import validate
 from configobj import ConfigObj
 
-import khal
-
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
diff -Nru khal-0.9.10/doc/source/faq.rst khal-0.10.2/doc/source/faq.rst
--- khal-0.9.10/doc/source/faq.rst	2018-02-20 19:53:01.000000000 +0100
+++ khal-0.10.2/doc/source/faq.rst	2020-07-29 18:17:53.000000000 +0200
@@ -10,13 +10,10 @@
       takes nearly as much time as running khal, uncompressing that file via
       pytz via `(sudo) pip unzip pytz` might help.
 
-* **ikhal raises an Exception: AttributeError: 'module' object has no attribute 'SimpleFocusListWalker'**
-        You probably need to upgrade urwid to version 1.1.0, if your OS does come with
-        an older version of *urwid* you can install the latest version to userspace
-        (without messing up your default installation) with `pip install --upgrade urwid --user`.
-
-
 * **Installation stops with an error: source/str_util.c:25:20: fatal error: Python.h: No such file or directory**
         You do not have the Python development headers installed, on Debian based
         Distributions you can install them via *aptitude install python-dev*.
 
+* **unknown key "default_command"**
+         This key was deprecated by f8d9135.
+         See https://github.com/pimutils/khal/issues/648 for the rationale behind this removal.
diff -Nru khal-0.9.10/doc/source/feedback.rst khal-0.10.2/doc/source/feedback.rst
--- khal-0.9.10/doc/source/feedback.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/feedback.rst	2020-07-29 18:17:53.000000000 +0200
@@ -28,7 +28,7 @@
 If it isn't, please open a new bug.  In case you submit a new bug report,
 please include:
 
- * how you ran khal (please run in verbose mode with `-v`)
+ * how you ran khal (please run in verbose mode with `-v DEBUG`)
  * what you expected khal to do
  * what it did instead
  * everything khal printed to the screen (you may redact private details)
diff -Nru khal-0.9.10/doc/source/hacking.rst khal-0.10.2/doc/source/hacking.rst
--- khal-0.9.10/doc/source/hacking.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/hacking.rst	2020-07-29 18:17:53.000000000 +0200
@@ -119,12 +119,6 @@
 reStructuredText_ format, which shouldn't be too hard to use after looking at
 some of the existing documentation (even for users who never used it before).
 
-.. note::
-        The file :file:`doc/source/configspec.rst` is auto-generated on
-        making the documentation from the file :file:`khal/settings/khal.spec`.  So
-        instead of editing the former, please edit the later, run make and include both
-        changes in your patch.
-
 Also, summarize your changes in :file:`CHANGELOG.rst`,  pointing readers to the
 (updated) documentation is fine.
 
diff -Nru khal-0.9.10/doc/source/index.rst khal-0.10.2/doc/source/index.rst
--- khal-0.9.10/doc/source/index.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/index.rst	2020-07-29 18:17:53.000000000 +0200
@@ -17,7 +17,7 @@
 - ikhal (interactive khal) lets you browse and edit calendars and events
 - only rudimentary support for creating and editing recursion rules
 - you cannot edit the timezones of events
-- works with python 3.3+
+- works with python 3.4+
 - khal should run on all major operating systems [1]_
 
 .. [1] except for Microsoft Windows
diff -Nru khal-0.9.10/doc/source/install.rst khal-0.10.2/doc/source/install.rst
--- khal-0.9.10/doc/source/install.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/install.rst	2020-07-29 18:17:53.000000000 +0200
@@ -6,7 +6,7 @@
 khal has been packaged for, among others: Arch Linux (stable_ and development_
 versions), Debian_, Fedora_, FreeBSD_, Guix_, and pkgsrc_.
 
-.. _stable: https://aur.archlinux.org/packages/khal/
+.. _stable: https://www.archlinux.org/packages/community/any/khal/
 .. _development: https://aur.archlinux.org/packages/khal-git/
 .. _Debian: https://packages.debian.org/search?keywords=khal&searchon=names
 .. _Fedora: https://admin.fedoraproject.org/pkgdb/package/rpms/khal/
@@ -31,7 +31,12 @@
 
      pip install git+git://github.com/pimutils/khal.git
 
-This should also take care of installing all required dependencies.
+This should also take care of installing all required dependencies.  If in
+doubt, do not use `sudo pip install` but install `pip install khal --user`.
+Especially if using the `--user` flag, *khal* might be installed to
+`~/.local/bin`.  So if your shell cannot find *khal*, you might want to check
+there and add that `folder to your $PATH
+<https://askubuntu.com/questions/60218/how-to-add-a-directory-to-the-path>`_.
 
 Otherwise, you can always download the latest release from pypi_ and execute::
 
@@ -43,7 +48,7 @@
 
 in the unpacked distribution folder.
 
-Since version 0.8, *khal* **only supports python 3.3+**. If you have
+Since version 0.10, *khal* **only supports python 3.4+**. If you have
 python 2 and 3 installed in parallel you might need to use `pip3` instead of
 `pip` and `python3` instead of `python`. In case your operating system cannot
 deal with python 2 and 3 packages concurrently, we suggest installing *khal* in
@@ -60,7 +65,7 @@
 Requirements
 ------------
 
-*khal* is written in python and can run on Python 3.3+. It requires a Python
+*khal* is written in python and can run on Python 3.4+. It requires a Python
 with ``sqlite3`` support enabled (which is usually the case).
 
 If you are installing python via *pip* or from source, be aware that since
@@ -69,7 +74,7 @@
 (included in a separate "development package" on some distributions) installed.
 
 .. _icalendar: https://github.com/collective/icalendar
-.. _vdirsyncer: https://github.com/untitaker/vdirsyncer
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer
 .. _lxml: http://lxml.de/
 
 Packaging
diff -Nru khal-0.9.10/doc/source/news/khal0100.rst khal-0.10.2/doc/source/news/khal0100.rst
--- khal-0.9.10/doc/source/news/khal0100.rst	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/doc/source/news/khal0100.rst	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,51 @@
+khal v0.10.0 released
+=====================
+
+.. feed-entry::
+        :date: 2019-03-25
+
+This is not only the first bugfix release in more than a year, but also the
+first release containing new features in nearly two years.
+
+v0.10.0 contains some breaking changes from earlier versions of khal, most
+notably the removal of the default command [1]_. For users who want the old
+functionality back, a shell function seems to be the best option. Please use the
+wiki_ to share your solutions. Have a look at the Changelog_ for more changes
+and fixes.
+
+With this release I want to bring the aforementioned changes to more people,
+find (and fix) as many more bugs as possible and then release a version 1.0.
+
+For convenience issues, khal will only be available on pypi_ in the future, with
+minor versions (v0.10.x) not getting announced here any more. Users and packagers
+who want to stay on a current version of khal are therefore advised to watch
+pypi_ for new versions.
+
+.. Important::
+
+   **Contributors and maintainers wanted**
+
+   As I find myself having less and less time to devout to khal, I'm looking for
+   more developers and maintainers. Even if you are not a python developer, your
+   help with helping new users and triaging and prioritizing bugs is very much
+   appreciated. 
+
+   If you don't know how to contact the current team, open an `issue on github`_.
+
+   **vdirsyncer**, which many khal users are probably dependend on, is also
+   looking for new maintainers_.
+
+
+.. [1] The implementation of the default command proved to be a source of
+   constant headache for users and developers alike. This was due to the library chosen
+   for handling argument parsing.
+
+   Feel free to share other suggestions in the wiki_.
+
+.. _pypi: https://pypi.python.org/pypi/khal/
+.. _issue on github: https://github.com/pimutils/khal/issues
+.. _issues: https://github.com/pimutils/khal/issues
+.. _wiki: https://github.com/pimutils/khal/wiki/Default-command-alternatives
+.. _changelog: changelog.html#id2
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer
+.. _maintainers: https://github.com/pimutils/vdirsyncer/issues/790
diff -Nru khal-0.9.10/doc/source/news/khal01.rst khal-0.10.2/doc/source/news/khal01.rst
--- khal-0.9.10/doc/source/news/khal01.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/news/khal01.rst	2020-07-29 18:17:53.000000000 +0200
@@ -14,5 +14,5 @@
 already try it out via checking out the branch *vdir* at github_.
 
 .. _pypi: https://pypi.python.org/pypi/khal/
-.. _vdirsyncer: https://github.com/untitaker/vdirsyncer/
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer/
 .. _github: https://github.com/geier/khal/tree/vdir
diff -Nru khal-0.9.10/doc/source/news/khal02.rst khal-0.10.2/doc/source/news/khal02.rst
--- khal-0.9.10/doc/source/news/khal02.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/news/khal02.rst	2020-07-29 18:17:53.000000000 +0200
@@ -27,6 +27,6 @@
 Also *khal*'s command line syntax changed quite a bit, so you might want to head over the documentation_.
 
 .. _pypi: https://pypi.python.org/pypi/khal/
-.. _vdirsyncer: https://github.com/untitaker/vdirsyncer/
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer/
 .. _tutorial: https://vdirsyncer.readthedocs.org/en/latest/tutorial.html
 .. _documentation: http://lostpackets.de/khal/pages/usage.html
diff -Nru khal-0.9.10/doc/source/news/khal03.rst khal-0.10.2/doc/source/news/khal03.rst
--- khal-0.9.10/doc/source/news/khal03.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/news/khal03.rst	2020-07-29 18:17:53.000000000 +0200
@@ -33,4 +33,4 @@
 __ https://khal.readthedocs.org
 
 .. _pypi: https://pypi.python.org/pypi/khal/
-.. _vdirsyncer: https://github.com/untitaker/vdirsyncer/
+.. _vdirsyncer: https://github.com/pimutils/vdirsyncer/
diff -Nru khal-0.9.10/doc/source/news/khal099.rst khal-0.10.2/doc/source/news/khal099.rst
--- khal-0.9.10/doc/source/news/khal099.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/news/khal099.rst	1970-01-01 01:00:00.000000000 +0100
@@ -1,21 +0,0 @@
-khal v0.9.9 released (dependency clarification)
-===============================================
-
-.. feed-entry::
-        :date: 2018-05-26
-
-`khal v0.9.9`_ (and previous version of khal) currently only support the
-dateutil library (that khal depends on) in versions < 2.7. The only change in
-khal v0.9.9 is updated dependency.
-
-If your OS already shipe dateutil >= 2.7, we recommend pipsi_ to install the
-latest version of khal. 
-
-
-Get `khal v0.9.9`_ from this site, or from pypi_.
-
-
-.. _pypi: https://pypi.python.org/pypi/khal/
-.. _khal v0.9.9: https://lostpackets.de/khal/downloads/khal-0.9.9.tar.gz
-.. _pipsi: https://pypi.org/project/pipsi/
-
diff -Nru khal-0.9.10/doc/source/news.rst khal-0.10.2/doc/source/news.rst
--- khal-0.9.10/doc/source/news.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/news.rst	2020-07-29 18:17:53.000000000 +0200
@@ -12,7 +12,7 @@
     :title: khal news
     :link: http://lostpackets.de/khal/
 
-    news/khal099
+    news/khal0100
     news/khal098
     news/khal097
     news/khal096
diff -Nru khal-0.9.10/doc/source/usage.rst khal-0.10.2/doc/source/usage.rst
--- khal-0.9.10/doc/source/usage.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/doc/source/usage.rst	2020-07-29 18:17:53.000000000 +0200
@@ -3,9 +3,9 @@
 Khal offers a set of commands, most importantly :command:`list`,
 :command:`calendar`, :command:`interactive`, :command:`new`,
 :command:`printcalendars`, :command:`printformats`, and :command:`search`. See
-below for a description of what every command does. Calling :program:`khal`
-without any command will invoke the default command, which can be specified in
-the :doc:`configuration file <configure>`.
+below for a description of what every command does. :program:`khal` does
+currently not support any default command, i.e., run a command, even though none
+has been specified. This is intentional.
 
 
 Options
@@ -24,13 +24,18 @@
 Several options are common to almost all of :program:`khal`'s commands
 (exceptions are described below):
 
-.. option:: -v
+.. option:: -v, --verbosity LVL
 
-        Be more verbose (e.g. print debugging information)
+        Configure verbosity (e.g. print debugging information), `LVL` needs to
+        be one of CRITICAL, ERROR, WARNING, INFO, or DEBUG.
+
+.. option:: -l, --logfile LOFILE
+
+        Use logfile `LOGFILE` for logging, default is logging to stdout.
 
 .. option:: -c CONFIGFILE
 
-        Use an alternate configuration file
+        Use an alternate configuration file.
 
 .. option:: -a CALENDAR
 
@@ -68,6 +73,12 @@
    description
         The description of the event.
 
+   description-separator
+        A separator: " :: " that appears when there is a description.
+
+   uid
+        The UID of the event.
+
    start
         The start datetime in datetimeformat.
 
@@ -101,12 +112,6 @@
    repeat-symbol
         A repeating symbol (loop arrow) if the event is repeating.
 
-   description
-        The event description.
-
-   description-separator
-        A separator: " :: " that appears when there is a description.
-
    location
         The event location.
 
@@ -146,6 +151,17 @@
        The string `CANCELLED` (plus one blank) if the event's status is
        cancelled, otherwise nothing.
 
+   organizer
+       The organizer of the event. If the format has CN then it returns "CN (email)"
+       if CN does not exist it returns just the email string. Example:
+       ORGANIZER;CN=Name Surname:mailto:name at mail.com
+       returns
+       Name Surname (name at mail.com)
+       and if it has no CN attribute it returns the last element after the colon:
+       ORGANIZER;SENT-BY="mailto:toemail at mail.com":mailto:name at mail.com
+       returns
+       name at mail.com
+
    By default, all-day events have no times. To see a start and end time anyway simply
    add `-full` to the end of any template with start/end, for instance
    `start-time` becomes `start-time-full` and will always show start and end times (instead
@@ -156,7 +172,10 @@
    is also `reset`, which clears the styling, and `bold`, which is the normal
    bold.
 
-   For example the below command with print the title and description of all events today.
+   A few control codes are exposed.  You can access newline (`nl`), 'tab', and 'bell'.
+   Control codes, such as `nl`, are best used with `--list` mode.
+
+   Below is an example command which prints the title and description of all events today.
 
    ::
 
@@ -190,6 +209,10 @@
 the next occurrence of a day with that name. The name of the current day gets
 interpreted as that date *next* week (i.e. seven days from now).
 
+If a short datetime format is used (no year is given), khal will interpret the
+date to be in the future. The inferred it might be in the next year if the given
+date has already past in the current year.
+
 Commands
 --------
 
@@ -280,7 +303,7 @@
  * focus on the right column by pressing :kbd:`tab` or :kbd:`enter`
  * re-focus on the current date, default keybinding :kbd:`t` as in today
  * marking a date range, default keybinding :kbd:`v`, as in visual, think visual
-   mode in Vim, pressing :kbd:`esc` escape this visual mode
+   mode in Vim, pressing :kbd:`esc` escapes this visual mode
  * if in visual mode, you can select the other end of the currently marked
    range, default keybinding :kbd:`o` as in other (again as in Vim)
  * create a new event on the currently focused day (or date range if a range is
@@ -308,8 +331,9 @@
 * use some common editing short cuts in most text fields (:kbd:`ctrl+w` deletes word
   before cursor, :kbd:`ctrl+u` (:kbd:`ctrl+k`) deletes till the beginning (end) of the
   line, :kbd:`ctrl+a` (:kbd:`ctrl+e`) will jump to the beginning (end) of the line
-* in the date and time field you can increment and decrement the number under
+* in the date and time fields you can increment and decrement the number under
   the cursor with :kbd:`ctrl+a` and :kbd:`ctrl+x` (time in 15 minute steps)
+* in the date fields you can access a miniature calendar by pressing `enter`
 * activate actions by pressing :kbd:`enter` on text enclosed by angled brackets, e.g.
   :guilabel:`< Save >` (sometimes this might open a pop up)
 
@@ -385,12 +409,13 @@
 
 * **-u, --until=UNTIL** specify until when a recurring event should run
 
-* **--alarm DURATION** will add an alarm DURATION before the start of the event,
+* **--alarms DURATION,...** will add alarm times as DELTAs comma separated for this event,
   *DURATION* should look like `1day 10minutes` or `1d3H10m`, negative
   *DURATIONs* will set alarm after the start of the event.
 
 Examples
 """"""""
+These may need to be adapted for your configuration and/or locale. See :command:`printformats`.
 ::
 
     khal new 18:00 Awesome Event
@@ -415,8 +440,8 @@
 
     khal new -a work 26.07. Great Event -g meeting -r weekly
 
-adds a new all day event on 26th of July to the calendar *work* which recurs
-every week.
+adds a new all day event on 26th of July to the calendar *work* in the *meeting* 
+category, which recurs every week.
 
 
 edit
@@ -437,7 +462,7 @@
 
 printformats
 ************
-prints a fixed date (*2013-12-21 10:09*) in all configured date(time) formats.
+prints a fixed date (*2013-12-21 21:45*) in all configured date(time) formats.
 This is supposed to help check if those formats are configured as intended.
 
 search
diff -Nru khal-0.9.10/.gitignore khal-0.10.2/.gitignore
--- khal-0.9.10/.gitignore	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/.gitignore	2020-07-29 18:17:53.000000000 +0200
@@ -12,3 +12,4 @@
 .cache
 htmlcov
 doc/source/configspec.rst
+.mypy_cache/
diff -Nru khal-0.9.10/khal/calendar_display.py khal-0.10.2/khal/calendar_display.py
--- khal-0.9.10/khal/calendar_display.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/calendar_display.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,15 +20,14 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
 import calendar
-import datetime
-from locale import getlocale, setlocale, LC_ALL, LC_TIME
+import datetime as dt
+from locale import LC_ALL, LC_TIME, getlocale, setlocale
 
 from click import style
 
 from .terminal import colored
 from .utils import get_month_abbr_len
 
-
 setlocale(LC_ALL, '')
 
 
@@ -49,23 +48,48 @@
     :return: weeknumber
     :rtype: int
     """
-    return datetime.date.isocalendar(date)[1]
+    return dt.date.isocalendar(date)[1]
 
 
-def get_event_color(event, default_color):
+def get_calendar_color(calendar, default_color, collection):
     """Because multi-line lambdas would be un-Pythonic
     """
-    if event.color == '':
+    if collection._calendars[calendar]['color'] == '':
         return default_color
-    return event.color
+    return collection._calendars[calendar]['color']
+
+
+def get_color_list(calendars, default_color, collection):
+    """Get the list of possible colors for the day, taking into account priority
+    """
+    dcolors = list(
+        map(lambda x: (get_calendar_color(x, default_color, collection),
+                       collection._calendars[x]['priority']), calendars)
+    )
+
+    dcolors.sort(key=lambda x: x[1], reverse=True)
+
+    maxPriority = dcolors[0][1]
+    dcolors = list(
+        filter(lambda x: x[1] == maxPriority, dcolors)
+    )
 
+    dcolors = list(
+        map(lambda x: x[0], dcolors)
+    )
 
-def str_highlight_day(day, devents, hmethod, default_color, multiple, color, bold_for_light_color):
+    dcolors = list(set(dcolors))
+
+    return dcolors
+
+
+def str_highlight_day(
+        day, calendars, hmethod, default_color, multiple, color, bold_for_light_color, collection):
     """returns a string with day highlighted according to configuration
     """
     dstr = str(day.day).rjust(2)
     if color == '':
-        dcolors = list(set(map(lambda x: get_event_color(x, default_color), devents)))
+        dcolors = get_color_list(calendars, default_color, collection)
         if len(dcolors) > 1:
             if multiple == '':
                 if hmethod == "foreground" or hmethod == "fg":
@@ -79,10 +103,7 @@
             else:
                 dcolor = multiple
         else:
-            if devents[0].color == '':
-                dcolor = default_color
-            else:
-                dcolor = devents[0].color
+            dcolor = dcolors[0] or default_color
     else:
         dcolor = color
     if dcolor != '':
@@ -112,10 +133,10 @@
         if day == today:
             day = style(str(day.day).rjust(2), reverse=True)
         elif highlight_event_days:
-            devents = list(collection.get_events_on(day, minimal=True))
+            devents = list(collection.get_calendars_on(day))
             if len(devents) > 0:
                 day = str_highlight_day(day, devents, hmethod, default_color,
-                                        multiple, color, bold_for_light_color)
+                                        multiple, color, bold_for_light_color, collection)
             else:
                 day = str(day.day).rjust(2)
         else:
@@ -130,6 +151,7 @@
                    weeknumber=False,
                    count=3,
                    firstweekday=0,
+                   monthdisplay='firstday',
                    collection=None,
                    hmethod='fg',
                    default_color='',
@@ -159,11 +181,11 @@
     :rtype: list() of str()
     """
     if month is None:
-        month = datetime.date.today().month
+        month = dt.date.today().month
     if year is None:
-        year = datetime.date.today().year
+        year = dt.date.today().year
     if today is None:
-        today = datetime.date.today()
+        today = dt.date.today()
 
     khal = list()
     w_number = '  ' if weeknumber == 'right' else ''
@@ -174,7 +196,10 @@
     _calendar = calendar.Calendar(firstweekday)
     for _ in range(count):
         for week in _calendar.monthdatescalendar(year, month):
-            new_month = len([day for day in week if day.day == 1])
+            if monthdisplay == 'firstday':
+                new_month = len([day for day in week if day.day == 1])
+            else:
+                new_month = len(week if week[0].day <= 7 else [])
             strweek = str_week(week, today, collection, hmethod, default_color,
                                multiple, color, highlight_event_days, locale, bold_for_light_color)
             if new_month:
diff -Nru khal-0.9.10/khal/cli.py khal-0.10.2/khal/cli.py
--- khal-0.9.10/khal/cli.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/cli.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,12 +19,22 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
+import datetime as dt
 import logging
 import os
+import stat
 import sys
 import textwrap
 from shutil import get_terminal_size
-import datetime
+
+import click
+import click_log
+
+from . import __version__, controllers, khalendar
+from .exceptions import FatalError
+from .settings import InvalidSettingsError, get_config
+from .settings.exceptions import NoConfigFile
+from .terminal import colored
 
 try:
     from setproctitle import setproctitle
@@ -32,22 +42,13 @@
     def setproctitle(x):
         pass
 
-import click
-
-from . import controllers, khalendar, __version__
-from .log import logger
-from .settings import get_config, InvalidSettingsError
-from .settings.exceptions import NoConfigFile
-from .exceptions import FatalError
-from .terminal import colored
 
+logger = logging.getLogger('khal')
+click_log.basic_config('khal')
 
-days_option = click.option('--days', default=None, type=int,
-                           help='How many days to include.')
-week_option = click.option('--week', '-w',
-                           help=('Include all events in one week.'), is_flag=True)
-events_option = click.option('--events', default=None, type=int,
-                             help='How many events to include.')
+days_option = click.option('--days', default=None, type=int, help='How many days to include.')
+week_option = click.option('--week', '-w', help='Include all events in one week.', is_flag=True)
+events_option = click.option('--events', default=None, type=int, help='How many events to include.')
 dates_arg = click.argument('dates', nargs=-1)
 
 
@@ -55,43 +56,38 @@
     return dates_arg(events_option(week_option(days_option(f))))
 
 
-def _multi_calendar_select_callback(ctx, option, calendars):
-    if not calendars:
-        return
-    if 'calendar_selection' in ctx.obj:
+def multi_calendar_select(ctx, include_calendars, exclude_calendars):
+    if include_calendars and exclude_calendars:
         raise click.UsageError('Can\'t use both -a and -d.')
-    if not isinstance(calendars, tuple):
-        calendars = (calendars,)
+    # if not isinstance(include_calendars, tuple):
+        # include_calendars = (include_calendars,)
+    # if not isinstance(exclude_calendars, tuple):
+        # exclude_calendars = (exclude_calendars,)
 
-    mode = option.name
-    selection = ctx.obj['calendar_selection'] = set()
+    selection = set()
 
-    if mode == 'include_calendar':
-        for cal_name in calendars:
+    if include_calendars:
+        for cal_name in include_calendars:
             if cal_name not in ctx.obj['conf']['calendars']:
                 raise click.BadParameter(
                     'Unknown calendar {}, run `khal printcalendars` to get a '
                     'list of all configured calendars.'.format(cal_name)
                 )
 
-        selection.update(calendars)
-    elif mode == 'exclude_calendar':
+        selection.update(include_calendars)
+    elif exclude_calendars:
         selection.update(ctx.obj['conf']['calendars'].keys())
-        for value in calendars:
+        for value in exclude_calendars:
             selection.remove(value)
-    else:
-        raise ValueError(mode)
+
+    return selection or None
 
 
 def multi_calendar_option(f):
     a = click.option('--include-calendar', '-a', multiple=True, metavar='CAL',
-                     expose_value=False,
-                     callback=_multi_calendar_select_callback,
                      help=('Include the given calendar. Can be specified '
                            'multiple times.'))
     d = click.option('--exclude-calendar', '-d', multiple=True, metavar='CAL',
-                     expose_value=False,
-                     callback=_multi_calendar_select_callback,
                      help=('Exclude the given calendar. Can be specified '
                            'multiple times.'))
 
@@ -118,35 +114,20 @@
 
 
 def calendar_option(f):
-    return click.option('--calendar', '-a', metavar='CAL',
-                        callback=_calendar_select_callback)(f)
+    return click.option('--calendar', '-a', metavar='CAL', callback=_calendar_select_callback)(f)
 
 
 def global_options(f):
-    def config_callback(ctx, option, config):
-        prepare_context(ctx, config)
-
-    def verbosity_callback(ctx, option, verbose):
-        if verbose:
-            logger.setLevel(logging.DEBUG)
-        else:
-            logger.setLevel(logging.INFO)
-
     def color_callback(ctx, option, value):
         ctx.color = value
 
+    def logfile_callback(ctx, option, path):
+        ctx.logfilepath = path
+
     config = click.option(
         '--config', '-c',
-        is_eager=True,  # make sure other options can access config
         help='The config file to use.',
-        default=None, metavar='PATH', expose_value=False,
-        callback=config_callback
-    )
-    verbose = click.option(
-        '--verbose', '-v',
-        is_eager=True,  # make sure to log config when debugging
-        help='Output debugging information.',
-        is_flag=True, expose_value=False, callback=verbosity_callback
+        default=None, metavar='PATH'
     )
     color = click.option(
         '--color/--no-color',
@@ -156,9 +137,19 @@
         callback=color_callback
     )
 
+    logfile = click.option(
+        '--logfile', '-l',
+        help='The logfile to use [defaults to stdout]',
+        type=click.Path(),
+        callback=logfile_callback,
+        default=None,
+        expose_value=False,
+        metavar='LOGFILE',
+    )
+
     version = click.version_option(version=__version__)
 
-    return config(verbose(color(version(f))))
+    return logfile(config(color(version(f))))
 
 
 def build_collection(conf, selection):
@@ -172,6 +163,7 @@
                     'path': cal['path'],
                     'readonly': cal['readonly'],
                     'color': cal['color'],
+                    'priority': cal['priority'],
                     'ctype': cal['type'],
                 }
         collection = khalendar.CalendarCollection(
@@ -185,6 +177,7 @@
             highlight_event_days=conf['default']['highlight_event_days'],
         )
     except FatalError as error:
+        logger.debug(error, exc_info=True)
         logger.fatal(error)
         sys.exit(1)
 
@@ -209,7 +202,7 @@
     except NoConfigFile:
         conf = _NoConfig()
     except InvalidSettingsError:
-        logger.info('If your configuration file used to work, please have a  '
+        logger.info('If your configuration file used to work, please have a '
                     'look at the Changelog to see what changed.')
         sys.exit(1)
     else:
@@ -236,22 +229,19 @@
 
 
 def _get_cli():
-    @click.group(invoke_without_command=True)
+    @click.group()
+    @click_log.simple_verbosity_option('khal')
     @global_options
     @click.pass_context
-    def cli(ctx):
+    def cli(ctx, config):
         # setting the process title so it looks nicer in ps
         # shows up as 'khal' under linux and as 'python: khal (python2.7)'
         # under FreeBSD, which is still nicer than the default
         setproctitle('khal')
-
-        if not ctx.invoked_subcommand:
-            command = ctx.obj['conf']['default']['default_command']
-            if command:
-                ctx.invoke(cli.commands[command])
-            else:
-                click.echo(ctx.get_help())
-                ctx.exit(1)
+        if ctx.logfilepath:
+            logger = logging.getLogger('khal')
+            logger.handlers = [logging.FileHandler(ctx.logfilepath)]
+        prepare_context(ctx, config)
 
     @cli.command()
     @multi_calendar_option
@@ -267,11 +257,15 @@
                   is_flag=True)
     @click.argument('DATERANGE', nargs=-1, required=False)
     @click.pass_context
-    def calendar(ctx, daterange, once, notstarted, format, day_format):
+    def calendar(ctx, include_calendar, exclude_calendar, daterange, once,
+                 notstarted, format, day_format):
         '''Print calendar with agenda.'''
         try:
             rows = controllers.calendar(
-                build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
+                build_collection(
+                    ctx.obj['conf'],
+                    multi_calendar_select(ctx, include_calendar, exclude_calendar)
+                ),
                 agenda_format=format,
                 day_format=day_format,
                 once=once,
@@ -281,6 +275,7 @@
                 firstweekday=ctx.obj['conf']['locale']['firstweekday'],
                 locale=ctx.obj['conf']['locale'],
                 weeknumber=ctx.obj['conf']['locale']['weeknumbers'],
+                monthdisplay=ctx.obj['conf']['view']['monthdisplay'],
                 hmethod=ctx.obj['conf']['highlight_days']['method'],
                 default_color=ctx.obj['conf']['highlight_days']['default_color'],
                 multiple=ctx.obj['conf']['highlight_days']['multiple'],
@@ -291,6 +286,7 @@
             )
             click.echo('\n'.join(rows))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -309,12 +305,16 @@
     @click.argument('DATERANGE', nargs=-1, required=False,
                     metavar='[DATETIME [DATETIME | RANGE]]')
     @click.pass_context
-    def klist(ctx, daterange, once, notstarted, format, day_format):
+    def klist(ctx, include_calendar, exclude_calendar,
+              daterange, once, notstarted, format, day_format):
         """List all events between a start (default: today) and (optional)
         end datetime."""
         try:
             event_column = controllers.khal_list(
-                build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
+                build_collection(
+                    ctx.obj['conf'],
+                    multi_calendar_select(ctx, include_calendar, exclude_calendar)
+                ),
                 agenda_format=format,
                 day_format=day_format,
                 daterange=daterange,
@@ -325,6 +325,7 @@
             )
             click.echo('\n'.join(event_column))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -335,7 +336,7 @@
     @click.option('--location', '-l',
                   help=('The location of the new event.'))
     @click.option('--categories', '-g',
-                  help=('The categories of the new event.'))
+                  help=('The categories of the new event, comma separated.'))
     @click.option('--repeat', '-r',
                   help=('Repeat event: daily, weekly, monthly or yearly.'))
     @click.option('--until', '-u',
@@ -357,10 +358,9 @@
         everything behind them is taken as the event's description.
         '''
         if not info and not interactive:
-                raise click.BadParameter(
-                    'no details provided, '
-                    'did you mean to use --interactive/-i?'
-                )
+            raise click.BadParameter(
+                'no details provided, did you mean to use --interactive/-i?'
+            )
 
         calendar = calendar or ctx.obj['conf']['default']['default_calendar']
         if calendar is None:
@@ -397,6 +397,7 @@
                 format=format,
             )
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -437,7 +438,7 @@
             if not ics:
                 ics_strs = (sys.stdin.read(),)
                 if not batch:
-                    if os.path.isfile('/dev/tty'):
+                    if os.stat('/dev/tty').st_mode & stat.S_IFCHR > 0:
                         sys.stdin = open('/dev/tty', 'r')
                     else:
                         logger.warning('/dev/tty does not exist, importing might not work')
@@ -454,13 +455,14 @@
                     env={"calendars": ctx.obj['conf']['calendars']},
                 )
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
     @cli.command()
     @multi_calendar_option
     @click.pass_context
-    def interactive(ctx):
+    def interactive(ctx, include_calendar, exclude_calendar):
         '''Interactive UI. Also launchable via `ikhal`.'''
         controllers.interactive(
             build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
@@ -471,24 +473,29 @@
     @global_options
     @multi_calendar_option
     @click.pass_context
-    def interactive_cli(ctx):
+    def interactive_cli(ctx, config, include_calendar, exclude_calendar):
         '''Interactive UI. Also launchable via `khal interactive`.'''
+        prepare_context(ctx, config)
         controllers.interactive(
-            build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
-            ctx.obj['conf'])
+            build_collection(
+                ctx.obj['conf'],
+                multi_calendar_select(ctx, include_calendar, exclude_calendar)
+            ),
+            ctx.obj['conf']
+        )
 
     @cli.command()
     @multi_calendar_option
     @click.pass_context
-    def printcalendars(ctx):
+    def printcalendars(ctx, include_calendar, exclude_calendar):
         '''List all calendars.'''
         try:
-            click.echo(
-                '\n'.join(
-                    build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)).names
-                )
-            )
+            click.echo('\n'.join(build_collection(
+                ctx.obj['conf'],
+                multi_calendar_select(ctx, include_calendar, exclude_calendar)
+            ).names))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -497,11 +504,10 @@
     def printformats(ctx):
         '''Print a date in all formats.
 
-        Print the date 2013-12-21 10:09 in all configured date(time)
+        Print the date 2013-12-21 21:45 in all configured date(time)
         formats to check if these locale settings are configured to ones
         liking.'''
-        from datetime import datetime
-        time = datetime(2013, 12, 21, 10, 9)
+        time = dt.datetime(2013, 12, 21, 21, 45)
         try:
             for strftime_format in [
                     'longdatetimeformat', 'datetimeformat', 'longdateformat',
@@ -509,6 +515,7 @@
                 dt_str = time.strftime(ctx.obj['conf']['locale'][strftime_format])
                 click.echo('{}: {}'.format(strftime_format, dt_str))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -530,6 +537,7 @@
                 name = 'stdin input'
             controllers.print_ics(ctx.obj['conf'], name, ics_str, format)
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -539,7 +547,7 @@
                   help=('The format of the events.'))
     @click.argument('search_string')
     @click.pass_context
-    def search(ctx, format, search_string):
+    def search(ctx, format, search_string, include_calendar, exclude_calendar):
         '''Search for events matching SEARCH_STRING.
 
         For recurring events, only the master event and different overwritten
@@ -549,11 +557,14 @@
         if format is None:
             format = ctx.obj['conf']['view']['event_format']
         try:
-            collection = build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None))
+            collection = build_collection(
+                ctx.obj['conf'],
+                multi_calendar_select(ctx, include_calendar, exclude_calendar)
+            )
             events = sorted(collection.search(search_string))
             event_column = list()
             term_width, _ = get_terminal_size()
-            now = datetime.datetime.now()
+            now = dt.datetime.now()
             env = {"calendars": ctx.obj['conf']['calendars']}
             for event in events:
                 desc = textwrap.wrap(event.format(format, relative_to=now, env=env), term_width)
@@ -564,6 +575,7 @@
                 )
             click.echo('\n'.join(event_column))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -575,11 +587,14 @@
                   is_flag=True)
     @click.argument('search_string', nargs=-1)
     @click.pass_context
-    def edit(ctx, format, search_string, show_past):
+    def edit(ctx, format, search_string, show_past, include_calendar, exclude_calendar):
         '''Interactively edit (or delete) events matching the search string.'''
         try:
             controllers.edit(
-                build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
+                build_collection(
+                    ctx.obj['conf'],
+                    multi_calendar_select(ctx, include_calendar, exclude_calendar)
+                ),
                 ' '.join(search_string),
                 format=format,
                 allow_past=show_past,
@@ -587,6 +602,7 @@
                 conf=ctx.obj['conf']
             )
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -600,13 +616,16 @@
                   is_flag=True)
     @click.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]')
     @click.pass_context
-    def at(ctx, datetime, notstarted, format, day_format):
+    def at(ctx, datetime, notstarted, format, day_format, include_calendar, exclude_calendar):
         '''Print all events at a specific datetime (defaults to now).'''
         if not datetime:
             datetime = ("now",)
         try:
             rows = controllers.khal_list(
-                build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)),
+                build_collection(
+                    ctx.obj['conf'],
+                    multi_calendar_select(ctx, include_calendar, exclude_calendar)
+                ),
                 agenda_format=format,
                 day_format=day_format,
                 datepoint=list(datetime),
@@ -617,6 +636,7 @@
             )
             click.echo('\n'.join(rows))
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
@@ -628,6 +648,7 @@
         try:
             configwizard.configwizard()
         except FatalError as error:
+            logger.debug(error, exc_info=True)
             logger.fatal(error)
             sys.exit(1)
 
diff -Nru khal-0.9.10/khal/configwizard.py khal-0.10.2/khal/configwizard.py
--- khal-0.9.10/khal/configwizard.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/configwizard.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,20 +20,21 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
 
-from click import confirm, prompt, UsageError
-import xdg
-
+import datetime as dt
+import logging
 from functools import partial
 from itertools import zip_longest
-from os.path import expanduser, expandvars, join, normpath, exists, isdir
 from os import makedirs
+from os.path import exists, expanduser, expandvars, isdir, join, normpath
 
-from datetime import date, datetime
+import xdg
+from click import UsageError, confirm, prompt
 
-from khal.log import logger
 from .exceptions import FatalError
 from .settings import settings
 
+logger = logging.getLogger('khal')
+
 
 def validate_int(input, min_value, max_value):
     try:
@@ -79,7 +80,7 @@
         ('month/day/year', '%m/%d/%Y'),
     ]
     validate = partial(validate_int, min_value=0, max_value=3)
-    today = date.today()
+    today = dt.date.today()
     print("What ordering of year, month, date do you want to use?")
     for num, (desc, fmt) in enumerate(choices):
         print('[{}] {} (today: {})'.format(num, desc, today.strftime(fmt)))
@@ -103,7 +104,7 @@
     validate = partial(validate_int, min_value=0, max_value=1)
     prompt_text = "Please choose one of the above options"
     timeformat = choices[prompt(prompt_text, default=0, value_proc=validate)]
-    now = datetime.now()
+    now = dt.datetime.now()
     print("Time format: {} "
           "(current time as an example: {})".format(timeformat, now.strftime(timeformat)))
     return timeformat
diff -Nru khal-0.9.10/khal/controllers.py khal-0.10.2/khal/controllers.py
--- khal-0.9.10/khal/controllers.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/controllers.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,30 +20,29 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
 
-import icalendar
-from click import confirm, echo, style, prompt
-
-from .khalendar.vdir import Item
-from .exceptions import ConfigurationError
-
-import pytz
-
-from collections import defaultdict, OrderedDict
-from shutil import get_terminal_size
-
-from datetime import time, timedelta, datetime, date
+import datetime as dt
+import logging
 import os
 import textwrap
+from collections import OrderedDict, defaultdict
+from shutil import get_terminal_size
 
-from khal import utils, calendar_display
-from khal.khalendar.exceptions import ReadOnlyCalendarError, DuplicateUid
-from khal.exceptions import FatalError
+import pytz
+from click import confirm, echo, prompt, style
+from khal import (__productname__, __version__, calendar_display,
+                  parse_datetime, utils)
+from khal.exceptions import FatalError, DateTimeParseError
 from khal.khalendar.event import Event
-from khal.khalendar.backend import sort_key
-from khal import __version__, __productname__
-from khal.log import logger
+from khal.khalendar.exceptions import DuplicateUid, ReadOnlyCalendarError
+
+from .exceptions import ConfigurationError
+from .icalendar import (cal_from_ics, new_event as new_vevent, split_ics,
+                        sort_key as sort_vevent_key)
+from .khalendar.vdir import Item
 from .terminal import merge_columns
 
+logger = logging.getLogger('khal')
+
 
 def format_day(day, format_string, locale, attributes=None):
     if attributes is None:
@@ -52,7 +51,7 @@
     attributes["date"] = day.strftime(locale['dateformat'])
     attributes["date-long"] = day.strftime(locale['longdateformat'])
 
-    attributes["name"] = utils.construct_daynames(day)
+    attributes["name"] = parse_datetime.construct_daynames(day)
 
     colors = {"reset": style("", reset=True), "bold": style("", bold=True, reset=False)}
     for c in ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]:
@@ -71,6 +70,7 @@
              conf=None,
              firstweekday=0,
              weeknumber=False,
+             monthdisplay='firstday',
              hmethod='fg',
              default_color='',
              multiple='',
@@ -109,6 +109,7 @@
         year=start.year,
         count=max(3, (end.year - start.year) * 12 + end.month - start.month + 1),
         firstweekday=firstweekday, weeknumber=weeknumber,
+        monthdisplay=monthdisplay,
         collection=collection,
         hmethod=hmethod,
         default_color=default_color,
@@ -121,8 +122,8 @@
 
 
 def start_end_from_daterange(daterange, locale,
-                             default_timedelta_date=timedelta(days=1),
-                             default_timedelta_datetime=timedelta(hours=1)):
+                             default_timedelta_date=dt.timedelta(days=1),
+                             default_timedelta_datetime=dt.timedelta(hours=1)):
     """
     convert a string description of a daterange into start and end datetime
 
@@ -134,10 +135,10 @@
     :type locale: dict
     """
     if not daterange:
-        start = datetime(*date.today().timetuple()[:3])
+        start = dt.datetime(*dt.date.today().timetuple()[:3])
         end = start + default_timedelta_date
     else:
-        start, end, allday = utils.guessrangefstr(
+        start, end, allday = parse_datetime.guessrangefstr(
             daterange, locale, default_timedelta_date=default_timedelta_date,
             default_timedelta_datetime=default_timedelta_datetime,
         )
@@ -187,9 +188,9 @@
         # to understand what's going on here this way
         if notstarted:
             if event.allday and event.start < original_start.date():
-                    continue
+                continue
             elif not event.allday and event.start_local < original_start:
-                    continue
+                continue
         if seen is not None and event.uid in seen:
             continue
 
@@ -231,13 +232,15 @@
         if not datepoint:
             datepoint = ['now']
         try:
-            start, allday = utils.guessdatetimefstr(datepoint, conf['locale'], date.today())
+            start, allday = parse_datetime.guessdatetimefstr(
+                datepoint, conf['locale'], dt.date.today(),
+            )
         except ValueError:
             raise FatalError('Invalid value of `{}` for a datetime'.format(' '.join(datepoint)))
         if allday:
             logger.debug('Got date {}'.format(start))
             raise FatalError('Please supply a datetime, not a date.')
-        end = start + timedelta(seconds=1)
+        end = start + dt.timedelta(seconds=1)
         if day_format is None:
             day_format = style(
                 start.strftime(conf['locale']['longdatetimeformat']),
@@ -255,7 +258,7 @@
         if start.date() == end.date():
             day_end = end
         else:
-            day_end = datetime.combine(start.date(), time.max)
+            day_end = dt.datetime.combine(start.date(), dt.time.max)
         current_events = get_events_between(
             collection, locale=conf['locale'], agenda_format=agenda_format, start=start,
             end=day_end, notstarted=notstarted, original_start=original_start,
@@ -266,7 +269,7 @@
         if day_format and (conf['default']['show_all_days'] or current_events):
             event_column.append(format_day(start.date(), day_format, conf['locale']))
         event_column.extend(current_events)
-        start = datetime(*start.date().timetuple()[:3]) + timedelta(days=1)
+        start = dt.datetime(*start.date().timetuple()[:3]) + dt.timedelta(days=1)
 
     if event_column == []:
         event_column = [style('No events', bold=True)]
@@ -277,8 +280,13 @@
                     categories=None, repeat=None, until=None, alarms=None,
                     format=None, env=None):
     try:
-        info = utils.eventinfofstr(info, conf['locale'], adjust_reasonably=True, localize=False)
-    except ValueError:
+        info = parse_datetime.eventinfofstr(
+            info, conf['locale'],
+            conf['default']['default_event_duration'],
+            conf['default']['default_dayevent_duration'],
+            adjust_reasonably=True, localize=False,
+        )
+    except DateTimeParseError:
         info = dict()
 
     while True:
@@ -297,7 +305,7 @@
             end_string = info["dtend"].strftime(conf['locale']['datetimeformat'])
             range_string = start_string + ' ' + end_string
         daterange = prompt("datetime range", default=range_string)
-        start, end, allday = utils.guessrangefstr(
+        start, end, allday = parse_datetime.guessrangefstr(
             daterange, conf['locale'], adjust_reasonably=True)
         info['dtstart'] = start
         info['dtend'] = end
@@ -336,7 +344,12 @@
                     categories=None, repeat=None, until=None, alarms=None,
                     format=None, env=None):
     """construct a new event from a string and add it"""
-    info = utils.eventinfofstr(info, conf['locale'], adjust_reasonably=True, localize=False)
+    info = parse_datetime.eventinfofstr(
+        info, conf['locale'],
+        conf['default']['default_event_duration'],
+        conf['default']['default_dayevent_duration'],
+        adjust_reasonably=True, localize=False
+    )
     new_from_args(
         collection, calendar_name, conf, format=format, env=env,
         location=location, categories=categories, repeat=repeat,
@@ -349,8 +362,10 @@
                   categories=None, repeat=None, until=None, alarms=None,
                   timezone=None, format=None, env=None):
     """Create a new event from arguments and add to vdirs"""
+    if isinstance(categories, str):
+        categories = list([category.strip() for category in categories.split(',')])
     try:
-        event = utils.new_event(
+        event = new_vevent(
             locale=conf['locale'], location=location, categories=categories,
             repeat=repeat, until=until, alarms=alarms, dtstart=dtstart,
             dtend=dtend, summary=summary, description=description, timezone=timezone,
@@ -370,7 +385,7 @@
     if conf['default']['print_new'] == 'event':
         if format is None:
             format = conf['view']['event_format']
-        echo(event.format(format, datetime.now(), env=env))
+        echo(event.format(format, dt.datetime.now(), env=env))
     elif conf['default']['print_new'] == 'path':
         path = os.path.join(
             collection._calendars[event.calendar]['path'],
@@ -412,7 +427,7 @@
     options["alarm"] = {"short": "a"}
     options["Delete"] = {"short": "D"}
 
-    now = datetime.now()
+    now = dt.datetime.now()
 
     while True:
         choice = present_options(options, prefix="Edit?", width=width)
@@ -434,10 +449,10 @@
             current = event.format("{start} {end}", relative_to=now)
             value = prompt("datetime range", default=current)
             try:
-                start, end, allday = utils.guessrangefstr(value, locale)
+                start, end, allday = parse_datetime.guessrangefstr(value, locale)
                 event.update_start_end(start, end)
                 edited = True
-            except:
+            except:  # noqa
                 echo("error parsing range")
         elif choice == "repeat":
             recur = event.recurobject
@@ -452,13 +467,13 @@
                 until = prompt('until (or "None")', until)
                 if until == 'None':
                     until = None
-                rrule = utils.rrulefstr(freq, until, locale)
+                rrule = parse_datetime.rrulefstr(freq, until, locale)
                 event.update_rrule(rrule)
             edited = True
         elif choice == "alarm":
             default_alarms = []
             for a in event.alarms:
-                s = utils.timedelta2str(-1 * a[0])
+                s = parse_datetime.timedelta2str(-1 * a[0])
                 default_alarms.append(s)
 
             default = ', '.join(default_alarms)
@@ -469,7 +484,7 @@
                 alarm = ""
             alarm_list = []
             for a in alarm.split(","):
-                alarm_trig = -1 * utils.guesstimedeltafstr(a.strip())
+                alarm_trig = -1 * parse_datetime.guesstimedeltafstr(a.strip())
                 new_alarm = (alarm_trig, event.description)
                 alarm_list += [new_alarm]
             event.update_alarms(alarm_list)
@@ -489,7 +504,10 @@
             value = prompt(question, default)
             if allow_none and value == "None":
                 value = ""
-            getattr(event, "update_" + attr)(value)
+            if attr == 'categories':
+                getattr(event, "update_" + attr)(list([cat.strip() for cat in value.split(',')]))
+            else:
+                getattr(event, "update_" + attr)(value)
             edited = True
 
         if edited:
@@ -503,15 +521,15 @@
             format = conf['view']['event_format']
 
     term_width, _ = get_terminal_size()
-    now = conf['locale']['local_timezone'].localize(datetime.now())
+    now = conf['locale']['local_timezone'].localize(dt.datetime.now())
 
     events = sorted(collection.search(search_string))
     for event in events:
         if not allow_past:
             if event.allday and event.end < now.date():
-                    continue
+                continue
             elif not event.allday and event.end_local < now:
-                    continue
+                continue
         event_text = textwrap.wrap(event.format(format, relative_to=now), term_width)
         echo(''.join(event_text))
         if not edit_event(event, collection, locale, allow_quit=True, width=term_width):
@@ -543,7 +561,7 @@
     """
     if format is None:
         format = conf['view']['event_format']
-    vevents = utils.split_ics(ics, random_uid, conf['locale']['default_timezone'])
+    vevents = split_ics(ics, random_uid, conf['locale']['default_timezone'])
     for vevent in vevents:
         import_event(vevent, collection, conf['locale'], batch, format, env)
 
@@ -557,11 +575,11 @@
     """
     # print all sub-events
     if not batch:
-        for item in icalendar.Calendar.from_ical(vevent).walk():
+        for item in cal_from_ics(vevent).walk():
             if item.name == 'VEVENT':
                 event = Event.fromVEvents(
                     [item], calendar=collection.default_calendar_name, locale=locale)
-                echo(event.format(format, datetime.now(), env=env))
+                echo(event.format(format, dt.datetime.now(), env=env))
 
     # get the calendar to insert into
     if not collection.writable_names:
@@ -605,7 +623,7 @@
 def print_ics(conf, name, ics, format):
     if format is None:
         format = conf['view']['agenda_event_format']
-    cal = icalendar.Calendar.from_ical(ics)
+    cal = cal_from_ics(ics)
     events = [item for item in cal.walk() if item.name == 'VEVENT']
     events_grouped = defaultdict(list)
     for event in events:
@@ -613,9 +631,9 @@
 
     vevents = list()
     for uid in events_grouped:
-        vevents.append(sorted(events_grouped[uid], key=sort_key))
+        vevents.append(sorted(events_grouped[uid], key=sort_vevent_key))
 
     echo('{} events found in {}'.format(len(vevents), name))
     for sub_event in vevents:
         event = Event.fromVEvents(sub_event, locale=conf['locale'])
-        echo(event.format(format, datetime.now()))
+        echo(event.format(format, dt.datetime.now()))
diff -Nru khal-0.9.10/khal/exceptions.py khal-0.10.2/khal/exceptions.py
--- khal-0.9.10/khal/exceptions.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/exceptions.py	2020-07-29 18:17:53.000000000 +0200
@@ -32,6 +32,10 @@
     pass
 
 
+class DateTimeParseError(FatalError):
+    pass
+
+
 class ConfigurationError(FatalError):
     pass
 
@@ -42,5 +46,11 @@
     pass
 
 
+class UnsupportedRecurrence(Error):
+
+    """raised if the RRULE is not understood by dateutil.rrule"""
+    pass
+
+
 class InvalidDate(Error):
     pass
diff -Nru khal-0.9.10/khal/icalendar.py khal-0.10.2/khal/icalendar.py
--- khal-0.9.10/khal/icalendar.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/khal/icalendar.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,498 @@
+# Copyright (c) 2013-2017 Christian Geier et al.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""collection of icalendar helper functions"""
+
+import datetime as dt
+import dateutil.rrule
+import icalendar
+import logging
+import pytz
+from collections import defaultdict
+
+from .exceptions import UnsupportedRecurrence
+from .parse_datetime import guesstimedeltafstr, rrulefstr
+from .utils import generate_random_uid, localize_strip_tz, to_unix_time
+
+logger = logging.getLogger('khal')
+
+
+def split_ics(ics, random_uid=False, default_timezone=None):
+    """split an ics string into several according to VEVENT's UIDs
+
+    and sort the right VTIMEZONEs accordingly
+    ignores all other ics components
+    :type ics: str
+    :param random_uid: assign random uids to all events
+    :type random_uid: bool
+    :rtype list:
+    """
+    cal = cal_from_ics(ics)
+    tzs = {}
+
+    events_grouped = defaultdict(list)
+    for item in cal.walk():
+
+        # Since some events could have a Windows format timezone (e.g. 'New Zealand
+        # Standard Time' for 'Pacific/Auckland' in Olson format), we convert any
+        # Windows format timezones to Olson.
+        if item.name == 'VTIMEZONE':
+            if item['TZID'] in icalendar.windows_to_olson.WINDOWS_TO_OLSON:
+                key = icalendar.windows_to_olson.WINDOWS_TO_OLSON[item['TZID']]
+            else:
+                key = item['TZID']
+            tzs[key] = item
+
+        if item.name == 'VEVENT':
+            events_grouped[item['UID']].append(item)
+        else:
+            continue
+    return [ics_from_list(events, tzs, random_uid, default_timezone) for uid, events in
+            sorted(events_grouped.items())]
+
+
+def new_event(locale, dtstart=None, dtend=None, summary=None, timezone=None,
+              allday=False, description=None, location=None, categories=None,
+              repeat=None, until=None, alarms=None):
+    """create a new event
+
+    :param dtstart: starttime of that event
+    :type dtstart: datetime
+    :param dtend: end time of that event, if this is a *date*, this value is
+        interpreted as being the last date the event is scheduled on, i.e.
+        the VEVENT DTEND will be *one day later*
+    :type dtend: datetime
+    :param summary: description of the event, used in the SUMMARY property
+    :type summary: unicode
+    :param timezone: timezone of the event (start and end)
+    :type timezone: pytz.timezone
+    :param allday: if set to True, we will not transform dtstart and dtend to
+        datetime
+    :type allday: bool
+    :returns: event
+    :rtype: icalendar.Event
+    """
+
+    if dtstart is None:
+        raise ValueError("no start given")
+    if dtend is None:
+        raise ValueError("no end given")
+    if summary is None:
+        raise ValueError("no summary given")
+
+    if not allday and timezone is not None:
+        dtstart = timezone.localize(dtstart)
+        dtend = timezone.localize(dtend)
+
+    event = icalendar.Event()
+    event.add('dtstart', dtstart)
+    event.add('dtend', dtend)
+    event.add('dtstamp', dt.datetime.now())
+    event.add('summary', summary)
+    event.add('uid', generate_random_uid())
+    # event.add('sequence', 0)
+
+    if description:
+        event.add('description', description)
+    if location:
+        event.add('location', location)
+    if categories:
+        event.add('categories', categories)
+    if repeat and repeat != "none":
+        rrule = rrulefstr(repeat, until, locale)
+        event.add('rrule', rrule)
+    if alarms:
+        for alarm in alarms.split(","):
+            alarm = alarm.strip()
+            alarm_trig = -1 * guesstimedeltafstr(alarm)
+            new_alarm = icalendar.Alarm()
+            new_alarm.add('ACTION', 'DISPLAY')
+            new_alarm.add('TRIGGER', alarm_trig)
+            new_alarm.add('DESCRIPTION', description)
+            event.add_component(new_alarm)
+    return event
+
+
+def ics_from_list(events, tzs, random_uid=False, default_timezone=None):
+    """convert an iterable of icalendar.Events to an icalendar.Calendar
+
+    :params events: list of events all with the same uid
+    :type events: list(icalendar.cal.Event)
+    :param random_uid: assign random uids to all events
+    :type random_uid: bool
+    :param tzs: collection of timezones
+    :type tzs: dict(icalendar.cal.Vtimzone
+    """
+    calendar = icalendar.Calendar()
+    calendar.add('version', '2.0')
+    calendar.add(
+        'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN'
+    )
+
+    if random_uid:
+        new_uid = generate_random_uid()
+
+    needed_tz, missing_tz = set(), set()
+    for sub_event in events:
+        sub_event = sanitize(sub_event, default_timezone=default_timezone)
+        if random_uid:
+            sub_event['UID'] = new_uid
+        # icalendar round-trip converts `TZID=a b` to `TZID="a b"` investigate, file bug XXX
+        for prop in ['DTSTART', 'DTEND', 'DUE', 'EXDATE', 'RDATE', 'RECURRENCE-ID', 'DUE']:
+            if isinstance(sub_event.get(prop), list):
+                items = sub_event.get(prop)
+            else:
+                items = [sub_event.get(prop)]
+
+            for item in items:
+                if not (hasattr(item, 'dt') or hasattr(item, 'dts')):
+                    continue
+                # if prop is a list, all items have the same parameters
+                datetime_ = item.dts[0].dt if hasattr(item, 'dts') else item.dt
+                if not hasattr(datetime_, 'tzinfo'):
+                    continue
+                # check for datetimes' timezones which are not understood by
+                # icalendar
+                if datetime_.tzinfo is None and 'TZID' in item.params and \
+                        item.params['TZID'] not in missing_tz:
+                    logger.warning(
+                        'Cannot find timezone `{}` in .ics file, using default timezone. '
+                        'This can lead to erroneous time shifts'.format(item.params['TZID'])
+                    )
+                    missing_tz.add(item.params['TZID'])
+                elif datetime_.tzinfo and datetime_.tzinfo != pytz.UTC and \
+                        datetime_.tzinfo not in needed_tz:
+                    needed_tz.add(datetime_.tzinfo)
+
+    for tzid in needed_tz:
+        if str(tzid) in tzs:
+            calendar.add_component(tzs[str(tzid)])
+        else:
+            logger.warning(
+                'Cannot find timezone `{}` in .ics file, this could be a bug, '
+                'please report this issue at http://github.com/pimutils/khal/.'.format(tzid))
+    for sub_event in events:
+        calendar.add_component(sub_event)
+    return calendar.to_ical().decode('utf-8')
+
+
+def expand(vevent, href=''):
+    """
+    Constructs a list of start and end dates for all recurring instances of the
+    event defined in vevent.
+
+    It considers RRULE as well as RDATE and EXDATE properties. In case of
+    unsupported recursion rules an UnsupportedRecurrence exception is thrown.
+
+    If the vevent contains a RECURRENCE-ID property, no expansion is done,
+    the function still returns a tuple of start and end (date)times.
+
+    :param vevent: vevent to be expanded
+    :type vevent: icalendar.cal.Event
+    :param href: the href of the vevent, used for more informative logging and
+                 nothing else
+    :type href: str
+    :returns: list of start and end (date)times of the expanded event
+    :rtype: list(tuple(datetime, datetime))
+    """
+    # we do this now and than never care about the "real" end time again
+    if 'DURATION' in vevent:
+        duration = vevent['DURATION'].dt
+    else:
+        duration = vevent['DTEND'].dt - vevent['DTSTART'].dt
+
+    # if this vevent has a RECURRENCE_ID property, no expansion will be
+    # performed
+    expand = not bool(vevent.get('RECURRENCE-ID'))
+
+    events_tz = getattr(vevent['DTSTART'].dt, 'tzinfo', None)
+    allday = not isinstance(vevent['DTSTART'].dt, dt.datetime)
+
+    def sanitize_datetime(date):
+        if allday and isinstance(date, dt.datetime):
+            date = date.date()
+        if events_tz is not None:
+            date = events_tz.localize(date)
+        return date
+
+    rrule_param = vevent.get('RRULE')
+    if expand and rrule_param is not None:
+        vevent = sanitize_rrule(vevent)
+
+        # dst causes problem while expanding the rrule, therefore we transform
+        # everything to naive datetime objects and transform back after
+        # expanding
+        # See https://github.com/dateutil/dateutil/issues/102
+        dtstart = vevent['DTSTART'].dt
+        if events_tz:
+            dtstart = dtstart.replace(tzinfo=None)
+
+        rrule = dateutil.rrule.rrulestr(
+            rrule_param.to_ical().decode(),
+            dtstart=dtstart,
+            ignoretz=True,
+        )
+
+        if rrule._until is None:
+            # rrule really doesn't like to calculate all recurrences until
+            # eternity, so we only do it until 2037, because a) I'm not sure
+            # if python can deal with larger datetime values yet and b) pytz
+            # doesn't know any larger transition times
+            rrule._until = dt.datetime(2037, 12, 31)
+        elif events_tz and 'Z' in rrule_param.to_ical().decode():
+            rrule._until = pytz.UTC.localize(
+                rrule._until).astimezone(events_tz).replace(tzinfo=None)
+
+        rrule = map(sanitize_datetime, rrule)
+
+        logger.debug('calculating recurrence dates for {}, this might take some time.'.format(href))
+
+        # RRULE and RDATE may specify the same date twice, it is recommended by
+        # the RFC to consider this as only one instance
+        dtstartl = set(rrule)
+        if not dtstartl:
+            raise UnsupportedRecurrence()
+    else:
+        dtstartl = {vevent['DTSTART'].dt}
+
+    def get_dates(vevent, key):
+        # TODO replace with get_all_properties
+        dates = vevent.get(key)
+        if dates is None:
+            return
+        if not isinstance(dates, list):
+            dates = [dates]
+
+        dates = (leaf.dt for tree in dates for leaf in tree.dts)
+        dates = localize_strip_tz(dates, events_tz)
+        return map(sanitize_datetime, dates)
+
+    # include explicitly specified recursion dates
+    if expand:
+        dtstartl.update(get_dates(vevent, 'RDATE') or ())
+
+    # remove excluded dates
+    if expand:
+        for date in get_dates(vevent, 'EXDATE') or ():
+            try:
+                dtstartl.remove(date)
+            except KeyError:
+                logger.warning(
+                    'In event {}, excluded instance starting at {} not found, '
+                    'event might be invalid.'.format(href, date))
+
+    dtstartend = [(start, start + duration) for start in dtstartl]
+    # not necessary, but I prefer deterministic output
+    dtstartend.sort()
+    return dtstartend
+
+
+def assert_only_one_uid(cal: icalendar.Calendar):
+    """assert the all VEVENTs in cal have the same UID"""
+    uids = set()
+    for item in cal.walk():
+        if item.name == 'VEVENT':
+            uids.add(item['UID'])
+    if len(uids) > 1:
+        return False
+    else:
+        return True
+
+
+def sanitize(vevent, default_timezone, href='', calendar=''):
+    """
+    clean up vevents we do not understand
+
+    :param vevent: the vevent that needs to be cleaned
+    :type vevent: icalendar.cal.Event
+    :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 vevent
+    :rtype: icalendar.cal.Event
+    """
+    # convert localized datetimes with timezone information we don't
+    # understand to the default timezone
+    # TODO do this for everything where a TZID can appear (RDATE, EXDATE)
+    for prop in ['DTSTART', 'DTEND', 'DUE', 'RECURRENCE-ID']:
+        if prop in vevent and invalid_timezone(vevent[prop]):
+            timezone = vevent[prop].params.get('TZID')
+            value = default_timezone.localize(vevent.pop(prop).dt)
+            vevent.add(prop, value)
+            logger.warning(
+                "{} localized in invalid or incomprehensible timezone `{}` in {}/{}. "
+                "This could lead to this event being wrongly displayed."
+                "".format(prop, timezone, calendar, href)
+            )
+
+    vdtstart = vevent.pop('DTSTART', None)
+    vdtend = vevent.pop('DTEND', None)
+    dtstart = getattr(vdtstart, 'dt', None)
+    dtend = getattr(vdtend, 'dt', None)
+
+    # event with missing DTSTART
+    if dtstart is None:
+        raise ValueError('Event has no start time (DTSTART).')
+    dtstart, dtend = sanitize_timerange(
+        dtstart, dtend, duration=vevent.get('DURATION', None))
+
+    vevent.add('DTSTART', dtstart)
+    if dtend is not None:
+        vevent.add('DTEND', dtend)
+    return vevent
+
+
+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.'''
+
+    if isinstance(dtstart, dt.datetime) and isinstance(dtend, dt.datetime):
+        if dtstart.tzinfo and not dtend.tzinfo:
+            logger.warning(
+                "Event end time has no timezone. "
+                "Assuming it's the same timezone as the start time"
+            )
+            dtend = dtstart.tzinfo.localize(dtend)
+        if not dtstart.tzinfo and dtend.tzinfo:
+            logger.warning(
+                "Event start time has no timezone. "
+                "Assuming it's the same timezone as the end time"
+            )
+            dtstart = dtend.tzinfo.localize(dtstart)
+
+    if dtend is None and duration is None:
+        if isinstance(dtstart, dt.datetime):
+            dtstart = dtstart.date()
+        dtend = dtstart + dt.timedelta(days=1)
+    elif dtend is not None:
+        if dtend < dtstart:
+            raise ValueError('The event\'s end time (DTEND) is older than '
+                             'the event\'s start time (DTSTART).')
+        elif dtend == dtstart:
+            logger.warning(
+                "Event start time and end time are the same. "
+                "Assuming the event's duration is one hour."
+            )
+            dtend += dt.timedelta(hours=1)
+
+    return dtstart, dtend
+
+
+def sanitize_rrule(vevent):
+    """fix problems with RRULE:UNTIL"""
+    if 'rrule' in vevent and 'UNTIL' in vevent['rrule']:
+        until = vevent['rrule']['UNTIL'][0]
+        dtstart = vevent['dtstart'].dt
+        # DTSTART is date, UNTIL is datetime
+        if not isinstance(dtstart, dt.datetime) and isinstance(until, dt.datetime):
+            vevent['rrule']['until'] = until.date()
+    return vevent
+
+
+def invalid_timezone(prop):
+    """check if an icalendar property has a timezone attached we don't understand"""
+    if hasattr(prop.dt, 'tzinfo') and prop.dt.tzinfo is None and 'TZID' in prop.params:
+        return True
+    else:
+        return False
+
+
+def _get_all_properties(vevent, prop):
+    """Get all properties from a vevent, even if there are several entries
+
+    example input:
+    EXDATE:1234,4567
+    EXDATE:7890
+
+    returns: [1234, 4567, 7890]
+
+    :type vevent: icalendar.cal.Event
+    :type prop: str
+    """
+    if prop not in vevent:
+        return list()
+    if isinstance(vevent[prop], list):
+        rdates = [leaf.dt for tree in vevent[prop] for leaf in tree.dts]
+    else:
+        rdates = [vddd.dt for vddd in vevent[prop].dts]
+    return rdates
+
+
+def delete_instance(vevent, instance):
+    """remove a recurrence instance from a VEVENT's RRDATE list or add it
+    to the EXDATE list
+
+    :type vevent: icalendar.cal.Event
+    :type instance: datetime.datetime
+    """
+    # TODO check where this instance is coming from and only call the
+    # appropriate function
+    if 'RRULE' in vevent:
+        exdates = _get_all_properties(vevent, 'EXDATE')
+        exdates += [instance]
+        vevent.pop('EXDATE')
+        vevent.add('EXDATE', exdates)
+    if 'RDATE' in vevent:
+        rdates = [one for one in _get_all_properties(vevent, 'RDATE') if one != instance]
+        vevent.pop('RDATE')
+        if rdates != []:
+            vevent.add('RDATE', rdates)
+
+
+def sort_key(vevent):
+    """helper function to determine order of VEVENTS
+    so that recurrence-id events come after the corresponding rrule event, etc
+    :param vevent: icalendar.Event
+    :rtype: tuple(str, int)
+    """
+    assert isinstance(vevent, icalendar.Event)
+    uid = str(vevent['UID'])
+    rec_id = vevent.get('RECURRENCE-ID')
+    if rec_id is None:
+        return uid, 0
+    rrange = rec_id.params.get('RANGE')
+    if rrange == 'THISANDFUTURE':
+        return uid, to_unix_time(rec_id.dt)
+    else:
+        return uid, 1
+
+
+def cal_from_ics(ics):
+    try:
+        cal = icalendar.Calendar.from_ical(ics)
+    except ValueError as error:
+        if (len(error.args) > 0 and isinstance(error.args[0], str) and
+                error.args[0].startswith('Offset must be less than 24 hours')):
+            logger.warning(
+                'Invalid timezone offset encountered, '
+                'timezone information may be wrong: ' + str(error.args[0])
+            )
+            icalendar.vUTCOffset.ignore_exceptions = True
+            cal = icalendar.Calendar.from_ical(ics)
+            icalendar.vUTCOffset.ignore_exceptions = False
+    return cal
diff -Nru khal-0.9.10/khal/khalendar/backend.py khal-0.10.2/khal/khalendar/backend.py
--- khal-0.9.10/khal/khalendar/backend.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/backend.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,32 +20,28 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 """
 The SQLite backend implementation.
-
-note on naming:
-  * every variable name vevent should be of type icalendar.Event
-  * every variable named event should be of type khal.khalendar.Events
-  * variables named vevents/events (plural) should be iterables of their
-    respective types
-
 """
-# TODO remove creating Events from SQLiteDb
-# we currently expect str/CALENDAR objects but return Event(), we should
-# accept and return the same kind of events
+
 import contextlib
-from datetime import datetime, timedelta
-from os import makedirs, path
+import datetime as dt
+from enum import IntEnum
+import logging
 import sqlite3
+from os import makedirs, path
+from typing import Any, Dict, Iterable, List, Optional, Tuple
 
-from dateutil import parser
 import icalendar
 import pytz
+from dateutil import parser
 
-from .event import Event, EventStandIn
-from . import utils
-from .. import log
-from .exceptions import CouldNotCreateDbDir, OutdatedDbVersionError, UpdateFailed
+from .. import utils
+from ..icalendar import (cal_from_ics, assert_only_one_uid,
+                         expand as expand_vevent, sanitize as sanitize_vevent,
+                         sort_key as sort_vevent_key)
+from .exceptions import (CouldNotCreateDbDir, OutdatedDbVersionError,
+                         UpdateFailed, NonUniqueUID)
 
-logger = log.logger
+logger = logging.getLogger('khal')
 
 DB_VERSION = 5  # The current db layout version
 
@@ -53,51 +49,34 @@
 THISANDFUTURE = 'THISANDFUTURE'
 THISANDPRIOR = 'THISANDPRIOR'
 
-DATE = 0
-DATETIME = 1
-
 PROTO = 'PROTO'
 
 
-def sort_key(vevent):
-    """helper function to determine order of VEVENTS
-
-    so that recurrence-id events come after the corresponding rrule event, etc
-
-    :param vevent: icalendar.Event
-    :rtype: tuple(str, int)
-    """
-    assert isinstance(vevent, icalendar.Event)
-    uid = str(vevent['UID'])
-    rec_id = vevent.get(RECURRENCE_ID)
-    if rec_id is None:
-        return uid, 0
-    rrange = rec_id.params.get('RANGE')
-    if rrange == THISANDFUTURE:
-        return uid, utils.to_unix_time(rec_id.dt)
-    else:
-        return uid, 1
+class EventType(IntEnum):
+    DATE = 0
+    DATETIME = 1
 
 
 class SQLiteDb(object):
     """
     This class should provide a caching database for a calendar, keeping raw
-    vevents in one table but allowing to retrieve events by dates (via the help
+    vevents in one table and allowing to retrieve them by dates (via the help
     of some auxiliary tables)
 
-    :param calendar: the `name` of this calendar, if the same *name* and
-                     *dbpath* is given on next creation of an SQLiteDb object
-                     the same tables will be used
-    :type calendar: str
+    :param calendar: names of calendars to select from, those are used as
+        additional itentifiers together with event's uids. Each (uid, calendar)
+        combination should be unique.
     :param db_path: path where this sqlite database will be saved, if this is
-                    None, a place according to the XDG specifications will be
-                    chosen
-    :type db_path: str or None
+        None, a place according to the XDG specifications will be chosen
     """
 
-    def __init__(self, calendars, db_path, locale):
+    def __init__(self,
+                 calendars: Iterable[str],
+                 db_path: Optional[str],
+                 locale: Dict[str, str],
+                 ) -> None:
         assert db_path is not None
-        self.calendars = calendars
+        self.calendars = list(calendars)
         self.db_path = path.expanduser(db_path)
         self._create_dbdir()
         self.locale = locale
@@ -108,24 +87,20 @@
         self._check_calendars_exists()
         self._check_table_version()
 
-    @property
-    def _select_calendars(self):
-        return ', '.join(['\'' + cal + '\'' for cal in self.calendars])
-
     @contextlib.contextmanager
     def at_once(self):
         assert not self._at_once
         self._at_once = True
         try:
             yield self
-        except:
+        except:  # noqa
             raise
         else:
             self.conn.commit()
         finally:
             self._at_once = False
 
-    def _create_dbdir(self):
+    def _create_dbdir(self) -> None:
         """create the dbdir if it doesn't exist"""
         if self.db_path == ':memory:':
             return None
@@ -136,10 +111,10 @@
                 makedirs(dbdir, mode=0o770)
                 logger.debug('success')
             except OSError as error:
-                logger.fatal('failed to create {0}: {1}'.format(dbdir, error))
+                logger.critical('failed to create {0}: {1}'.format(dbdir, error))
                 raise CouldNotCreateDbDir()
 
-    def _check_table_version(self):
+    def _check_table_version(self) -> None:
         """tests for current db Version
         if the table is still empty, insert db_version
         """
@@ -155,7 +130,7 @@
                 " is probably an invalid or outdated database.\n"
                 "You should consider removing it and running khal again.")
 
-    def _create_default_tables(self):
+    def _create_default_tables(self) -> None:
         """creates version and calendar tables and inserts table version number
         """
         self.cursor.execute('CREATE TABLE IF NOT EXISTS '
@@ -197,13 +172,12 @@
             );''')
         self.conn.commit()
 
-    def _check_calendars_exists(self):
+    def _check_calendars_exists(self) -> None:
         """make sure an entry for the current calendar exists in `calendar`
         table
         """
         for cal in self.calendars:
-            self.cursor.execute('''SELECT count(*) FROM calendars
-                    WHERE calendar = ?;''', (cal,))
+            self.cursor.execute('''SELECT count(*) FROM calendars WHERE calendar = ?;''', (cal,))
             result = self.cursor.fetchone()
 
             if result[0] != 0:
@@ -213,7 +187,7 @@
                 stuple = (cal, '')
                 self.sql_ex(sql_s, stuple)
 
-    def sql_ex(self, statement, stuple=''):
+    def sql_ex(self, statement: str, stuple: tuple) -> List:
         """wrapper for sql statements, does a "fetchall" """
         self.cursor.execute(statement, stuple)
         result = self.cursor.fetchall()
@@ -221,34 +195,38 @@
             self.conn.commit()
         return result
 
-    def update(self, vevent_str, href, etag='', calendar=None):
-        """insert a new or update an existing card in the db
+    def update(self, vevent_str: str, href: str, etag: str='', calendar: str=None) -> None:
+        """insert a new or update an existing event into the db
 
         This is mostly a wrapper around two SQL statements, doing some cleanup
         before.
 
         :param vevent_str: event to be inserted or updated.
-                           We assume that even if it contains more than one
-                           VEVENT, that they are all part of the same event and
-                           all have the same UID
-        :type vevent: unicode
+            We assume that even if it contains more than one VEVENT, that they
+            are all part of the same event and all have the same UID
         :param href: href of the card on the server, if this href already
-                     exists in the db the card gets updated. If no href is
-                     given, a random href is chosen and it is implied that this
-                     card does not yet exist on the server, but will be
-                     uploaded there on next sync.
-        :type href: str()
+            exists in the db the card gets updated. If no href is given, a
+            random href is chosen and it is implied that this card does not yet
+            exist on the server, but will be uploaded there on next sync.
         :param etag: the etag of the vcard, if this etag does not match the
-                     remote etag on next sync, this card will be updated from
-                     the server. For locally created vcards this should not be
-                     set
-        :type etag: str()
+            remote etag on next sync, this card will be updated from the server.
+            For locally created vcards this should not be set
         """
         assert calendar is not None
         assert href is not None
-        ical = icalendar.Event.from_ical(vevent_str)
+        ical = cal_from_ics(vevent_str)
         check_for_errors(ical, calendar, href)
-        vevents = (utils.sanitize(c, self.locale['default_timezone'], href, calendar) for
+        if not assert_only_one_uid(ical):
+            logger.warning(
+                "The .ics file at {}/{} contains multiple UIDs.\n"
+                "This should not occur in vdir .ics files.\n"
+                "If you didn't edit the file by hand, please report a bug "
+                "at https://github.com/pimutils/khal/issues .\n"
+                "If you want to import it, please use `khal import FILE`."
+                "".format(calendar, href)
+            )
+            raise NonUniqueUID
+        vevents = (sanitize_vevent(c, self.locale['default_timezone'], href, calendar) for
                    c in ical.walk() if c.name == 'VEVENT')
         # Need to delete the whole event in case we are updating a
         # recurring event with an event which is either not recurring any
@@ -256,71 +234,94 @@
         # tables. There are obviously better ways to achieve the same
         # result.
         self.delete(href, calendar=calendar)
-        for vevent in sorted(vevents, key=sort_key):
+        for vevent in sorted(vevents, key=sort_vevent_key):
             check_for_errors(vevent, calendar, href)
             check_support(vevent, href, calendar)
             self._update_impl(vevent, href, calendar)
 
-        sql_s = ('INSERT INTO events '
-                 '(item, etag, href, calendar) '
-                 'VALUES (?, ?, ?, ?);')
+        sql_s = ('INSERT INTO events (item, etag, href, calendar) VALUES (?, ?, ?, ?);')
         stuple = (vevent_str, etag, href, calendar)
         self.sql_ex(sql_s, stuple)
 
-    def update_birthday(self, vevent, href, etag='', calendar=None):
-        """
-        XXX write docstring
+    def update_vcf_dates(self, vevent_str: str, href: str, etag: str='',
+                         calendar: str=None) -> None:
+        """insert events from a vcard into the db
+
+        This is will parse BDAY, ANNIVERSARY, X-ANNIVERSARY and X-ABDATE fields.
+        It will also look for any X-ABLABEL fields associated with an X-ABDATE
+        and use that in the event description.
+
+        :param vevent_str: contact (vcard) to be parsed.
+        :param href: href of the card on the server, if this href already
+            exists in the db the card gets updated. If no href is given, a
+            random href is chosen and it is implied that this card does not yet
+            exist on the server, but will be uploaded there on next sync.
+        :param etag: the etag of the vcard, if this etag does not match the
+            remote etag on next sync, this card will be updated from the server.
+            For locally created vcards this should not be set
         """
         assert calendar is not None
         assert href is not None
-        self.delete(href, calendar=calendar)
-        ical = icalendar.Event.from_ical(vevent)
+        # Delete all event entries for this contact
+        self.deletelike(href + '%', calendar=calendar)
+        ical = cal_from_ics(vevent_str)
         vcard = ical.walk()[0]
-        if 'BDAY' in vcard.keys():
-            bday = vcard['BDAY']
-            if isinstance(bday, list):
-                logger.warning(
-                    'Vcard {0} in collection {1} has more than one '
-                    'BIRTHDAY, will be skipped and not be available '
-                    'in khal.'.format(href, calendar)
-                )
-                return
-            try:
-                if bday[0:2] == '--' and bday[3] != '-':
-                    bday = '1900' + bday[2:]
-                    orig_bday = False
+        for key in vcard.keys():
+            if key in ['BDAY', 'X-ANNIVERSARY', 'ANNIVERSARY'] or key.endswith('X-ABDATE'):
+                date = vcard[key]
+                if isinstance(date, list):
+                    logger.warning(
+                        'Vcard {0} in collection {1} has more than one '
+                        '{2}, will be skipped and not be available '
+                        'in khal.'.format(href, calendar, key)
+                    )
+                    continue
+                try:
+                    if date[0:2] == '--' and date[3] != '-':
+                        date = '1900' + date[2:]
+                        orig_date = False
+                    else:
+                        orig_date = True
+                    date = parser.parse(date).date()
+                except ValueError:
+                    logger.warning(
+                        'cannot parse {0} in {1} in collection {2}'.format(key, href, calendar))
+                    continue
+                if 'FN' in vcard:
+                    name = vcard['FN']
                 else:
-                    orig_bday = True
-                bday = parser.parse(bday).date()
-            except ValueError:
-                logger.warning(
-                    'cannot parse BIRTHDAY in {0} in collection {1}'.format(href, calendar))
-                return
-            if 'FN' in vcard:
-                name = vcard['FN']
-            else:
-                n = vcard['N'].split(';')
-                name = ' '.join([n[1], n[2], n[0]])
-            event = icalendar.Event()
-            event.add('dtstart', bday)
-            event.add('dtend', bday + timedelta(days=1))
-            if bday.month == 2 and bday.day == 29:  # leap year
-                event.add('rrule', {'freq': 'YEARLY', 'BYYEARDAY': 60})
-            else:
-                event.add('rrule', {'freq': 'YEARLY'})
-            if orig_bday:
-                event.add('x-birthday',
-                          '{:04}{:02}{:02}'.format(bday.year, bday.month, bday.day))
-                event.add('x-fname', name)
-            event.add('summary', '{0}\'s birthday'.format(name))
-            event.add('uid', href)
-            event_str = event.to_ical().decode('utf-8')
-            self._update_impl(event, href, calendar)
-            sql_s = ('INSERT INTO events (item, etag, href, calendar) VALUES (?, ?, ?, ?);')
-            stuple = (event_str, etag, href, calendar)
-            self.sql_ex(sql_s, stuple)
+                    n = vcard['N'].split(';')
+                    name = ' '.join([n[1], n[2], n[0]])
+                vevent = icalendar.Event()
+                vevent.add('dtstart', date)
+                vevent.add('dtend', date + dt.timedelta(days=1))
+                if date.month == 2 and date.day == 29:  # leap year
+                    vevent.add('rrule', {'freq': 'YEARLY', 'BYYEARDAY': 60})
+                else:
+                    vevent.add('rrule', {'freq': 'YEARLY'})
+                description = get_vcard_event_description(vcard, key)
+                if orig_date:
+                    if key == 'BDAY':
+                        xtag = 'x-birthday'
+                    elif key.endswith('ANNIVERSARY'):
+                        xtag = 'x-anniversary'
+                    else:
+                        xtag = 'x-abdate'
+                        vevent.add('x-ablabel', description)
+                    vevent.add(xtag,
+                               '{:04}{:02}{:02}'.format(date.year, date.month, date.day))
+                    vevent.add('x-fname', name)
+                vevent.add('summary',
+                           '{0}\'s {1}'.format(name, description))
+                vevent.add('uid', href + key)
+                vevent_str = vevent.to_ical().decode('utf-8')
+                self._update_impl(vevent, href + key, calendar)
+                sql_s = ('INSERT INTO events (item, etag, href, calendar)'
+                         ' VALUES (?, ?, ?, ?);')
+                stuple = (vevent_str, etag, href + key, calendar)
+                self.sql_ex(sql_s, stuple)
 
-    def _update_impl(self, vevent, href, calendar):
+    def _update_impl(self, vevent: icalendar.cal.Event, href: str, calendar: str) -> None:
         """insert `vevent` into the database
 
         expand `vevent`'s recurrence rules (if needed) and insert all instance
@@ -336,11 +337,11 @@
             rrange = rec_id.params.get('RANGE')
 
         # testing on datetime.date won't work as datetime is a child of date
-        if not isinstance(vevent['DTSTART'].dt, datetime):
-            dtype = DATE
+        if not isinstance(vevent['DTSTART'].dt, dt.datetime):
+            dtype = EventType.DATE
         else:
-            dtype = DATETIME
-        if ('TZID' in vevent['DTSTART'].params and dtype == DATETIME) or \
+            dtype = EventType.DATETIME
+        if ('TZID' in vevent['DTSTART'].params and dtype == EventType.DATETIME) or \
                 getattr(vevent['DTSTART'].dt, 'tzinfo', None):
             recs_table = 'recs_loc'
         else:
@@ -349,10 +350,10 @@
         thisandfuture = (rrange == THISANDFUTURE)
         if thisandfuture:
             start_shift, duration = calc_shift_deltas(vevent)
-            start_shift = start_shift.days * 3600 * 24 + start_shift.seconds
-            duration = duration.days * 3600 * 24 + duration.seconds
+            start_shift_seconds = start_shift.days * 3600 * 24 + start_shift.seconds
+            duration_seconds = duration.days * 3600 * 24 + duration.seconds
 
-        dtstartend = utils.expand(vevent, href)
+        dtstartend = expand_vevent(vevent, href)
         if not dtstartend:
             # Does this event even have dates? Technically it is possible for
             # events to be empty/non-existent by deleting all their recurrences
@@ -360,7 +361,7 @@
             return
 
         for dtstart, dtend in dtstartend:
-            if dtype == DATE:
+            if dtype == EventType.DATE:
                 dbstart = utils.to_unix_time(dtstart)
                 dbend = utils.to_unix_time(dtend)
             else:
@@ -370,23 +371,27 @@
             if rec_id is not None:
                 ref = rec_inst = str(utils.to_unix_time(rec_id.dt))
             else:
-                rec_inst = dbstart
+                rec_inst = str(dbstart)
                 ref = PROTO
 
             if thisandfuture:
                 recs_sql_s = (
                     'UPDATE {0} SET dtstart = rec_inst + ?, dtend = rec_inst + ?, ref = ? '
                     'WHERE rec_inst >= ? AND href = ? AND calendar = ?;'.format(recs_table))
-                stuple = (start_shift, start_shift + duration, ref, rec_inst, href, calendar)
+                stuple_f = (
+                    start_shift_seconds, start_shift_seconds + duration_seconds,
+                    ref, rec_inst, href, calendar,
+                )
+                self.sql_ex(recs_sql_s, stuple_f)
             else:
                 recs_sql_s = (
                     'INSERT OR REPLACE INTO {0} '
                     '(dtstart, dtend, href, ref, dtype, rec_inst, calendar)'
                     'VALUES (?, ?, ?, ?, ?, ?, ?);'.format(recs_table))
-                stuple = (dbstart, dbend, href, ref, dtype, rec_inst, calendar)
-            self.sql_ex(recs_sql_s, stuple)
+                stuple_n = (dbstart, dbend, href, ref, dtype, rec_inst, calendar)
+                self.sql_ex(recs_sql_s, stuple_n)
 
-    def get_ctag(self, calendar):
+    def get_ctag(self, calendar=str) -> Optional[str]:
         stuple = (calendar, )
         sql_s = 'SELECT ctag FROM calendars WHERE calendar = ?;'
         try:
@@ -395,13 +400,13 @@
         except IndexError:
             return None
 
-    def set_ctag(self, ctag, calendar):
+    def set_ctag(self, ctag: str, calendar: str):
         stuple = (ctag, calendar, )
         sql_s = 'UPDATE calendars SET ctag = ? WHERE calendar = ?;'
         self.sql_ex(sql_s, stuple)
         self.conn.commit()
 
-    def get_etag(self, href, calendar):
+    def get_etag(self, href: str, calendar: str) -> Optional[str]:
         """get etag for href
 
         type href: str()
@@ -415,7 +420,7 @@
         except IndexError:
             return None
 
-    def delete(self, href, etag=None, calendar=None):
+    def delete(self, href: str, etag: Any=None, calendar: str=None):
         """
         removes the event from the db,
 
@@ -430,6 +435,23 @@
         sql_s = 'DELETE FROM events WHERE href = ? AND calendar = ?;'
         self.sql_ex(sql_s, (href, calendar))
 
+    def deletelike(self, href: str, etag: Any=None, calendar: str=None):
+        """
+        removes events from the db that match an SQL 'like' statement,
+
+        :param href: The pattern of hrefs to delete. May contain SQL wildcards
+                     like '%'
+        :param etag: only there for compatibility with vdirsyncer's Storage,
+                     we always delete
+        :returns: None
+        """
+        assert calendar is not None
+        for table in ['recs_loc', 'recs_float']:
+            sql_s = 'DELETE FROM {0} WHERE href LIKE ? AND calendar = ?;'.format(table)
+            self.sql_ex(sql_s, (href, calendar))
+        sql_s = 'DELETE FROM events WHERE href LIKE ? AND calendar = ?;'
+        self.sql_ex(sql_s, (href, calendar))
+
     def list(self, calendar):
         """ list all events in `calendar`
 
@@ -439,7 +461,28 @@
         sql_s = 'SELECT href, etag FROM events WHERE calendar = ?;'
         return list(set(self.sql_ex(sql_s, (calendar, ))))
 
-    def get_localized(self, start, end, minimal=False):
+    def get_localized_calendars(self, start: dt.datetime, end: dt.datetime) -> Iterable[str]:
+        assert start.tzinfo is not None
+        assert end.tzinfo is not None
+        start_u = utils.to_unix_time(start)
+        end_u = utils.to_unix_time(end)
+        sql_s = (
+            'SELECT events.calendar FROM '
+            'recs_loc JOIN events ON '
+            'recs_loc.href = events.href AND '
+            'recs_loc.calendar = events.calendar WHERE '
+            '(dtstart >= ? AND dtstart <= ? OR '
+            'dtend > ? AND dtend <= ? OR '
+            'dtstart <= ? AND dtend >= ?) AND events.calendar in ({0}) '
+            'ORDER BY dtstart')
+        stuple = tuple(
+            [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars))  # type: ignore
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
+        for calendar in result:
+            yield calendar[0]  # result is always an iterable, even if getting only one item
+
+    def get_localized(self, start, end) \
+            -> Iterable[Tuple[str, str, dt.datetime, dt.datetime, str, str, str]]:
         """returns
         :type start: datetime.datetime
         :type end: datetime.datetime
@@ -450,118 +493,82 @@
         assert end.tzinfo is not None
         start = utils.to_unix_time(start)
         end = utils.to_unix_time(end)
-        if minimal:
-            sql_s = (
-                'SELECT events.calendar FROM '
-                'recs_loc JOIN events ON '
-                'recs_loc.href = events.href AND '
-                'recs_loc.calendar = events.calendar WHERE '
-                '(dtstart >= ? AND dtstart <= ? OR '
-                'dtend > ? AND dtend <= ? OR '
-                'dtstart <= ? AND dtend >= ?) AND events.calendar in ({0}) '
-                'ORDER BY dtstart')
-        else:
-            sql_s = (
-                'SELECT item, recs_loc.href, dtstart, dtend, ref, etag, dtype, events.calendar '
-                'FROM recs_loc JOIN events ON '
-                'recs_loc.href = events.href AND '
-                'recs_loc.calendar = events.calendar WHERE '
-                '(dtstart >= ? AND dtstart <= ? OR '
-                'dtend > ? AND dtend <= ? OR '
-                'dtstart <= ? AND dtend >= ?) AND events.calendar in ({0}) '
-                'ORDER BY dtstart')
-        stuple = (start, end, start, end, start, end)
-        result = self.sql_ex(sql_s.format(self._select_calendars), stuple)
-        if minimal:
-            for calendar in result:
-                yield EventStandIn(calendar[0])
-        else:
-            for item, href, start, end, ref, etag, dtype, calendar in result:
-                start = pytz.UTC.localize(datetime.utcfromtimestamp(start))
-                end = pytz.UTC.localize(datetime.utcfromtimestamp(end))
-                yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype)
+        sql_s = (
+            'SELECT item, recs_loc.href, dtstart, dtend, ref, etag, dtype, events.calendar '
+            'FROM recs_loc JOIN events ON '
+            'recs_loc.href = events.href AND '
+            'recs_loc.calendar = events.calendar WHERE '
+            '(dtstart >= ? AND dtstart <= ? OR '
+            'dtend > ? AND dtend <= ? OR '
+            'dtstart <= ? AND dtend >= ?) AND events.calendar in ({0}) '
+            'ORDER BY dtstart')
+        stuple = tuple([start, end, start, end, start, end] + list(self.calendars))
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
+        for item, href, start, end, ref, etag, dtype, calendar in result:
+            start = pytz.UTC.localize(dt.datetime.utcfromtimestamp(start))
+            end = pytz.UTC.localize(dt.datetime.utcfromtimestamp(end))
+            yield item, href, start, end, ref, etag, calendar
 
-    def get_floating(self, start, end, minimal=False):
+    def get_floating_calendars(self, start: dt.datetime, end: dt.datetime) -> Iterable[str]:
+        assert start.tzinfo is None
+        assert end.tzinfo is None
+        start_u = utils.to_unix_time(start)
+        end_u = utils.to_unix_time(end)
+        sql_s = (
+            'SELECT events.calendar FROM '
+            'recs_float JOIN events ON '
+            'recs_float.href = events.href AND '
+            'recs_float.calendar = events.calendar WHERE '
+            '(dtstart >= ? AND dtstart < ? OR '
+            'dtend > ? AND dtend <= ? OR '
+            'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) '
+            'ORDER BY dtstart')
+        stuple = tuple(
+            [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars))  # type: ignore
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
+        for calendar in result:
+            yield calendar[0]
+
+    def get_floating(self, start, end) \
+            -> Iterable[Tuple[str, str, dt.datetime, dt.datetime, str, str, str]]:
         """return floating events between `start` and `end`
 
         :type start: datetime.datetime
         :type end: datetime.datetime
-        :param minimal: if set, we do not return an event but a minimal stand in
-        :type minimal: bool
         """
         assert start.tzinfo is None
         assert end.tzinfo is None
-        strstart = utils.to_unix_time(start)
-        strend = utils.to_unix_time(end)
-        if minimal:
-            sql_s = (
-                'SELECT events.calendar FROM '
-                'recs_float JOIN events ON '
-                'recs_float.href = events.href AND '
-                'recs_float.calendar = events.calendar WHERE '
-                '(dtstart >= ? AND dtstart < ? OR '
-                'dtend > ? AND dtend <= ? OR '
-                'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) '
-                'ORDER BY dtstart')
-        else:
-            sql_s = (
-                'SELECT item, recs_float.href, dtstart, dtend, ref, etag, dtype, events.calendar '
-                'FROM recs_float JOIN events ON '
-                'recs_float.href = events.href AND '
-                'recs_float.calendar = events.calendar WHERE '
-                '(dtstart >= ? AND dtstart < ? OR '
-                'dtend > ? AND dtend <= ? OR '
-                'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) '
-                'ORDER BY dtstart')
-        stuple = (strstart, strend, strstart, strend, strstart, strend)
-        result = self.sql_ex(sql_s.format(self._select_calendars), stuple)
-        if minimal:
-            for calendar in result:
-                yield EventStandIn(calendar[0])
-        else:
-            for item, href, start, end, ref, etag, dtype, calendar in result:
-                start = datetime.utcfromtimestamp(start)
-                end = datetime.utcfromtimestamp(end)
-                yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype)
-
-    def get(self, href, start=None, end=None, ref=None, dtype=None, calendar=None):
-        """returns the Event matching href
+        start_u = utils.to_unix_time(start)
+        end_u = utils.to_unix_time(end)
+        sql_s = (
+            'SELECT item, recs_float.href, dtstart, dtend, ref, etag, dtype, events.calendar '
+            'FROM recs_float JOIN events ON '
+            'recs_float.href = events.href AND '
+            'recs_float.calendar = events.calendar WHERE '
+            '(dtstart >= ? AND dtstart < ? OR '
+            'dtend > ? AND dtend <= ? OR '
+            'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) '
+            'ORDER BY dtstart')
+        stuple = tuple(
+            [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars))  # type: ignore
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
+        for item, href, start, end, ref, etag, dtype, calendar in result:
+            start = dt.datetime.utcfromtimestamp(start)
+            end = dt.datetime.utcfromtimestamp(end)
+            if dtype == EventType.DATE:
+                start = start.date()
+                end = end.date()
+            yield item, href, start, end, ref, etag, calendar
 
-        if start and end are given, a specific Event from a Recursion set is
-        returned, otherwise the Event returned exactly as saved in the db
-        """
+    def get(self, href: str, calendar: str) -> str:
+        """returns the ical string matching href and calendar"""
         assert calendar is not None
-        sql_s = 'SELECT href, etag, item FROM events WHERE href = ? AND calendar = ?;'
-        result = self.sql_ex(sql_s, (href, calendar))
-        href, etag, item = result[0]
-        if dtype == DATE:
-            start = start.date()
-            end = end.date()
-        return Event.fromString(item,
-                                locale=self.locale,
-                                href=href,
-                                calendar=calendar,
-                                etag=etag,
-                                start=start,
-                                end=end,
-                                ref=ref,
-                                )
-
-    def construct_event(self, item, href, start, end, ref, etag, calendar, dtype=None):
-        if dtype == DATE:
-            start = start.date()
-            end = end.date()
-        return Event.fromString(item,
-                                locale=self.locale,
-                                href=href,
-                                calendar=calendar,
-                                etag=etag,
-                                start=start,
-                                end=end,
-                                ref=ref,
-                                )
+        sql_s = 'SELECT item, etag FROM events WHERE href = ? AND calendar = ?;'
+        item, etag = self.sql_ex(sql_s, (href, calendar))[0]
+        return item
 
-    def search(self, search_string):
+    def search(self, search_string: str) \
+            -> Iterable[Tuple[str, str, dt.datetime, dt.datetime, str, str, str]]:
         """search for events matching `search_string`"""
         sql_s = (
             'SELECT item, recs_loc.href, dtstart, dtend, ref, etag, dtype, events.calendar '
@@ -570,12 +577,15 @@
             'recs_loc.calendar = events.calendar '
             'WHERE item LIKE (?) and events.calendar in ({0});'
         )
-        stuple = ('%{0}%'.format(search_string), )
-        result = self.sql_ex(sql_s.format(self._select_calendars), stuple)
+        stuple = tuple(['%{0}%'.format(search_string)] + list(self.calendars))
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
         for item, href, start, end, ref, etag, dtype, calendar in result:
-            start = pytz.UTC.localize(datetime.utcfromtimestamp(start))
-            end = pytz.UTC.localize(datetime.utcfromtimestamp(end))
-            yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype)
+            start = pytz.UTC.localize(dt.datetime.utcfromtimestamp(start))
+            end = pytz.UTC.localize(dt.datetime.utcfromtimestamp(end))
+            if dtype == EventType.DATE:
+                start = start.date()
+                end = end.date()
+            yield item, href, start, end, ref, etag, calendar
 
         sql_s = (
             'SELECT item, recs_float.href, dtstart, dtend, ref, etag, dtype, events.calendar '
@@ -584,21 +594,22 @@
             'recs_float.calendar = events.calendar '
             'WHERE item LIKE (?) and events.calendar in ({0});'
         )
-        stuple = ('%{0}%'.format(search_string), )
-        result = self.sql_ex(sql_s.format(self._select_calendars), stuple)
+        stuple = tuple(['%{0}%'.format(search_string)] + list(self.calendars))
+        result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple)
         for item, href, start, end, ref, etag, dtype, calendar in result:
-            start = datetime.utcfromtimestamp(start)
-            end = datetime.utcfromtimestamp(end)
-            yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype)
+            start = dt.datetime.utcfromtimestamp(start)
+            end = dt.datetime.utcfromtimestamp(end)
+            if dtype == EventType.DATE:
+                start = start.date()
+                end = end.date()
+            yield item, href, start, end, ref, etag, calendar
 
 
-def check_support(vevent, href, calendar):
+def check_support(vevent: icalendar.cal.Event, href: str, calendar: str):
     """test if all icalendar features used in this event are supported,
     raise `UpdateFailed` otherwise.
     :param vevent: event to test
-    :type vevent: icalendar.cal.Event
     :param href: href of this event, only used for logging
-    :type href: str
     """
     rec_id = vevent.get(RECURRENCE_ID)
 
@@ -621,7 +632,7 @@
         )
 
 
-def check_for_errors(component, calendar, href):
+def check_for_errors(component: icalendar.cal.Component, calendar: str, href: str):
     """checking if component.errors exists, is not empty and if so warn the user"""
     if hasattr(component, 'errors') and component.errors:
         logger.error(
@@ -632,14 +643,11 @@
         logger.error('This might lead to this event being shown wrongly or not at all.')
 
 
-def calc_shift_deltas(vevent):
+def calc_shift_deltas(vevent: icalendar.Event) -> Tuple[dt.timedelta, dt.timedelta]:
     """calculate an event's duration and by how much its start time has shifted
     versus its recurrence-id time
 
     :param event: an event with an RECURRENCE-ID property
-    :type event: icalendar.Event
-    :returns: time shift and duration
-    :rtype: (datetime.timedelta, datetime.timedelta)
     """
     assert isinstance(vevent, icalendar.Event)  # REMOVE ME
     start_shift = vevent['DTSTART'].dt - vevent['RECURRENCE-ID'].dt
@@ -648,3 +656,22 @@
     except KeyError:
         duration = vevent['DURATION'].dt
     return start_shift, duration
+
+
+def get_vcard_event_description(vcard: icalendar.cal.Component, key: str) -> str:
+    if key == 'BDAY':
+        return 'birthday'
+    elif key.endswith('ANNIVERSARY'):
+        return 'anniversary'
+    elif key.endswith('X-ABDATE'):
+        desc_key = key[:-8] + 'X-ABLABEL'
+        if desc_key in vcard.keys():
+            return vcard[desc_key]
+        else:
+            desc_key = key[:-8] + 'X-ABLabel'
+            if desc_key in vcard.keys():
+                return vcard[desc_key]
+            else:
+                return 'custom event from vcard'
+    else:
+        return 'unknown event from vcard'
diff -Nru khal-0.9.10/khal/khalendar/event.py khal-0.10.2/khal/khalendar/event.py
--- khal-0.9.10/khal/khalendar/event.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/event.py	2020-07-29 18:17:53.000000000 +0200
@@ -22,20 +22,21 @@
 """This module contains the event model with all relevant subclasses and some
 helper functions."""
 
-from collections import defaultdict
-from datetime import date, datetime, time, timedelta
-
+import datetime as dt
+import logging
 import os
+
 import icalendar
 import pytz
+from click import style
 
-from ..utils import generate_random_uid
-from .utils import to_naive_utc, to_unix_time, invalid_timezone, delete_instance, \
-    is_aware
 from ..exceptions import FatalError
-from ..log import logger
+from ..icalendar import cal_from_ics, delete_instance, invalid_timezone
 from ..terminal import get_color
-from click import style
+from ..utils import generate_random_uid, is_aware, to_naive_utc, to_unix_time
+from ..parse_datetime import timedelta2str
+
+logger = logging.getLogger('khal')
 
 
 class Event(object):
@@ -67,6 +68,7 @@
         self.href = kwargs.pop('href', None)
         self.etag = kwargs.pop('etag', None)
         self.calendar = kwargs.pop('calendar', None)
+        self.color = kwargs.pop('color', None)
         self.ref = ref
 
         start = kwargs.pop('start', None)
@@ -83,7 +85,7 @@
                 try:
                     self._end = self._start + self._vevents[self.ref]['DURATION'].dt
                 except KeyError:
-                    self._end = self._start + timedelta(days=1)
+                    self._end = self._start + dt.timedelta(days=1)
 
         else:
             self._end = end
@@ -96,7 +98,7 @@
         :type start: icalendar.prop.vDDDTypes
         :type start: icalendar.prop.vDDDTypes
         """
-        if not isinstance(start.dt, datetime):
+        if not isinstance(start.dt, dt.datetime):
             return AllDayEvent
         if 'TZID' in start.params or start.dt.tzinfo is not None:
             return LocalizedEvent
@@ -106,9 +108,9 @@
     def _get_type_from_date(cls, start):
         if hasattr(start, 'tzinfo') and start.tzinfo is not None:
             cls = LocalizedEvent
-        elif isinstance(start, datetime):
+        elif isinstance(start, dt.datetime):
             cls = FloatingEvent
-        elif isinstance(start, date):
+        elif isinstance(start, dt.date):
             cls = AllDayEvent
         return cls
 
@@ -135,7 +137,7 @@
         if ref is None:
             ref = 'PROTO' if ref in vevents.keys() else list(vevents.keys())[0]
         try:
-            if type(vevents[ref]['DTSTART'].dt) != type(vevents[ref]['DTEND'].dt):  # flake8: noqa
+            if type(vevents[ref]['DTSTART'].dt) != type(vevents[ref]['DTEND'].dt):  # noqa: E721
                 raise ValueError('DTSTART and DTEND should be of the same type (datetime or date)')
         except KeyError:
             pass
@@ -148,18 +150,18 @@
 
     @classmethod
     def fromString(cls, event_str, ref=None, **kwargs):
-        calendar_collection = icalendar.Calendar.from_ical(event_str)
+        calendar_collection = cal_from_ics(event_str)
         events = [item for item in calendar_collection.walk() if item.name == 'VEVENT']
         return cls.fromVEvents(events, ref, **kwargs)
 
     def __lt__(self, other):
         start = self.start_local
         other_start = other.start_local
-        if isinstance(start, date) and not isinstance(start, datetime):
-            start = datetime.combine(start, time.min)
+        if isinstance(start, dt.date) and not isinstance(start, dt.datetime):
+            start = dt.datetime.combine(start, dt.time.min)
 
-        if isinstance(other_start, date) and not isinstance(other_start, datetime):
-            other_start = datetime.combine(other_start, time.max)
+        if isinstance(other_start, dt.date) and not isinstance(other_start, dt.datetime):
+            other_start = dt.datetime.combine(other_start, dt.time.max)
 
         start = start.replace(tzinfo=None)
         other_start = other_start.replace(tzinfo=None)
@@ -183,8 +185,8 @@
         self._vevents[self.ref].pop('DTSTART')
         self._vevents[self.ref].add('DTSTART', start)
         self._start = start
-        if not isinstance(end, datetime):
-            end = end + timedelta(days=1)
+        if not isinstance(end, dt.datetime):
+            end = end + dt.timedelta(days=1)
         self._end = end
         if 'DTEND' in self._vevents[self.ref]:
             self._vevents[self.ref].pop('DTEND')
@@ -225,7 +227,7 @@
         if self.ref == 'PROTO':
             return self.start
         else:
-            return pytz.UTC.localize(datetime.utcfromtimestamp(int(self.ref)))
+            return pytz.UTC.localize(dt.datetime.utcfromtimestamp(int(self.ref)))
 
     def increment_sequence(self):
         """update the SEQUENCE number, call before saving this event"""
@@ -349,14 +351,37 @@
 
     @property
     def summary(self):
-        bday = self._vevents[self.ref].get('x-birthday', None)
-        if bday:
-            number = self.start_local.year - int(bday[:4])
+        description = None
+        date = self._vevents[self.ref].get('x-birthday', None)
+        if date:
+            description = 'birthday'
+        else:
+            date = self._vevents[self.ref].get('x-anniversary', None)
+            if date:
+                description = 'anniversary'
+            else:
+                date = self._vevents[self.ref].get('x-abdate', None)
+                if date:
+                    description = self._vevents[self.ref].get('x-ablabel', 'custom event')
+
+        if date:
+            number = self.start_local.year - int(date[:4])
             name = self._vevents[self.ref].get('x-fname', None)
-            if int(bday[4:6]) == 2 and int(bday[6:8]) == 29:
-                return '{name}\'s {number}th birthday (29th of Feb.)'.format(name=name, number=number)
+            if int(date[4:6]) == 2 and int(date[6:8]) == 29:
+                leap = ' (29th of Feb.)'
+            else:
+                leap = ''
+            if (number - 1) % 10 == 0 and number != 11:
+                suffix = 'st'
+            elif (number - 2) % 10 == 0 and number != 12:
+                suffix = 'nd'
+            elif (number - 3) % 10 == 0 and number != 13:
+                suffix = 'rd'
             else:
-                return '{name}\'s {number}th birthday'.format(name=name, number=number)
+                suffix = 'th'
+            return '{name}\'s {number}{suffix} {desc}{leap}'.format(
+                name=name, number=number, suffix=suffix, desc=description, leap=leap,
+            )
         else:
             return self._vevents[self.ref].get('SUMMARY', '')
 
@@ -368,7 +393,8 @@
         """
         Decides whether we can handle a certain alarm.
         """
-        return alarm.get('ACTION') == 'DISPLAY' and isinstance(alarm.get('TRIGGER').dt, timedelta)
+        return alarm.get('ACTION') == 'DISPLAY' and \
+            isinstance(alarm.get('TRIGGER').dt, dt.timedelta)
 
     @property
     def alarms(self):
@@ -409,13 +435,16 @@
 
     @property
     def categories(self):
-        return self._vevents[self.ref].get('CATEGORIES', '')
+        try:
+            return self._vevents[self.ref].get('CATEGORIES', '').to_ical().decode('utf-8')
+        except AttributeError:
+            return ''
 
     def update_categories(self, categories):
-        if categories.strip():
-            self._vevents[self.ref]['CATEGORIES'] = categories
-        else:
-            self._vevents[self.ref].pop('CATEGORIES', False)
+        assert isinstance(categories, list)
+        self._vevents[self.ref].pop('CATEGORIES', False)
+        if categories:
+            self._vevents[self.ref].add('CATEGORIES', categories)
 
     @property
     def description(self):
@@ -446,23 +475,27 @@
         except TypeError:
             relative_to_start = relative_to_end = relative_to
 
-        if isinstance(relative_to_end, datetime):
+        if isinstance(relative_to_end, dt.datetime):
             relative_to_end = relative_to_end.date()
-        if isinstance(relative_to_start, datetime):
+        if isinstance(relative_to_start, dt.datetime):
             relative_to_start = relative_to_start.date()
 
-        if isinstance(self.start_local, datetime):
+        if isinstance(self.start_local, dt.datetime):
             start_local_datetime = self.start_local
             end_local_datetime = self.end_local
         else:
             start_local_datetime = self._locale['local_timezone'].localize(
-                datetime.combine(self.start, time.min))
+                dt.datetime.combine(self.start, dt.time.min))
             end_local_datetime = self._locale['local_timezone'].localize(
-                datetime.combine(self.end, time.min))
+                dt.datetime.combine(self.end, dt.time.min))
 
-        day_start = self._locale['local_timezone'].localize(datetime.combine(relative_to_start, time.min))
-        day_end = self._locale['local_timezone'].localize(datetime.combine(relative_to_end, time.max))
-        next_day_start = day_start + timedelta(days=1)
+        day_start = self._locale['local_timezone'].localize(
+            dt.datetime.combine(relative_to_start, dt.time.min),
+        )
+        day_end = self._locale['local_timezone'].localize(
+            dt.datetime.combine(relative_to_end, dt.time.max),
+        )
+        next_day_start = day_start + dt.timedelta(days=1)
 
         allday = isinstance(self, AllDayEvent)
 
@@ -478,6 +511,8 @@
         attributes["end-date-long"] = self.end_local.strftime(self._locale['longdateformat'])
         attributes["end-time"] = self.end_local.strftime(self._locale['timeformat'])
 
+        attributes["duration"] = timedelta2str(self.duration)
+
         # should only have time attributes at this point (start/end)
         full = {}
         for attr in attributes:
@@ -553,6 +588,7 @@
         attributes["repeat-symbol"] = self._recur_str
         attributes["repeat-pattern"] = self.recurpattern
         attributes["title"] = self.summary
+        attributes["organizer"] = self.organizer.strip()
         attributes["description"] = self.description.strip()
         attributes["description-separator"] = ""
         if attributes["description"]:
@@ -560,6 +596,7 @@
         attributes["location"] = self.location.strip()
         attributes["all-day"] = allday
         attributes["categories"] = self.categories
+        attributes['uid'] = self.uid
 
         if "calendars" in env and self.calendar in env["calendars"]:
             cal = env["calendars"][self.calendar]
@@ -579,7 +616,11 @@
             for c in ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]:
                 attributes[c] = attributes[c + '-bold'] = ''
 
-        attributes['status'] = self.status
+        attributes['nl'] = '\n'
+        attributes['tab'] = '\t'
+        attributes['bell'] = '\a'
+
+        attributes['status'] = self.status + ' ' if self.status else ''
         attributes['cancelled'] = 'CANCELLED ' if self.status == 'CANCELLED' else ''
         return format_string.format(**dict(attributes)) + attributes["reset"]
 
@@ -705,10 +746,17 @@
             logger.warning('{} ("{}"): The event\'s end date property '
                            'contains the same value as the start date, '
                            'which is invalid as per RFC 5545. Khal will '
-                           'assume this is meant to be single-day event '
+                           'assume this is meant to be a single-day event '
                            'on {}'.format(self.href, self.summary, self.start))
-            end += timedelta(days=1)
-        return end - timedelta(days=1)
+            end += dt.timedelta(days=1)
+        return end - dt.timedelta(days=1)
+
+    @property
+    def duration(self):
+        try:
+            return self._vevents[self.ref]['DURATION'].dt
+        except KeyError:
+            return self.end - self.start + dt.timedelta(days=1)
 
 
 def create_timezone(tz, first_date=None, last_date=None):
@@ -745,8 +793,8 @@
 
     # TODO last_date = None, recurring to infinity
 
-    first_date = datetime.today() if not first_date else to_naive_utc(first_date)
-    last_date = datetime.today() if not last_date else to_naive_utc(last_date)
+    first_date = dt.datetime.today() if not first_date else to_naive_utc(first_date)
+    last_date = dt.datetime.today() if not last_date else to_naive_utc(last_date)
     timezone = icalendar.Timezone()
     timezone.add('TZID', tz)
 
@@ -763,13 +811,13 @@
     first_num, last_num = 0, len(tz._utc_transition_times) - 1
     first_tt = tz._utc_transition_times[0]
     last_tt = tz._utc_transition_times[-1]
-    for num, dt in enumerate(tz._utc_transition_times):
-        if dt > first_tt and dt < first_date:
+    for num, transtime in enumerate(tz._utc_transition_times):
+        if transtime > first_tt and transtime < first_date:
             first_num = num
-            first_tt = dt
-        if dt < last_tt and dt > last_date:
+            first_tt = transtime
+        if transtime < last_tt and transtime > last_date:
             last_num = num
-            last_tt = dt
+            last_tt = transtime
 
     timezones = dict()
     for num in range(first_num, last_num + 1):
@@ -814,17 +862,9 @@
     timezone.add('TZID', tz)
     subcomp = icalendar.TimezoneStandard()
     subcomp.add('TZNAME', tz)
-    subcomp.add('DTSTART', datetime(1601, 1, 1))
-    subcomp.add('RDATE', datetime(1601, 1, 1))
+    subcomp.add('DTSTART', dt.datetime(1601, 1, 1))
+    subcomp.add('RDATE', dt.datetime(1601, 1, 1))
     subcomp.add('TZOFFSETTO', tz._utcoffset)
     subcomp.add('TZOFFSETFROM', tz._utcoffset)
     timezone.add_component(subcomp)
     return timezone
-
-
-class EventStandIn():
-    def __init__(self, calendar):
-        self.calendar = calendar
-        self.color = None
-        self.unicode_symbols = None
-        self.readonly = None
diff -Nru khal-0.9.10/khal/khalendar/exceptions.py khal-0.10.2/khal/khalendar/exceptions.py
--- khal-0.9.10/khal/khalendar/exceptions.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/exceptions.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,7 +19,9 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-from ..exceptions import UnsupportedFeatureError, Error, FatalError
+from typing import Optional  # noqa
+
+from ..exceptions import Error, FatalError, UnsupportedFeatureError
 
 
 class UnsupportedRruleExceptionError(UnsupportedFeatureError):
@@ -54,13 +56,12 @@
     """could not update the event in the database"""
 
 
-class UnsupportedRecurrence(Error):
+class DuplicateUid(Error):
 
-    """raised if the RRULE is not understood by dateutil.rrule"""
-    pass
+    """an event with this UID already exists"""
+    existing_href = None  # type: Optional[str]
 
 
-class DuplicateUid(Error):
+class NonUniqueUID(Error):
 
-    """an event with this UID already exists"""
-    existing_href = None
+    """the .ics file contains more than one UID"""
diff -Nru khal-0.9.10/khal/khalendar/__init__.py khal-0.10.2/khal/khalendar/__init__.py
--- khal-0.9.10/khal/khalendar/__init__.py	2018-02-20 19:53:01.000000000 +0100
+++ khal-0.10.2/khal/khalendar/__init__.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,4 +19,4 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-from .khalendar import CalendarCollection  # flake8: noqa
+from .khalendar import CalendarCollection  # noqa: F401
diff -Nru khal-0.9.10/khal/khalendar/khalendar.py khal-0.10.2/khal/khalendar/khalendar.py
--- khal-0.9.10/khal/khalendar/khalendar.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/khalendar.py	2020-07-29 18:17:53.000000000 +0200
@@ -25,31 +25,32 @@
 calendars. Each calendar is defined by the contents of a vdir, but uses an
 SQLite db for caching (see backend if you're interested).
 """
-import datetime
+import datetime as dt
+import itertools
+import logging
 import os
 import os.path
-import itertools
-
-from .vdir import CollectionNotFoundError, AlreadyExistingError, Vdir, \
-    get_etag_from_file
+from typing import Any, Dict, Iterable, List, Optional, Tuple, Union  # noqa
 
 from . import backend
 from .event import Event
-from .. import log
-from .exceptions import CouldNotCreateDbDir, UnsupportedFeatureError, \
-    ReadOnlyCalendarError, UpdateFailed, DuplicateUid
+from .exceptions import (CouldNotCreateDbDir, DuplicateUid, NonUniqueUID,
+                         ReadOnlyCalendarError, UnsupportedFeatureError,
+                         UpdateFailed)
+from .vdir import (AlreadyExistingError, CollectionNotFoundError, Vdir,
+                   get_etag_from_file)
 
-logger = log.logger
+logger = logging.getLogger('khal')
 
 
-def create_directory(path):
+def create_directory(path: str):
     if not os.path.isdir(path):
         if os.path.exists(path):
             raise RuntimeError('{0} is not a directory.'.format(path))
         try:
             os.makedirs(path, mode=0o750)
         except OSError as error:
-            logger.fatal('failed to create {0}: {1}'.format(path, error))
+            logger.critical('failed to create {0}: {1}'.format(path, error))
             raise CouldNotCreateDbDir()
 
 
@@ -60,19 +61,20 @@
 
     def __init__(self,
                  calendars=None,
-                 hmethod='fg',
-                 default_color='',
-                 multiple='',
-                 color='',
-                 highlight_event_days=0,
-                 locale=None,
-                 dbpath=None,
-                 ):
+                 hmethod: str='fg',
+                 default_color: str='',
+                 multiple: str='',
+                 color: str='',
+                 priority: int=10,
+                 highlight_event_days: bool=False,
+                 locale: Dict[str, Any]=dict(),
+                 dbpath: Optional[str]=None,
+                 ) -> None:
         assert dbpath is not None
         assert calendars is not None
         self._calendars = calendars
-        self._default_calendar_name = None
-        self._storages = dict()
+        self._default_calendar_name = None  # type: Optional[str]
+        self._storages = dict()  # type: Dict[str, Vdir]
         for name, calendar in self._calendars.items():
             ctype = calendar.get('ctype', 'calendar')
             if ctype == 'calendar':
@@ -92,31 +94,31 @@
         self.default_color = default_color
         self.multiple = multiple
         self.color = color
+        self.priority = priority
         self.highlight_event_days = highlight_event_days
         self._locale = locale
-        self._backend = backend.SQLiteDb(
-            calendars=self.names, db_path=dbpath, locale=self._locale)
-        self._last_ctags = dict()
+        self._backend = backend.SQLiteDb(self.names, dbpath, self._locale)
+        self._last_ctags = dict()  # type: Dict[str, str]
         self.update_db()
 
     @property
-    def writable_names(self):
+    def writable_names(self) -> List[str]:
         return [c for c in self._calendars if not self._calendars[c].get('readonly', False)]
 
     @property
-    def calendars(self):
+    def calendars(self) -> Iterable[str]:
         return self._calendars.values()
 
     @property
-    def names(self):
+    def names(self) -> Iterable[str]:
         return self._calendars.keys()
 
     @property
-    def default_calendar_name(self):
+    def default_calendar_name(self) -> Optional[str]:
         return self._default_calendar_name
 
     @default_calendar_name.setter
-    def default_calendar_name(self, default):
+    def default_calendar_name(self, default: str):
         if default is None:
             self._default_calendar_name = default
         elif default not in self.names:
@@ -130,38 +132,37 @@
             raise ValueError(
                 'Calendar "{0}" is read-only and cannot be used as default'.format(default))
 
-    def _local_ctag(self, calendar):
+    def _local_ctag(self, calendar: str) -> str:
         return get_etag_from_file(self._calendars[calendar]['path'])
 
-    def _cover_event(self, event):
-        event.color = self._calendars[event.calendar]['color']
-        event.readonly = self._calendars[event.calendar]['readonly']
-        event.unicode_symbols = self._locale['unicode_symbols']
-        return event
-
-    def get_floating(self, start, end, minimal=False):
-        events = self._backend.get_floating(start, end, minimal)
-        return (self._cover_event(event) for event in events)
-
-    def get_localized(self, start, end, minimal=False):
-        events = self._backend.get_localized(start, end, minimal)
-        return (self._cover_event(event) for event in events)
-
-    def get_events_on(self, day, minimal=False):
-        """return all events on `day`
-
-        :param day: datetime.date
-        :rtype: list()
-        """
-        start = datetime.datetime.combine(day, datetime.time.min)
-        end = datetime.datetime.combine(day, datetime.time.max)
-        floating_events = self.get_floating(start, end, minimal)
+    def get_floating(self, start: dt.datetime, end: dt.datetime) -> Iterable[Event]:
+        for args in self._backend.get_floating(start, end):
+            yield self._construct_event(*args)
+
+    def get_localized(self, start: dt.datetime, end: dt.datetime) -> Iterable[Event]:
+        for args in self._backend.get_localized(start, end):
+            yield self._construct_event(*args)
+
+    def get_events_on(self, day: dt.date) -> Iterable[Event]:
+        """return all events on `day`"""
+        start = dt.datetime.combine(day, dt.time.min)
+        end = dt.datetime.combine(day, dt.time.max)
+        floating_events = self.get_floating(start, end)
         localize = self._locale['local_timezone'].localize
-        localized_events = self.get_localized(localize(start), localize(end), minimal)
-
+        localized_events = self.get_localized(localize(start), localize(end))
         return itertools.chain(floating_events, localized_events)
 
-    def update(self, event):
+    def get_calendars_on(self, day: dt.date) -> List[str]:
+        start = dt.datetime.combine(day, dt.time.min)
+        end = dt.datetime.combine(day, dt.time.max)
+        localize = self._locale['local_timezone'].localize
+        calendars = itertools.chain(
+            self._backend.get_floating_calendars(start, end),
+            self._backend.get_localized_calendars(localize(start), localize(end)),
+        )
+        return list(set(calendars))
+
+    def update(self, event: Event):
         """update `event` in vdir and db"""
         assert event.etag
         if self._calendars[event.calendar]['readonly']:
@@ -171,7 +172,7 @@
             self._backend.update(event.raw, event.href, event.etag, calendar=event.calendar)
             self._backend.set_ctag(self._local_ctag(event.calendar), calendar=event.calendar)
 
-    def force_update(self, event, collection=None):
+    def force_update(self, event: Event, collection: Optional[str]=None):
         """update `event` even if an event with the same uid/href already exists"""
         calendar = collection if collection is not None else event.calendar
         if self._calendars[calendar]['readonly']:
@@ -187,7 +188,7 @@
             self._backend.update(event.raw, href, etag, calendar=calendar)
             self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar)
 
-    def new(self, event, collection=None):
+    def new(self, event: Event, collection: Optional[str]=None):
         """save a new event to the vdir and the database
 
         param event: the event that should be updated, will get a new href and
@@ -210,22 +211,48 @@
             self._backend.update(event.raw, event.href, event.etag, calendar=calendar)
             self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar)
 
-    def delete(self, href, etag, calendar):
+    def delete(self, href: str, etag: str, calendar: str):
         if self._calendars[calendar]['readonly']:
             raise ReadOnlyCalendarError()
         self._storages[calendar].delete(href, etag)
         self._backend.delete(href, calendar=calendar)
 
-    def get_event(self, href, calendar):
-        return self._cover_event(self._backend.get(href, calendar=calendar))
+    def get_event(self, href: str, calendar: str) -> Event:
+        """get an event by its href from the datatbase"""
+        return self._construct_event(
+            self._backend.get(href, calendar), href=href, calendar=calendar,
+        )
+
+    def _construct_event(self,
+                         item: str,
+                         href: str,
+                         start: dt.datetime = None,
+                         end: dt.datetime = None,
+                         ref: str='PROTO',
+                         etag: str=None,
+                         calendar: str=None,
+                         ) -> Event:
+        event = Event.fromString(
+            item,
+            locale=self._locale,
+            href=href,
+            calendar=calendar,
+            etag=etag,
+            start=start,
+            end=end,
+            ref=ref,
+            color=self._calendars[calendar]['color'],
+            readonly=self._calendars[calendar]['readonly'],
+        )
+        return event
 
-    def change_collection(self, event, new_collection):
+    def change_collection(self, event: Event, new_collection: str):
         href, etag, calendar = event.href, event.etag, event.calendar
         event.etag = None
         self.new(event, new_collection)
         self.delete(href, etag, calendar=calendar)
 
-    def new_event(self, ical, collection):
+    def new_event(self, ical: str, collection: str):
         """creates and returns (but does not insert) new event from ical
         string"""
         calendar = collection or self.writable_names[0]
@@ -240,7 +267,7 @@
             if self._needs_update(calendar, remember=True):
                 self._db_update(calendar)
 
-    def needs_update(self):
+    def needs_update(self) -> bool:
         """Check if you need to call update_db.
 
         This could either be the case because the vdirs were changed externally,
@@ -266,18 +293,19 @@
                 return True
         return False
 
-    def _needs_update(self, calendar, remember=False):
+    def _needs_update(self, calendar: str, remember: bool=False) -> bool:
         """checks if the db for the given calendar needs an update"""
         local_ctag = self._local_ctag(calendar)
         if remember:
             self._last_ctags[calendar] = local_ctag
         return local_ctag != self._backend.get_ctag(calendar)
 
-    def _db_update(self, calendar):
+    def _db_update(self, calendar: str):
         """implements the actual db update on a per calendar base"""
         local_ctag = self._local_ctag(calendar)
         db_hrefs = set(href for href, etag in self._backend.list(calendar))
         storage_hrefs = set()
+        bdays = self._calendars[calendar].get('ctype') == 'birthdays'
 
         with self._backend.at_once():
             for href, etag in self._storages[calendar].list():
@@ -287,48 +315,53 @@
                     logger.debug('Updating {0} because {1} != {2}'.format(href, etag, db_etag))
                     self._update_vevent(href, calendar=calendar)
             for href in db_hrefs - storage_hrefs:
-                self._backend.delete(href, calendar=calendar)
+                if bdays:
+                    for sh in storage_hrefs:
+                        if href.startswith(sh):
+                            break
+                    else:
+                        self._backend.delete(href, calendar=calendar)
+                else:
+                    self._backend.delete(href, calendar=calendar)
             self._backend.set_ctag(local_ctag, calendar=calendar)
             self._last_ctags[calendar] = local_ctag
 
-    def _update_vevent(self, href, calendar):
+    def _update_vevent(self, href: str, calendar: str) -> bool:
         """should only be called during db_update, only updates the db,
         does not check for readonly"""
         event, etag = self._storages[calendar].get(href)
         try:
             if self._calendars[calendar].get('ctype') == 'birthdays':
-                update = self._backend.update_birthday
+                update = self._backend.update_vcf_dates
             else:
                 update = self._backend.update
             update(event.raw, href=href, etag=etag, calendar=calendar)
-
             return True
         except Exception as e:
-            if not isinstance(e, (UpdateFailed, UnsupportedFeatureError)):
+            if not isinstance(e, (UpdateFailed, UnsupportedFeatureError, NonUniqueUID)):
                 logger.exception('Unknown exception happened.')
             logger.warning(
                 'Skipping {0}/{1}: {2}\n'
                 'This event will not be available in khal.'.format(calendar, href, str(e)))
             return False
 
-    def search(self, search_string):
+    def search(self, search_string: str) -> Iterable[Event]:
         """search for the db for events matching `search_string`"""
-        return (self._cover_event(event) for event in self._backend.search(search_string))
+        return (self._construct_event(*args) for args in self._backend.search(search_string))
 
-    def get_day_styles(self, day, focus):
-        devents = list(self.get_events_on(day, minimal=True))
-        if len(devents) == 0:
+    def get_day_styles(self, day: dt.date, focus: bool) -> Optional[Union[str, Tuple[str, str]]]:
+        calendars = self.get_calendars_on(day)
+        if len(calendars) == 0:
             return None
         if self.color != '':
             return 'highlight_days_color'
-        dcalendars = list(set(map(lambda event: event.calendar, devents)))
-        if len(dcalendars) == 1:
-            return 'calendar ' + dcalendars[0]
+        if len(calendars) == 1:
+            return 'calendar ' + calendars[0]
         if self.multiple != '':
             return 'highlight_days_multiple'
-        return ('calendar ' + dcalendars[0], 'calendar ' + dcalendars[1])
+        return ('calendar ' + calendars[0], 'calendar ' + calendars[1])
 
-    def get_styles(self, date, focus):
+    def get_styles(self, date: dt.date, focus: bool) -> Union[str, None, Tuple[str, str]]:
         if focus:
             if date == date.today():
                 return 'today focus'
diff -Nru khal-0.9.10/khal/khalendar/utils.py khal-0.10.2/khal/khalendar/utils.py
--- khal-0.9.10/khal/khalendar/utils.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/utils.py	1970-01-01 01:00:00.000000000 +0100
@@ -1,341 +0,0 @@
-# Copyright (c) 2013-2017 Christian Geier et al.
-#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-"""collection of utility functions"""
-from datetime import datetime, timedelta
-import calendar
-
-import dateutil.rrule
-import pytz
-
-from .. import log
-
-from .exceptions import UnsupportedRecurrence
-
-logger = log.logger
-
-
-def expand(vevent, href=''):
-    """
-    Constructs a list of start and end dates for all recurring instances of the
-    event defined in vevent.
-
-    It considers RRULE as well as RDATE and EXDATE properties. In case of
-    unsupported recursion rules an UnsupportedRecurrence exception is thrown.
-
-    If the vevent contains a RECURRENCE-ID property, no expansion is done,
-    the function still returns a tuple of start and end (date)times.
-
-    :param vevent: vevent to be expanded
-    :type vevent: icalendar.cal.Event
-    :param href: the href of the vevent, used for more informative logging and
-                 nothing else
-    :type href: str
-    :returns: list of start and end (date)times of the expanded event
-    :rtype: list(tuple(datetime, datetime))
-    """
-    # we do this now and than never care about the "real" end time again
-    if 'DURATION' in vevent:
-        duration = vevent['DURATION'].dt
-    else:
-        duration = vevent['DTEND'].dt - vevent['DTSTART'].dt
-
-    # if this vevent has a RECURRENCE_ID property, no expansion will be
-    # performed
-    expand = not bool(vevent.get('RECURRENCE-ID'))
-
-    events_tz = getattr(vevent['DTSTART'].dt, 'tzinfo', None)
-    allday = not isinstance(vevent['DTSTART'].dt, datetime)
-
-    def sanitize_datetime(date):
-        if allday and isinstance(date, datetime):
-            date = date.date()
-        if events_tz is not None:
-            date = events_tz.localize(date)
-        return date
-
-    rrule_param = vevent.get('RRULE')
-    if expand and rrule_param is not None:
-        vevent = sanitize_rrule(vevent)
-
-        # dst causes problem while expanding the rrule, therefore we transform
-        # everything to naive datetime objects and transform back after
-        # expanding
-        # See https://github.com/dateutil/dateutil/issues/102
-        dtstart = vevent['DTSTART'].dt
-        if events_tz:
-            dtstart = dtstart.replace(tzinfo=None)
-        if events_tz and 'Z' not in rrule_param.to_ical().decode():
-            logger.warning(
-                "In event {}, DTSTART has a timezone, but UNTIL does not. This "
-                "might lead to errenous repeating instances (like missing the "
-                "last intended instance or adding an extra one)."
-                "".format(href))
-        elif not events_tz and 'Z' in rrule_param.to_ical().decode():
-            logger.warning(
-                "In event {}, DTSTART has no timezone, but UNTIL has one. This "
-                "might lead to errenous repeating instances (like missing the "
-                "last intended instance or adding an extra one)."
-                "".format(href))
-
-        rrule = dateutil.rrule.rrulestr(
-            rrule_param.to_ical().decode(),
-            dtstart=dtstart,
-            ignoretz=True,
-        )
-
-        if rrule._until is None:
-            # rrule really doesn't like to calculate all recurrences until
-            # eternity, so we only do it until 2037, because a) I'm not sure
-            # if python can deal with larger datetime values yet and b) pytz
-            # doesn't know any larger transition times
-            rrule._until = datetime(2037, 12, 31)
-        elif events_tz and 'Z' in rrule_param.to_ical().decode():
-            rrule._until = pytz.UTC.localize(
-                rrule._until).astimezone(events_tz).replace(tzinfo=None)
-
-        rrule = map(sanitize_datetime, rrule)
-
-        logger.debug('calculating recurrence dates for {0}, '
-                     'this might take some time.'.format(href))
-
-        # RRULE and RDATE may specify the same date twice, it is recommended by
-        # the RFC to consider this as only one instance
-        dtstartl = set(rrule)
-        if not dtstartl:
-            raise UnsupportedRecurrence()
-    else:
-        dtstartl = {vevent['DTSTART'].dt}
-
-    def get_dates(vevent, key):
-        # TODO replace with get_all_properties
-        dates = vevent.get(key)
-        if dates is None:
-            return
-        if not isinstance(dates, list):
-            dates = [dates]
-
-        dates = (leaf.dt for tree in dates for leaf in tree.dts)
-        dates = localize_strip_tz(dates, events_tz)
-        return map(sanitize_datetime, dates)
-
-    # include explicitly specified recursion dates
-    if expand:
-        dtstartl.update(get_dates(vevent, 'RDATE') or ())
-
-    # remove excluded dates
-    if expand:
-        for date in get_dates(vevent, 'EXDATE') or ():
-            try:
-                dtstartl.remove(date)
-            except KeyError:
-                logger.warning(
-                    'In event {}, excluded instance starting at {} not found, '
-                    'event might be invalid.'.format(href, date))
-
-    dtstartend = [(start, start + duration) for start in dtstartl]
-    # not necessary, but I prefer deterministic output
-    dtstartend.sort()
-    return dtstartend
-
-
-def sanitize(vevent, default_timezone, href='', calendar=''):
-    """
-    clean up vevents we do not understand
-
-    :param vevent: the vevent that needs to be cleaned
-    :type vevent: icalendar.cal.Event
-    :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 vevent
-    :rtype: icalendar.cal.Event
-    """
-    # convert localized datetimes with timezone information we don't
-    # understand to the default timezone
-    # TODO do this for everything where a TZID can appear (RDATE, EXDATE)
-    for prop in ['DTSTART', 'DTEND', 'DUE', 'RECURRENCE-ID']:
-        if prop in vevent and invalid_timezone(vevent[prop]):
-            timezone = vevent[prop].params.get('TZID')
-            value = default_timezone.localize(vevent.pop(prop).dt)
-            vevent.add(prop, value)
-            logger.warning(
-                "{} localized in invalid or incomprehensible timezone `{}` in {}/{}. "
-                "This could lead to this event being wrongly displayed."
-                "".format(prop, timezone, calendar, href)
-            )
-
-    vdtstart = vevent.pop('DTSTART', None)
-    vdtend = vevent.pop('DTEND', None)
-    dtstart = getattr(vdtstart, 'dt', None)
-    dtend = getattr(vdtend, 'dt', None)
-
-    # event with missing DTSTART
-    if dtstart is None:
-        raise ValueError('Event has no start time (DTSTART).')
-    dtstart, dtend = sanitize_timerange(
-        dtstart, dtend, duration=vevent.get('DURATION', None))
-
-    vevent.add('DTSTART', dtstart)
-    if dtend is not None:
-        vevent.add('DTEND', dtend)
-    return vevent
-
-
-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.'''
-
-    if isinstance(dtstart, datetime) and isinstance(dtend, datetime):
-        if dtstart.tzinfo and not dtend.tzinfo:
-            logger.warning(
-                "Event end time has no timezone. "
-                "Assuming it's the same timezone as the start time"
-            )
-            dtend = dtstart.tzinfo.localize(dtend)
-        if not dtstart.tzinfo and dtend.tzinfo:
-            logger.warning(
-                "Event start time has no timezone. "
-                "Assuming it's the same timezone as the end time"
-            )
-            dtstart = dtend.tzinfo.localize(dtstart)
-
-    if dtend is None and duration is None:
-        if isinstance(dtstart, datetime):
-            dtstart = dtstart.date()
-        dtend = dtstart + timedelta(days=1)
-    elif dtend is not None:
-        if dtend < dtstart:
-            raise ValueError('The event\'s end time (DTEND) is older than '
-                             'the event\'s start time (DTSTART).')
-        elif dtend == dtstart:
-            logger.warning(
-                "Event start time and end time are the same. "
-                "Assuming the event's duration is one hour."
-            )
-            dtend += timedelta(hours=1)
-
-    return dtstart, dtend
-
-
-def sanitize_rrule(vevent):
-    """fix problems with RRULE:UNTIL"""
-    if 'rrule' in vevent and 'UNTIL' in vevent['rrule']:
-        until = vevent['rrule']['UNTIL'][0]
-        dtstart = vevent['dtstart'].dt
-        # DTSTART is date, UNTIL is datetime
-        if not isinstance(dtstart, datetime) and isinstance(until, datetime):
-            vevent['rrule']['until'] = until.date()
-    return vevent
-
-
-def localize_strip_tz(dates, timezone):
-    """converts a list of dates to timezone, than removes tz info"""
-    for one_date in dates:
-        if getattr(one_date, 'tzinfo', None) is not None:
-            one_date = one_date.astimezone(timezone)
-            one_date = one_date.replace(tzinfo=None)
-        yield one_date
-
-
-def to_unix_time(dtime):
-    """convert a datetime object to unix time in UTC (as a float)"""
-    if getattr(dtime, 'tzinfo', None) is not None:
-        dtime = dtime.astimezone(pytz.UTC)
-    unix_time = calendar.timegm(dtime.timetuple())
-    return unix_time
-
-
-def to_naive_utc(dtime):
-    """convert a datetime object to UTC and than remove the tzinfo, if
-    datetime is naive already, return it
-    """
-    if not hasattr(dtime, 'tzinfo') or dtime.tzinfo is None:
-        return dtime
-
-    dtime_utc = dtime.astimezone(pytz.UTC)
-    dtime_naive = dtime_utc.replace(tzinfo=None)
-    return dtime_naive
-
-
-def invalid_timezone(prop):
-    """check if an icalendar property has a timezone attached we don't understand"""
-    if hasattr(prop.dt, 'tzinfo') and prop.dt.tzinfo is None and 'TZID' in prop.params:
-        return True
-    else:
-        return False
-
-
-def _get_all_properties(vevent, prop):
-    """Get all properties from a vevent, even if there are several entries
-
-    example input:
-    EXDATE:1234,4567
-    EXDATE:7890
-
-    returns: [1234, 4567, 7890]
-
-    :type vevent: icalendar.cal.Event
-    :type prop: str
-    """
-    if prop not in vevent:
-        return list()
-    if isinstance(vevent[prop], list):
-        rdates = [leaf.dt for tree in vevent[prop] for leaf in tree.dts]
-    else:
-        rdates = [vddd.dt for vddd in vevent[prop].dts]
-    return rdates
-
-
-def delete_instance(vevent, instance):
-    """remove a recurrence instance from a VEVENT's RRDATE list or add it
-    to the EXDATE list
-
-    :type vevent: icalendar.cal.Event
-    :type instance: datetime.datetime
-    """
-    # TODO check where this instance is coming from and only call the
-    # appropriate function
-    if 'RRULE' in vevent:
-        exdates = _get_all_properties(vevent, 'EXDATE')
-        exdates += [instance]
-        vevent.pop('EXDATE')
-        vevent.add('EXDATE', exdates)
-    if 'RDATE' in vevent:
-        rdates = [one for one in _get_all_properties(vevent, 'RDATE') if one != instance]
-        vevent.pop('RDATE')
-        if rdates != []:
-            vevent.add('RDATE', rdates)
-
-
-def is_aware(dtime):
-    """test if a datetime instance is timezone aware"""
-    if dtime.tzinfo is not None and dtime.tzinfo.utcoffset(dtime) is not None:
-        return True
-    else:
-        return False
diff -Nru khal-0.9.10/khal/khalendar/vdir.py khal-0.10.2/khal/khalendar/vdir.py
--- khal-0.9.10/khal/khalendar/vdir.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/khalendar/vdir.py	2020-07-29 18:17:53.000000000 +0200
@@ -3,10 +3,12 @@
 vdirsyncer.
 '''
 
-import os
 import errno
+import os
 import uuid
 
+from typing import Optional  # noqa
+
 from atomicwrites import atomic_write
 
 
@@ -111,7 +113,7 @@
 
 
 class AlreadyExistingError(VdirError):
-    existing_href = None
+    existing_href = None  # type: Optional[str]
 
 
 class Item:
diff -Nru khal-0.9.10/khal/log.py khal-0.10.2/khal/log.py
--- khal-0.9.10/khal/log.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/log.py	1970-01-01 01:00:00.000000000 +0100
@@ -1,62 +0,0 @@
-# Copyright (c) 2013-2017 Christian Geier et al.
-#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-import logging
-import sys
-
-import click
-
-from khal import __productname__
-
-
-class ColorFormatter(logging.Formatter):
-    colors = {
-        'error': dict(fg='red'),
-        'exception': dict(fg='red'),
-        'critical': dict(fg='red'),
-        'debug': dict(fg='blue'),
-        'warning': dict(fg='yellow')
-    }
-
-    def format(self, record):
-        if not record.exc_info:
-            level = record.levelname.lower()
-            if level in self.colors:
-                prefix = click.style('{}: '.format(level),
-                                     **self.colors[level])
-                record.msg = '\n'.join(prefix + x
-                                       for x in str(record.msg).splitlines())
-
-        return logging.Formatter.format(self, record)
-
-
-class ClickStream:
-
-    def write(self, string):
-        click.echo(string, file=sys.stderr, nl=False)
-
-
-stdout_handler = logging.StreamHandler(ClickStream())
-stdout_handler.formatter = ColorFormatter()
-
-logger = logging.getLogger(__productname__)
-logger.setLevel(logging.INFO)
-logger.addHandler(stdout_handler)
diff -Nru khal-0.9.10/khal/parse_datetime.py khal-0.10.2/khal/parse_datetime.py
--- khal-0.9.10/khal/parse_datetime.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/khal/parse_datetime.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,499 @@
+# Copyright (c) 2013-2017 Christian Geier et al.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""this module contains some helper functions converting strings or list of
+strings to date(time) or event objects"""
+
+import datetime as dt
+import logging
+import re
+from calendar import isleap
+from time import strptime
+
+import pytz
+from khal.exceptions import FatalError, DateTimeParseError
+
+logger = logging.getLogger('khal')
+
+
+def timefstr(dtime_list, timeformat):
+    """converts the first item of a list (a time as a string) to a datetimeobject
+
+    where the date is today and the time is given by a string
+    removes "used" elements of list
+
+    :type dtime_list: list(str)
+    :type timeformat: str
+    :rtype: datetime.datetime
+    """
+    if len(dtime_list) == 0:
+        raise ValueError()
+    time_start = dt.datetime.strptime(dtime_list[0], timeformat)
+    time_start = dt.time(*time_start.timetuple()[3:5])
+    day_start = dt.date.today()
+    dtstart = dt.datetime.combine(day_start, time_start)
+    dtime_list.pop(0)
+    return dtstart
+
+
+def datetimefstr(dtime_list, dateformat, default_day=None, infer_year=True,
+                 in_future=True):
+    """converts a datetime (as one or several string elements of a list) to
+    a datetimeobject, if infer_year is True, use the `default_day`'s year as
+    the year of the return datetimeobject,
+
+    removes "used" elements of list
+
+    example: dtime_list = ['17.03.', 'description']
+             dateformat = '%d.%m.'
+    or     : dtime_list = ['17.03.', '16:00', 'description']
+             dateformat = '%d.%m. %H:%M'
+    """
+    # if now() is called as default param, mocking with freezegun won't work
+    now = dt.datetime.now()
+    if default_day is None:
+        default_day = now.date()
+    parts = dateformat.count(' ') + 1
+    dtstring = ' '.join(dtime_list[0:parts])
+    # only time.strptime can parse the 29th of Feb. if no year is given
+    dtstart = strptime(dtstring, dateformat)
+    if infer_year and dtstart.tm_mon == 2 and dtstart.tm_mday == 29 and \
+            not isleap(default_day.year):
+        raise ValueError
+
+    for _ in range(parts):
+        dtime_list.pop(0)
+
+    if infer_year:
+        dtstart = dt.datetime(*(default_day.timetuple()[:1] + dtstart[1:5]))
+        if in_future and dtstart < now:
+            dtstart = dtstart.replace(year=dtstart.year + 1)
+        if dtstart.date() < default_day:
+            dtstart = dtstart.replace(year=default_day.year + 1)
+        return dtstart
+    else:
+        return dt.datetime(*dtstart[:5])
+
+
+def weekdaypstr(dayname):
+    """converts an (abbreviated) dayname to a number (mon=0, sun=6)
+
+    :param dayname: name of abbreviation of the day
+    :type dayname: str
+    :return: number of the day in a week
+    :rtype: int
+    """
+
+    if dayname in ['monday', 'mon']:
+        return 0
+    if dayname in ['tuesday', 'tue']:
+        return 1
+    if dayname in ['wednesday', 'wed']:
+        return 2
+    if dayname in ['thursday', 'thu']:
+        return 3
+    if dayname in ['friday', 'fri']:
+        return 4
+    if dayname in ['saturday', 'sat']:
+        return 5
+    if dayname in ['sunday', 'sun']:
+        return 6
+    raise ValueError('invalid weekday name `%s`' % dayname)
+
+
+def construct_daynames(date_):
+    """converts datetime.date into a string description
+
+    either `Today`, `Tomorrow` or name of weekday.
+    """
+    if date_ == dt.date.today():
+        return 'Today'
+    elif date_ == dt.date.today() + dt.timedelta(days=1):
+        return 'Tomorrow'
+    else:
+        return date_.strftime('%A')
+
+
+def calc_day(dayname):
+    """converts a relative date's description to a datetime object
+
+    :param dayname: relative day name (like 'today' or 'monday')
+    :type dayname: str
+    :returns: date
+    :rtype: datetime.datetime
+    """
+    today = dt.datetime.combine(dt.date.today(), dt.time.min)
+    dayname = dayname.lower()
+    if dayname == 'today':
+        return today
+    if dayname == 'tomorrow':
+        return today + dt.timedelta(days=1)
+    if dayname == 'yesterday':
+        return today - dt.timedelta(days=1)
+
+    wday = weekdaypstr(dayname)
+    days = (wday - today.weekday()) % 7
+    days = 7 if days == 0 else days
+    day = today + dt.timedelta(days=days)
+    return day
+
+
+def datefstr_weekday(dtime_list, _, **kwargs):
+    """interprets first element of a list as a relative date and removes that
+    element
+
+    :param dtime_list: event description in list form
+    :type dtime_list: list
+    :returns: date
+    :rtype: datetime.datetime
+
+    """
+    if len(dtime_list) == 0:
+        raise ValueError()
+    day = calc_day(dtime_list[0])
+    dtime_list.pop(0)
+    return day
+
+
+def datetimefstr_weekday(dtime_list, timeformat, **kwargs):
+    if len(dtime_list) == 0:
+        raise ValueError()
+    day = calc_day(dtime_list[0])
+    this_time = timefstr(dtime_list[1:], timeformat)
+    dtime_list.pop(0)
+    dtime_list.pop(0)  # we need to pop twice as timefstr gets a copy
+    dtime = dt.datetime.combine(day, this_time.time())
+    return dtime
+
+
+def guessdatetimefstr(dtime_list, locale, default_day=None, in_future=True):
+    """
+    :type dtime_list: list
+    :type locale: dict
+    :type default_day: datetime.datetime
+    :param in_future: if set, shortdate(time) events will be set in the future
+    :type in_future: bool
+    :rtype: datetime.datetime
+    """
+    # if now() is called as default param, mocking with freezegun won't work
+    if default_day is None:
+        default_day = dt.datetime.now().date()
+    # TODO rename in guessdatetimefstrLIST or something saner altogether
+
+    def timefstr_day(dtime_list, timeformat, **kwargs):
+        if locale['timeformat'] == '%H:%M' and dtime_list[0] == '24:00':
+            a_date = dt.datetime.combine(default_day, dt.time(0))
+            dtime_list.pop(0)
+        else:
+            a_date = timefstr(dtime_list, timeformat)
+            a_date = dt.datetime(*(default_day.timetuple()[:3] + a_date.timetuple()[3:5]))
+        return a_date
+
+    def datetimefwords(dtime_list, _, **kwargs):
+        if len(dtime_list) > 0 and dtime_list[0].lower() == 'now':
+            dtime_list.pop(0)
+            return dt.datetime.now()
+        raise ValueError
+
+    def datefstr_year(dtime_list, dtformat, infer_year):
+        return datetimefstr(dtime_list, dtformat, default_day, infer_year, in_future)
+
+    dtstart = None
+    for fun, dtformat, all_day, infer_year in [
+            (datefstr_year, locale['datetimeformat'], False, True),
+            (datefstr_year, locale['longdatetimeformat'], False, False),
+            (timefstr_day, locale['timeformat'], False, False),
+            (datetimefstr_weekday, locale['timeformat'], False, False),
+            (datefstr_year, locale['dateformat'], True, True),
+            (datefstr_year, locale['longdateformat'], True, False),
+            (datefstr_weekday, None, True, False),
+            (datetimefwords, None, False, False),
+    ]:
+        # if a `short` format contains a year, treat it as a `long` format
+        if infer_year and '97' in dt.datetime(1997, 10, 11).strftime(dtformat):
+            infer_year = False
+        try:
+            dtstart = fun(dtime_list, dtformat, infer_year=infer_year)
+        except (ValueError, DateTimeParseError):
+            pass
+        else:
+            return dtstart, all_day
+    raise DateTimeParseError(
+        "Could not parse \"{}\".\nPlease check your configuration or run "
+        "`khal printformats` to see if this does match your configured "
+        "[long](date|time|datetime)format.\nIf you suspect a bug, please "
+        "file an issue at https://github.com/pimutils/khal/issues/ "
+        "".format(dtime_list)
+    )
+
+
+def timedelta2str(delta):
+    # we deliberately ignore any subsecond deltas
+    total_seconds = int(abs(delta).total_seconds())
+
+    seconds = total_seconds % 60
+    total_seconds -= seconds
+    total_minutes = total_seconds // 60
+    minutes = total_minutes % 60
+    total_minutes -= minutes
+    total_hours = total_minutes // 60
+    hours = total_hours % 24
+    total_hours -= hours
+    days = total_hours // 24
+
+    s = []
+    if days:
+        s.append(str(days) + "d")
+    if hours:
+        s.append(str(hours) + "h")
+    if minutes:
+        s.append(str(minutes) + "m")
+    if seconds:
+        s.append(str(seconds) + "s")
+
+    if delta != abs(delta):
+        s = ["-" + part for part in s]
+
+    return ' '.join(s)
+
+
+def guesstimedeltafstr(delta_string):
+    """parses a timedelta from a string
+
+    :param delta_string: string encoding time-delta, e.g. '1h 15m'
+    :type delta_string: str
+    :rtype: datetime.timedelta
+    """
+
+    tups = re.split(r'(-?\d+)', delta_string)
+    if not re.match(r'^\s*$', tups[0]):
+        raise ValueError('Invalid beginning of timedelta string "%s": "%s"'
+                         % (delta_string, tups[0]))
+    tups = tups[1:]
+    res = dt.timedelta()
+
+    for num, unit in zip(tups[0::2], tups[1::2]):
+        try:
+            numint = int(num)
+        except ValueError:
+            raise DateTimeParseError(
+                'Invalid number in timedelta string "%s": "%s"' % (delta_string, num))
+
+        ulower = unit.lower().strip()
+        if ulower == 'd' or ulower == 'day' or ulower == 'days':
+            res += dt.timedelta(days=numint)
+        elif ulower == 'h' or ulower == 'hour' or ulower == 'hours':
+            res += dt.timedelta(hours=numint)
+        elif (ulower == 'm' or ulower == 'minute' or ulower == 'minutes' or
+              ulower == 'min'):
+            res += dt.timedelta(minutes=numint)
+        elif (ulower == 's' or ulower == 'second' or ulower == 'seconds' or
+              ulower == 'sec'):
+            res += dt.timedelta(seconds=numint)
+        else:
+            raise ValueError('Invalid unit in timedelta string "%s": "%s"'
+                             % (delta_string, unit))
+
+    return res
+
+
+def guessrangefstr(daterange, locale,
+                   default_timedelta_date=dt.timedelta(days=1),
+                   default_timedelta_datetime=dt.timedelta(hours=1),
+                   adjust_reasonably=False,
+                   ):
+    """parses a range string
+
+    :param daterange: date1 [date2 | timedelta]
+    :type daterange: str or list
+    :param locale:
+    :returns: start and end of the date(time) range  and if
+        this is an all-day time range or not,
+        **NOTE**: the end is *exclusive* if this is an allday event
+    :rtype: (datetime, datetime, bool)
+
+    """
+    range_list = daterange
+    if isinstance(daterange, str):
+        range_list = daterange.split(' ')
+
+    if range_list == ['week']:
+        today_weekday = dt.datetime.today().weekday()
+        start = dt.datetime.today() - dt.timedelta(days=(today_weekday - locale['firstweekday']))
+        end = start + dt.timedelta(days=8)
+        return start, end, True
+
+    for i in reversed(range(1, len(range_list) + 1)):
+        start = ' '.join(range_list[:i])
+        end = ' '.join(range_list[i:])
+        allday = False
+        try:
+            # figuring out start
+            split = start.split(" ")
+            start, allday = guessdatetimefstr(split, locale)
+            if len(split) != 0:
+                continue
+
+            # and end
+            if len(end) == 0:
+                if allday:
+                    end = start + default_timedelta_date
+                else:
+                    end = start + default_timedelta_datetime
+            elif end.lower() == 'eod':
+                end = dt.datetime.combine(start.date(), dt.time.max)
+            elif end.lower() == 'week':
+                start -= dt.timedelta(days=(start.weekday() - locale['firstweekday']))
+                end = start + dt.timedelta(days=8)
+            else:
+                try:
+                    delta = guesstimedeltafstr(end)
+                    if allday and delta.total_seconds() % (3600 * 24):
+                        # TODO better error class, no logging in here
+                        logger.fatal(
+                            "Cannot give delta containing anything but whole days for allday events"
+                        )
+                        raise FatalError()
+                    elif delta.total_seconds() == 0:
+                        logger.fatal(
+                            "Events that last no time are not allowed"
+                        )
+                        raise FatalError()
+
+                    end = start + delta
+                except (ValueError, DateTimeParseError):
+                    split = end.split(" ")
+                    end, end_allday = guessdatetimefstr(
+                        split, locale, default_day=start.date(), in_future=False)
+                    if len(split) != 0:
+                        continue
+                    if allday:
+                        end += dt.timedelta(days=1)
+
+            if adjust_reasonably:
+                if allday:
+                    # test if end's year is this year, but start's year is not
+                    today = dt.datetime.today()
+                    if end.year == today.year and start.year != today.year:
+                        end = dt.datetime(start.year, *end.timetuple()[1:6])
+
+                    if end < start:
+                        end = dt.datetime(end.year + 1, *end.timetuple()[1:6])
+
+                if end < start:
+                    end = dt.datetime(*start.timetuple()[0:3] + end.timetuple()[3:5])
+                if end < start:
+                    end = end + dt.timedelta(days=1)
+            return start, end, allday
+        except (ValueError, DateTimeParseError):
+            pass
+
+    raise DateTimeParseError(
+        "Could not parse \"{}\".\nPlease check your configuration or run "
+        "`khal printformats` to see if this does match your configured "
+        "[long](date|time|datetime)format.\nIf you suspect a bug, please "
+        "file an issue at https://github.com/pimutils/khal/issues/ "
+        "".format(daterange)
+    )
+
+
+def rrulefstr(repeat, until, locale):
+    if repeat in ["daily", "weekly", "monthly", "yearly"]:
+        rrule_settings = {'freq': repeat}
+        if until:
+            until_dt, is_date = guessdatetimefstr(until.split(' '), locale)
+            rrule_settings['until'] = until_dt
+        return rrule_settings
+    else:
+        logger.fatal("Invalid value for the repeat option. \
+                Possible values are: daily, weekly, monthly or yearly")
+        raise FatalError()
+
+
+def eventinfofstr(info_string, locale, default_event_duration, default_dayevent_duration,
+                  adjust_reasonably=False, localize=False):
+    """parses a string of the form START [END | DELTA] [TIMEZONE] [SUMMARY] [::
+    DESCRIPTION] into a dictionary with keys: dtstart, dtend, timezone, allday,
+    summary, description
+
+    :param info_string:
+    :type info_string: string fitting the form
+    :param locale:
+    :type locale: locale
+    :param adjust_reasonably:
+    :type adjust_reasonably: passed on to guessrangefstr
+    :rtype: dictionary
+
+    """
+    description = None
+    if " :: " in info_string:
+        info_string, description = info_string.split(' :: ')
+
+    parts = info_string.split(' ')
+    summary = None
+    start = None
+    end = None
+    tz = None
+    allday = False
+    for i in reversed(range(1, len(parts) + 1)):
+        try:
+            start, end, allday = guessrangefstr(
+                ' '.join(parts[0:i]), locale,
+                default_event_duration,
+                default_dayevent_duration,
+                adjust_reasonably=adjust_reasonably,
+            )
+        except (ValueError, DateTimeParseError):
+            continue
+        if start is not None and end is not None:
+            try:
+                # next element is a valid Olson db timezone string
+                tz = pytz.timezone(parts[i])
+                i += 1
+            except (pytz.UnknownTimeZoneError, UnicodeDecodeError, IndexError):
+                tz = None
+            summary = ' '.join(parts[i:])
+            break
+
+    if start is None or end is None:
+        raise DateTimeParseError(
+            "Could not parse \"{}\".\nPlease check your configuration or run "
+            "`khal printformats` to see if this does match your configured "
+            "[long](date|time|datetime)format.\nIf you suspect a bug, please "
+            "file an issue at https://github.com/pimutils/khal/issues/ "
+            "".format(info_string)
+        )
+
+    if tz is None:
+        tz = locale['default_timezone']
+
+    if allday:
+        start = start.date()
+        end = end.date()
+
+    info = {}
+    info["dtstart"] = start
+    info["dtend"] = end
+    info["summary"] = summary if summary else None
+    info["description"] = description
+    info["timezone"] = tz if not allday else None
+    info["allday"] = allday
+    return info
diff -Nru khal-0.9.10/khal/settings/khal.spec khal-0.10.2/khal/settings/khal.spec
--- khal-0.9.10/khal/settings/khal.spec	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/settings/khal.spec	2020-07-29 18:17:53.000000000 +0200
@@ -30,11 +30,20 @@
 # The 24-bit color must be given as #RRGGBB, where RR, GG, BB is the
 # hexadecimal value of the red, green and blue component, respectively.
 # When using a 24-bit color, make sure to enclose the color value in ' or "!
-# If the color is set to *auto* (the default), khal tries to read the file
-# *color* from this calendar's vdir, if this fails the default_color (see
-# below) is used. If color is set to '', the default_color is always used.
+# If `color` is set to *auto* (the default), khal looks for a color value in a
+# *color* file in this calendar's vdir. If the *color* file does not exist, the 
+# default_color (see below) is used. If color is set to '', the default_color is 
+# always used. Note that you can use `vdirsyncer metasync` to synchronize colors 
+# with your caldav server. 
+
 color = color(default='auto')
 
+# When coloring days, the color will be determined based on the calendar with
+# the highest priority. If the priorities are equal, then the "multiple" color
+# will be used.
+
+priority = integer(default=10)
+
 # setting this to *True*, will keep khal from making any changes to this
 # calendar
 readonly = boolean(default=False)
@@ -46,7 +55,7 @@
 # considered, all other files are ignored (except for a possible `color` file).
 #
 # If set to ``birthdays`` khal will expect a VCARD collection and extract
-# birthdays from those VCARDS, that is only files with ``.ics`` extension will
+# birthdays from those VCARDS, that is only files with ``.vcf`` extension will
 # be considered, all other files will be ignored.  ``birthdays`` also implies
 # ``readonly=True``.
 #
@@ -68,7 +77,7 @@
 # It is mandatory to set (long)date-, time-, and datetimeformat options, all others options in the **[locale]** section are optional and have (sensible) defaults.
 [locale]
 
-# the first day of the week, were Monday is 0 and Sunday is 6
+# the first day of the week, where Monday is 0 and Sunday is 6
 firstweekday = integer(0, 6, default=0)
 
 # by default khal uses some unicode symbols (as in 'non-ascii') as indicators for things like repeating events,
@@ -87,24 +96,24 @@
 # khal will display and understand all times in this format.
 
 # The formatting string is interpreted as defined by Python's `strftime
-# <https://docs.python.org/2/library/time.html#time.strftime>`_, which is
+# <https://docs.python.org/3/library/time.html#time.strftime>`_, which is
 # similar to the format specified in ``man strftime``.
-timeformat = string(default='%H:%M')
+timeformat = string(default='%X')
 
 # khal will display and understand all dates in this format, see :ref:`timeformat <locale-timeformat>` for the format
-dateformat = string(default='%d.%m.')
+dateformat = string(default='%x')
 
 # khal will display and understand all dates in this format, it should
 # contain a year (e.g. *%Y*) see :ref:`timeformat <locale-timeformat>` for the format.
-longdateformat = string(default='%d.%m.%Y')
+longdateformat = string(default='%x')
 
 # khal will display and understand all datetimes in this format, see
 # :ref:`timeformat <locale-timeformat>` for the format.
-datetimeformat = string(default='%d.%m. %H:%M')
+datetimeformat = string(default='%c')
 
 # khal will display and understand all datetimes in this format, it should
 # contain a year (e.g. *%Y*) see :ref:`timeformat <locale-timeformat>` for the format.
-longdatetimeformat = string(default='%d.%m.%Y %H:%M')
+longdatetimeformat = string(default='%c')
 
 
 # Enable weeknumbers in `calendar` and `interactive` (ikhal) mode. As those are
@@ -164,6 +173,9 @@
 # open a text field to start a search for events
 search = force_list(default=list('/'))
 
+# show logged messages
+log = force_list(default=list('L'))
+
 # quit
 quit = force_list(default=list('q', 'Q'))
 
@@ -171,9 +183,6 @@
 # Some default values and behaviors are set here.
 [default]
 
-# Command to be executed if no command is given when executing khal.
-default_command = option('calendar', 'list', 'interactive', 'printformats', 'printcalendars', 'printics', '', default='calendar')
-
 # The calendar to use if none is specified for some operation (e.g. if adding a
 # new event). If this is not set, such operations require an explicit value.
 default_calendar = string(default=None)
@@ -195,8 +204,17 @@
 # `khal list`) by default.
 timedelta = timedelta(default='2d')
 
+
+# Define the defaut duration for a day-long event ('khal new' only)
+default_event_duration = timedelta(default='1d')
+
+# Define the default duration for an event ('khal new' only)
+default_dayevent_duration = timedelta(default='1h')
+
+
 # The view section contains configuration options that effect the visual appearance
 # when using khal and ikhal.
+
 [view]
 
 # Defines the behaviour of ikhal's right column. If `True`, the right column
@@ -253,6 +271,11 @@
 # Specifies how each *day header* is formatted.
 agenda_day_format = string(default='{bold}{name}, {date-long}{reset}')
 
+# Display month name on row when the week contains the first day of
+# of the month ('firstday') or when the first day of the week is in the
+# month ('firstfullweek')
+monthdisplay = monthdisplay(default='firstday')
+
 # Default formatting for events used when the start- and end-date are not
 # clear through context, e.g. for :command:`search`, used almost everywhere
 # but :command:`list` and :command:`calendar`. It is therefore probably a
diff -Nru khal-0.9.10/khal/settings/settings.py khal-0.10.2/khal/settings/settings.py
--- khal-0.9.10/khal/settings/settings.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/settings/settings.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,19 +20,22 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
 
+import logging
 import os
 
-from configobj import ConfigObj, flatten_errors, get_extra_values, \
-    ConfigObjError
-from validate import Validator
 import xdg.BaseDirectory
-
-from .exceptions import InvalidSettingsError, CannotParseConfigFileError, NoConfigFile
+from configobj import (ConfigObj, ConfigObjError, flatten_errors,
+                       get_extra_values)
 from khal import __productname__
-from ..log import logger
-from .utils import is_timezone, is_timedelta, weeknumber_option, config_checks, \
-    expand_path, expand_db_path, is_color, get_vdir_type, get_color_from_vdir
+from validate import Validator
+
+from .exceptions import (CannotParseConfigFileError, InvalidSettingsError,
+                         NoConfigFile)
+from .utils import (config_checks, expand_db_path, expand_path,
+                    get_color_from_vdir, get_vdir_type, is_color, is_timedelta,
+                    is_timezone, weeknumber_option, monthdisplay_option)
 
+logger = logging.getLogger('khal')
 SPECPATH = os.path.join(os.path.dirname(__file__), 'khal.spec')
 
 
@@ -42,16 +45,9 @@
     This function builds the list of paths known by khal and then return the
     first one which exists. The first paths searched are the ones described in
     the XDG Base Directory Standard, e.g. ~/.config/khal/config, additionally
-    ~/.config/khal/khal.conf is searched (deprecated). All other paths end with
-    DEFAULT_PATH/DEFAULT_FILE.
-
-    On failure, the path DEFAULT_PATH/DEFAULT_FILE, prefixed with
-    a dot, is searched in the home user directory. Ultimately,
-    DEFAULT_FILE is searched in the current directory.
+    ~/.config/khal/khal.conf is searched (deprecated).
     """
-    DEFAULT_FILE = __productname__ + '.conf'
     DEFAULT_PATH = __productname__
-    resource = os.path.join(DEFAULT_PATH, DEFAULT_FILE)
 
     paths = []
     paths = [os.path.join(path, os.path.join(DEFAULT_PATH, 'config'))
@@ -60,20 +56,6 @@
         if os.path.exists(path):
             return path
 
-    # remove this part for v0.10.0
-    paths = [os.path.join(path, resource) for path in xdg.BaseDirectory.xdg_config_dirs]
-    for path in paths:
-        if os.path.exists(path):
-            logger.warning(
-                'Deprecation Warning: configuration file path `{}` will not be '
-                'supported from khal v0.10.0 onwards, please move it to '
-                '`{}`.'
-                ''.format(path, path.replace('khal.conf', 'config')))
-            return path
-    paths = []
-    paths.append(os.path.expanduser(os.path.join('~', '.' + resource)))
-    paths.append(os.path.expanduser(DEFAULT_FILE))
-
     # remove this part for v0.11.0
     for path in paths:
         if os.path.exists(path):
@@ -127,25 +109,26 @@
              'expand_path': expand_path,
              'expand_db_path': expand_db_path,
              'weeknumbers': weeknumber_option,
+             'monthdisplay': monthdisplay_option,
              'color': is_color,
              }
     validator = Validator(fdict)
     results = user_config.validate(validator, preserve_errors=True)
 
     abort = False
-    for section, subsection, error in flatten_errors(user_config, results):
+    for section, subsection, config_error in flatten_errors(user_config, results):
         abort = True
-        if isinstance(error, Exception):
+        if isinstance(config_error, Exception):
             logger.fatal(
                 'config error:\n'
-                'in [{}] {}: {}'.format(section[0], subsection, error))
+                'in [{}] {}: {}'.format(section[0], subsection, config_error))
         else:
-            for key in error:
-                if isinstance(error[key], Exception):
+            for key in config_error:
+                if isinstance(config_error[key], Exception):
                     logger.fatal('config error:\nin {} {}: {}'.format(
                         sectionize(section + [subsection]),
                         key,
-                        str(error[key]))
+                        str(config_error[key]))
                     )
 
     if abort or not results:
@@ -161,6 +144,12 @@
             section = sectionize(section)
             logger.warning(
                 'unknown key or subsection "{}" in section "{}"'.format(value, section))
+
+            deprecated = [{'value': 'default_command', 'section': 'default'}]
+            for d in deprecated:
+                if (value == d['value']) and (section == d['section']):
+                    logger.warning('Key "{}" in section "{}" was deprecated. '
+                                   'See the FAQ to find out when and why!'.format(value, section))
     return user_config
 
 
diff -Nru khal-0.9.10/khal/settings/utils.py khal-0.10.2/khal/settings/utils.py
--- khal-0.9.10/khal/settings/utils.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/settings/utils.py	2020-07-29 18:17:53.000000000 +0200
@@ -20,21 +20,22 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
 
-from os.path import expandvars, expanduser, join
-import os
 import glob
+import logging
+import os
+from os.path import expanduser, expandvars, join
 
 import pytz
 import xdg
 from tzlocal import get_localzone
 from validate import VdtValueError
 
-from ..log import logger
+from ..khalendar.vdir import CollectionNotFoundError, Vdir
+from ..parse_datetime import guesstimedeltafstr
+from ..terminal import COLORS
 from .exceptions import InvalidSettingsError
 
-from ..terminal import COLORS
-from ..khalendar.vdir import Vdir, CollectionNotFoundError
-from ..utils import guesstimedeltafstr
+logger = logging.getLogger('khal')
 
 
 def is_timezone(tzstring):
@@ -78,6 +79,25 @@
             "'off', 'left' or 'right'".format(option))
 
 
+def monthdisplay_option(option):
+    """checks if *option* is a valid value
+
+    :param option: the option the user set in the config file
+    :type option: str
+    :returns: firstday, firstfullweek
+    :rtype: str/bool
+    """
+    option = option.lower()
+    if option == 'firstday':
+        return 'firstday'
+    elif option == 'firstfullweek':
+        return 'firstfullweek'
+    else:
+        raise VdtValueError(
+            "Invalid value '{}' for option 'monthdisplay', must be one of "
+            "'firstday' or 'firstfullweek'".format(option))
+
+
 def expand_path(path):
     """expands `~` as well as variable names"""
     return expanduser(expandvars(path))
@@ -133,7 +153,7 @@
         color = Vdir(path, '.ics').get_meta('color')
     except CollectionNotFoundError:
         color = None
-    if color is None or color is '':
+    if color is None or color == '':
         logger.debug('Found no or empty file `color` in {}'.format(path))
         return None
     color = color.strip()
@@ -189,6 +209,9 @@
     vdirs_complete = list()
     vdir_colors_from_config = {}
     for calendar in list(config['calendars'].keys()):
+        if not isinstance(config['calendars'][calendar], dict):
+            logger.fatal('Invalid config file, probably missing calendar sections')
+            raise InvalidSettingsError
         if config['calendars'][calendar]['type'] == 'discover':
             logger.debug(
                 'discovering calendars in {}'.format(config['calendars'][calendar]['path'])
@@ -203,7 +226,8 @@
         calendar = {'path': vdir,
                     'color': _get_color_from_vdir(vdir),
                     'type': _get_vdir_type(vdir),
-                    'readonly': False
+                    'readonly': False,
+                    'priority': 10,
                     }
 
         # get color from config if not defined in vdir
diff -Nru khal-0.9.10/khal/terminal.py khal-0.10.2/khal/terminal.py
--- khal-0.9.10/khal/terminal.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/terminal.py	2020-07-29 18:17:53.000000000 +0200
@@ -24,7 +24,6 @@
 from collections import namedtuple
 from itertools import zip_longest
 
-
 NamedColor = namedtuple('NamedColor', ['index', 'light'])
 
 RTEXT = '\x1b[7m'  # reverse
diff -Nru khal-0.9.10/khal/ui/base.py khal-0.10.2/khal/ui/base.py
--- khal-0.9.10/khal/ui/base.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/base.py	2020-07-29 18:17:53.000000000 +0200
@@ -23,13 +23,16 @@
 """this module should contain classes that are specific to ikhal, more
 general widgets should go in widgets.py"""
 
-
-import urwid
+import logging
 import threading
 import time
 
+import urwid
+
 from .widgets import NColumns
 
+logger = logging.getLogger('khal')
+
 
 class Pane(urwid.WidgetWrap):
 
@@ -74,10 +77,39 @@
         overlay = urwid.Overlay(content, self, 'center', ('relative', 70), ('relative', 70), None)
         self.window.open(overlay)
 
+    def scrollable_dialog(self, text, buttons=None, title="Press `ESC` to close this window"):
+        """Open a scrollable dialog box.
+
+        :param text: Text to appear as the body of the Dialog box
+        :type text: str
+        :param buttons: list of tuples of button labels and functions to call
+            when the button is pressed
+        :type buttons: list(str, callable)
+        """
+        body = urwid.ListBox([urwid.Text(line) for line in text.splitlines()])
+        if buttons:
+            buttons = NColumns(
+                [urwid.Button(label, on_press=func) for label, func in buttons],
+                outermost=True,
+            )
+            content = urwid.LineBox(urwid.Pile([body, ('pack', buttons)]))
+        else:
+            content = urwid.LineBox(urwid.Pile([body]))
+
+        # put the title on the upper line
+        over = urwid.Overlay(
+            urwid.Text(" " + title + " "), content, 'center', len(title) + 2, 'top', None,
+        )
+        overlay = urwid.Overlay(
+            over, self, 'center', ('relative', 70), 'middle', ('relative', 70), None)
+        self.window.open(overlay)
+
     def keypress(self, size, key):
         """Handle application-wide key strokes."""
         if key in ['f1', '?']:
             self.show_keybindings()
+        elif key in ['L']:
+            self.show_log()
         else:
             return super().keypress(size, key)
 
@@ -87,10 +119,16 @@
         lines.append('  =======              ====')
         for command, keys in self._conf['keybindings'].items():
             lines.append('  {:20} {}'.format(command, keys))
-        lines.append('')
-        lines.append("Press `Escape` to close this window")
+        self.scrollable_dialog(
+            '\n'.join(lines),
+            title="Press `ESC` to close this window, arrows to scroll",
+        )
 
-        self.dialog('\n'.join(lines), [])
+    def show_log(self):
+        self.scrollable_dialog(
+            '\n'.join(self.window._log),
+            title="Press `ESC` to close this window, arrows to scroll",
+        )
 
 
 class Window(urwid.Frame):
@@ -118,10 +156,14 @@
         self._original_w = None
         self.quit_keys = quit_keys
 
-        self._alert_daemon = AlertDaemon(self.update_header)
+        def alert(message):
+            self.update_header(message, warn=True)
+        self._alert_daemon = AlertDaemon(alert)
         self._alert_daemon.start()
         self.alert = self._alert_daemon.alert
         self.loop = None
+        self._log = []
+        self._header_is_warning = False
 
     def open(self, pane, callback=None):
         """Open a new pane.
@@ -166,12 +208,21 @@
 
     def _update(self, pane):
         self.set_body(pane)
-        self.update_header()
+        self.clear_header()
+
+    def log(self, record):
+        self._log.append(record)
 
     def _get_current_pane(self):
         return self._track[-1][0] if self._track else None
 
-    def update_header(self, alert=None):
+    def clear_header(self):
+        """clears header if we are not currently showing a warning"""
+        if not self._header_is_warning:
+            pane_title = getattr(self._get_current_pane(), 'title', '')
+            self.header.w.set_text(pane_title)
+
+    def update_header(self, alert=None, warn=False):
         """Update the Windows header line.
 
         :param alert: additional text to show in header, additionally to
@@ -179,6 +230,7 @@
             be a valid palette entry
         :type alert: str or (palette_entry, str)
         """
+        self._header_is_warning = warn
         pane_title = getattr(self._get_current_pane(), 'title', None)
         text = []
 
diff -Nru khal-0.9.10/khal/ui/calendarwidget.py khal-0.10.2/khal/ui/calendarwidget.py
--- khal-0.9.10/khal/ui/calendarwidget.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/calendarwidget.py	2020-07-29 18:17:53.000000000 +0200
@@ -25,13 +25,12 @@
 """
 
 import calendar
+import datetime as dt
 from collections import defaultdict
-from datetime import date
-from locale import getlocale, setlocale, LC_ALL, LC_TIME
-
-from khal.utils import get_month_abbr_len
+from locale import LC_ALL, LC_TIME, getlocale, setlocale
 
 import urwid
+from khal.utils import get_month_abbr_len
 
 setlocale(LC_ALL, '')
 
@@ -43,7 +42,7 @@
     :return: weeknumber
     :rtype: int
     """
-    return date.isocalendar(day)[1]
+    return dt.date.isocalendar(day)[1]
 
 
 class DatePart(urwid.Text):
@@ -236,9 +235,9 @@
         super(CListBox, self).__init__(walker)
 
     def render(self, size, focus=False):
+        while 'bottom' in self.ends_visible(size):
+            self.body._autoextend()
         if self._init:
-            while 'bottom' in self.ends_visible(size):
-                self.body._autoextend()
             self.set_focus_valign('middle')
             self._init = False
 
@@ -313,9 +312,9 @@
 
     def keypress(self, size, key):
         if key in self.keybindings['mark'] + ['esc'] and self._marked:
-                self._unmark_all()
-                self._marked = False
-                return
+            self._unmark_all()
+            self._marked = False
+            return
         if key in self.keybindings['mark']:
             self._marked = {'date': self.body.focus_date,
                             'pos': (self.focus_position, self.focus.focus_col)}
@@ -338,7 +337,7 @@
             # reset colors of currently focused Date widget
             self.focus.focus.set_styles(self.focus.get_styles(self.body.focus_date, False))
         if key in self.keybindings['today']:
-            self.set_focus_date(date.today())
+            self.set_focus_date(dt.date.today())
             self.set_focus_valign(('relative', 10))
 
         key = super(CListBox, self).keypress(size, key)
@@ -349,11 +348,13 @@
 
 class CalendarWalker(urwid.SimpleFocusListWalker):
     def __init__(self, on_date_change, on_press, keybindings, firstweekday=0,
-                 weeknumbers=False, get_styles=None, initial=None):
+                 weeknumbers=False, monthdisplay='firstday', get_styles=None,
+                 initial=None):
         if initial is None:
-            initial = date.today()
+            initial = dt.date.today()
         self.firstweekday = firstweekday
         self.weeknumbers = weeknumbers
+        self.monthdisplay = monthdisplay
         self.on_date_change = on_date_change
         self.on_press = on_press
         self.keybindings = keybindings
@@ -484,7 +485,10 @@
                   if today is in this week
         :rtype: tuple(urwid.CColumns, bool)
         """
-        if 1 in [day.day for day in week]:
+        if self.monthdisplay == 'firstday' and 1 in [day.day for day in week]:
+            month_name = calendar.month_abbr[week[-1].month].ljust(4)
+            attr = 'monthname'
+        elif self.monthdisplay == 'firstfullweek' and week[0].day <= 7:
             month_name = calendar.month_abbr[week[-1].month].ljust(4)
             attr = 'monthname'
         elif self.weeknumbers == 'left':
@@ -512,8 +516,8 @@
         return week
 
     def _construct_month(self,
-                         year=date.today().year,
-                         month=date.today().month,
+                         year=dt.date.today().year,
+                         month=dt.date.today().month,
                          clean_first_row=False,
                          clean_last_row=False):
         """construct one month of DateCColumns
@@ -554,7 +558,7 @@
 
 class CalendarWidget(urwid.WidgetWrap):
     def __init__(self, on_date_change, keybindings, on_press, firstweekday=0,
-                 weeknumbers=False, get_styles=None, initial=None):
+                 weeknumbers=False, monthdisplay='firstday', get_styles=None, initial=None):
         """
         :param on_date_change: a function that is called every time the selected
             date is changed with the newly selected date as a first (and only
@@ -580,7 +584,7 @@
         :type on_press: dict
         """
         if initial is None:
-            self._initial = date.today()
+            self._initial = dt.date.today()
         else:
             self._initial = initial
 
@@ -605,12 +609,12 @@
 
         def _get_styles(date, focus):
             if focus:
-                if date == date.today():
+                if date == dt.date.today():
                     return 'today focus'
                 else:
                     return 'reveal focus'
             else:
-                if date == date.today():
+                if date == dt.date.today():
                     return 'today'
                 else:
                     return None
@@ -625,7 +629,8 @@
             [(2, urwid.AttrMap(urwid.Text(name), 'dayname')) for name in dnames],
             dividechars=1)
         self.walker = CalendarWalker(
-            on_date_change, on_press, default_keybindings, firstweekday, weeknumbers,
+            on_date_change, on_press, default_keybindings, firstweekday,
+            weeknumbers, monthdisplay,
             get_styles, initial=self._initial)
         self.box = CListBox(self.walker)
         frame = urwid.Frame(self.box, header=dnames)
@@ -633,11 +638,15 @@
         self.set_focus_date(self._initial)
 
     def focus_today(self):
-        self.set_focus_date(date.today())
+        self.set_focus_date(dt.date.today())
 
     def reset_styles_range(self, min_date, max_date):
         self.walker.reset_styles_range(min_date, max_date)
 
+    @classmethod
+    def selectable(cls):
+        return True
+
     @property
     def focus_date(self):
         return self.walker.focus_date
diff -Nru khal-0.9.10/khal/ui/colors.py khal-0.10.2/khal/ui/colors.py
--- khal-0.9.10/khal/ui/colors.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/colors.py	2020-07-29 18:17:53.000000000 +0200
@@ -36,9 +36,9 @@
     ('today focus', 'white', 'dark magenta'),
     ('today', 'dark gray', 'dark green',),
 
-    ('date', 'light gray', ''),
-    ('date focused', 'black', 'light gray', ('bold', 'standout')),
-    ('date selected', 'white', 'yellow'),
+    ('date header', 'light gray', 'black'),
+    ('date header focused', 'black', 'white'),
+    ('date header selected', 'dark gray', 'light gray'),
 
     ('dayname', 'light gray', ''),
     ('monthname', 'light gray', ''),
@@ -72,9 +72,9 @@
     ('today focus', 'white', 'dark cyan', 'standout'),
     ('today', 'black', 'light gray', 'dark cyan'),
 
-    ('date', '', 'white'),
-    ('date focused', 'white', 'dark gray', ('bold', 'standout')),
-    ('date selected', 'dark gray', 'light cyan'),
+    ('date header', '', 'white'),
+    ('date header focused', 'white', 'dark gray', ('bold', 'standout')),
+    ('date header selected', 'dark gray', 'light cyan'),
 
     ('dayname', 'dark gray', 'white'),
     ('monthname', 'dark gray', 'white'),
diff -Nru khal-0.9.10/khal/ui/editor.py khal-0.10.2/khal/ui/editor.py
--- khal-0.9.10/khal/ui/editor.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/editor.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,15 +19,15 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-from datetime import datetime, time
 import datetime as dt
 
 import urwid
 
-from ..utils import get_weekday_occurrence
-from .widgets import DateWidget, TimeWidget, NColumns, NPile, ValidatedEdit, \
-    DateConversionError, Choice, PositiveIntEdit, AlarmsEditor, NListBox, ExtendedEdit
+from ..utils import get_weekday_occurrence, get_wrapped_text
 from .calendarwidget import CalendarWidget
+from .widgets import (AlarmsEditor, Choice, DateConversionError, DateWidget,
+                      ExtendedEdit, NColumns, NListBox, NPile, PositiveIntEdit,
+                      TimeWidget, ValidatedEdit)
 
 
 class StartEnd(object):
@@ -41,9 +41,11 @@
 
 
 class CalendarPopUp(urwid.PopUpLauncher):
-    def __init__(self, widget, on_date_change, weeknumbers=False, firstweekday=0, keybindings=None):
+    def __init__(self, widget, on_date_change, weeknumbers=False,
+                 firstweekday=0, monthdisplay='firstday', keybindings=None):
         self._on_date_change = on_date_change
         self._weeknumbers = weeknumbers
+        self._monthdisplay = monthdisplay
         self._firstweekday = firstweekday
         self._keybindings = {} if keybindings is None else keybindings
         self.__super.__init__(widget)
@@ -70,6 +72,7 @@
                 on_change, self._keybindings, on_press,
                 firstweekday=self._firstweekday,
                 weeknumbers=self._weeknumbers,
+                monthdisplay=self._monthdisplay,
                 initial=initial_date)
             pop_up = urwid.LineBox(pop_up)
             return pop_up
@@ -89,7 +92,8 @@
     def __init__(
             self, startdt=None, dateformat='%Y-%m-%d',
             on_date_change=lambda _: None,
-            weeknumbers=False, firstweekday=0, keybindings=None,
+            weeknumbers=False, firstweekday=0, monthdisplay='firstday',
+            keybindings=None,
     ):
         datewidth = len(startdt.strftime(dateformat)) + 1
         self._dateformat = dateformat
@@ -101,13 +105,14 @@
             validate=self._validate,
             edit_text=startdt.strftime(dateformat),
             on_date_change=on_date_change)
-        wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers, firstweekday, keybindings)
+        wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers,
+                                firstweekday, monthdisplay, keybindings)
         padded = urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1)
         super().__init__(padded)
 
     def _validate(self, text):
         try:
-            _date = datetime.strptime(text, self._dateformat).date()
+            _date = dt.datetime.strptime(text, self._dateformat).date()
         except ValueError:
             return False
         else:
@@ -128,7 +133,7 @@
 
         :type date: datetime.date
         """
-        self._edit.set_edit_text(date.strftime(self._dateformat))
+        self._edit.set_edit_text(dt.date.strftime(self._dateformat))
 
 
 class StartEndEditor(urwid.WidgetWrap):
@@ -147,7 +152,7 @@
         :param on_end_date_change: same as for on_start_date_change, just for the
             end date
         """
-        self.allday = not isinstance(start, datetime)
+        self.allday = not isinstance(start, dt.datetime)
         self.conf = conf
         self._startdt, self._original_start = start, start
         self._enddt, self._original_end = end, end
@@ -166,7 +171,7 @@
 
     @property
     def startdt(self):
-        if self.allday and isinstance(self._startdt, datetime):
+        if self.allday and isinstance(self._startdt, dt.datetime):
             return self._startdt.date()
         else:
             return self._startdt
@@ -176,7 +181,7 @@
         try:
             return self._startdt.time()
         except AttributeError:
-            return time(0)
+            return dt.time(0)
 
     @property
     def localize_start(self):
@@ -194,7 +199,7 @@
 
     @property
     def enddt(self):
-        if self.allday and isinstance(self._enddt, datetime):
+        if self.allday and isinstance(self._enddt, dt.datetime):
             return self._enddt.date()
         else:
             return self._enddt
@@ -204,33 +209,33 @@
         try:
             return self._enddt.time()
         except AttributeError:
-            return time(0)
+            return dt.time(0)
 
     def _validate_start_time(self, text):
         try:
-            startval = datetime.strptime(text, self.conf['locale']['timeformat'])
+            startval = dt.datetime.strptime(text, self.conf['locale']['timeformat'])
             self._startdt = self.localize_start(
-                datetime.combine(self._startdt.date(), startval.time()))
+                dt.datetime.combine(self._startdt.date(), startval.time()))
         except ValueError:
             return False
         else:
             return startval
 
     def _start_date_change(self, date):
-        self._startdt = self.localize_start(datetime.combine(date, self._start_time))
+        self._startdt = self.localize_start(dt.datetime.combine(date, self._start_time))
         self.on_start_date_change(date)
 
     def _validate_end_time(self, text):
         try:
-            endval = datetime.strptime(text, self.conf['locale']['timeformat'])
-            self._enddt = self.localize_end(datetime.combine(self._enddt.date(), endval.time()))
+            endval = dt.datetime.strptime(text, self.conf['locale']['timeformat'])
+            self._enddt = self.localize_end(dt.datetime.combine(self._enddt.date(), endval.time()))
         except ValueError:
             return False
         else:
             return endval
 
     def _end_date_change(self, date):
-        self._enddt = self.localize_end(datetime.combine(date, self._end_time))
+        self._enddt = self.localize_end(dt.datetime.combine(date, self._end_time))
         self.on_end_date_change(date)
 
     def toggle(self, checkbox, state):
@@ -245,8 +250,8 @@
         """
 
         if self.allday is True and state is False:
-            self._startdt = datetime.combine(self._startdt, datetime.min.time())
-            self._enddt = datetime.combine(self._enddt, datetime.min.time())
+            self._startdt = dt.datetime.combine(self._startdt, dt.datetime.min.time())
+            self._enddt = dt.datetime.combine(self._enddt, dt.datetime.min.time())
         elif self.allday is False and state is True:
             self._startdt = self._startdt.date()
             self._enddt = self._enddt.date()
@@ -254,12 +259,16 @@
         self.widgets.startdate = DateEdit(
             self._startdt, self.conf['locale']['longdateformat'],
             self._start_date_change, self.conf['locale']['weeknumbers'],
-            self.conf['locale']['firstweekday'], self.conf['keybindings'],
+            self.conf['locale']['firstweekday'],
+            self.conf['view']['monthdisplay'],
+            self.conf['keybindings'],
         )
         self.widgets.enddate = DateEdit(
             self._enddt, self.conf['locale']['longdateformat'],
             self._end_date_change, self.conf['locale']['weeknumbers'],
-            self.conf['locale']['firstweekday'], self.conf['keybindings'],
+            self.conf['locale']['firstweekday'],
+            self.conf['view']['monthdisplay'],
+            self.conf['keybindings'],
         )
 
         if state is True:
@@ -268,25 +277,23 @@
             self.widgets.endtime = urwid.Text('')
         elif state is False:
             timewidth = self._timewidth + 1
-            edit = ValidatedEdit(
+            raw_start_time_widget = ValidatedEdit(
                 dateformat=self.conf['locale']['timeformat'],
                 EditWidget=TimeWidget,
                 validate=self._validate_start_time,
                 edit_text=self.startdt.strftime(self.conf['locale']['timeformat']),
             )
-            edit = urwid.Padding(
-                edit, align='left', width=self._timewidth + 1, left=1)
-            self.widgets.starttime = edit
+            self.widgets.starttime = urwid.Padding(
+                raw_start_time_widget, align='left', width=self._timewidth + 1, left=1)
 
-            edit = ValidatedEdit(
+            raw_end_time_widget = ValidatedEdit(
                 dateformat=self.conf['locale']['timeformat'],
                 EditWidget=TimeWidget,
                 validate=self._validate_end_time,
                 edit_text=self.enddt.strftime(self.conf['locale']['timeformat']),
             )
-            edit = urwid.Padding(
-                edit, align='left', width=self._timewidth + 1, left=1)
-            self.widgets.endtime = edit
+            self.widgets.endtime = urwid.Padding(
+                raw_end_time_widget, align='left', width=self._timewidth + 1, left=1)
 
         columns = NPile([
             self.checkallday,
@@ -341,7 +348,9 @@
         self.recurrenceeditor = RecurrenceEditor(
             self.event.recurobject, self._conf, event.start_local,
         )
-        self.summary = ExtendedEdit(caption='Title: ', edit_text=event.summary)
+        self.summary = urwid.AttrMap(ExtendedEdit(
+            caption=('', 'Title:       '), edit_text=event.summary), 'edit'
+        )
 
         divider = urwid.Divider(' ')
 
@@ -353,14 +362,24 @@
             self.collection._calendars[self.event.calendar],
             decorate_choice
         )
-        self.description = ExtendedEdit(
-            caption='Description: ', edit_text=self.description, multiline=True,
+        self.description = urwid.AttrMap(
+            ExtendedEdit(
+                caption=('', 'Description: '),
+                edit_text=self.description,
+                multiline=True
+            ),
+            'edit'
+        )
+        self.location = urwid.AttrMap(ExtendedEdit(
+            caption=('', 'Location:    '), edit_text=self.location), 'edit'
+        )
+        self.categories = urwid.AttrMap(ExtendedEdit(
+            caption=('', 'Categories:  '), edit_text=self.categories), 'edit'
         )
-        self.location = ExtendedEdit(caption='Location: ', edit_text=self.location)
-        self.categories = ExtendedEdit(caption='Categories: ', edit_text=self.categories)
         self.alarms = AlarmsEditor(self.event)
         self.pile = NListBox(urwid.SimpleFocusListWalker([
-            NColumns([self.summary, self.calendar_chooser], dividechars=2),
+            self.summary,
+            urwid.Columns([(12, self.calendar_chooser)]),
             divider,
             self.location,
             self.categories,
@@ -371,8 +390,8 @@
             divider,
             self.alarms,
             divider,
-            urwid.Button('Save', on_press=self.save),
-            urwid.Button('Export', on_press=self.export)
+            urwid.Columns([(12, urwid.Button('Save', on_press=self.save))]),
+            urwid.Columns([(12, urwid.Button('Export', on_press=self.export))])
         ]), outermost=True)
         self._always_save = always_save
         urwid.WidgetWrap.__init__(self, self.pile)
@@ -386,7 +405,7 @@
 
     @property
     def title(self):  # Window title
-        return 'Edit: {}'.format(self.summary.get_edit_text())
+        return 'Edit: {}'.format(get_wrapped_text(self.summary))
 
     @classmethod
     def selectable(cls):
@@ -394,13 +413,13 @@
 
     @property
     def changed(self):
-        if self.summary.get_edit_text() != self.event.summary:
+        if get_wrapped_text(self.summary) != self.event.summary:
             return True
-        if self.description.get_edit_text() != self.event.description:
+        if get_wrapped_text(self.description) != self.event.description:
             return True
-        if self.location.get_edit_text() != self.event.location:
+        if get_wrapped_text(self.location) != self.event.location:
             return True
-        if self.categories.get_edit_text() != self.event.categories:
+        if get_wrapped_text(self.categories) != self.event.categories:
             return True
         if self.startendeditor.changed or self.calendar_chooser.changed:
             return True
@@ -411,10 +430,10 @@
         return False
 
     def update_vevent(self):
-        self.event.update_summary(self.summary.get_edit_text())
-        self.event.update_description(self.description.get_edit_text())
-        self.event.update_location(self.location.get_edit_text())
-        self.event.update_categories(self.categories.get_edit_text())
+        self.event.update_summary(get_wrapped_text(self.summary))
+        self.event.update_description(get_wrapped_text(self.description))
+        self.event.update_location(get_wrapped_text(self.location))
+        self.event.update_categories(get_wrapped_text(self.categories).split(','))
 
         if self.startendeditor.changed:
             self.event.update_start_end(
@@ -560,17 +579,18 @@
         self.repetitions_edit = PositiveIntEdit(edit_text=count)
 
         until = self._rrule.get('UNTIL', [None])[0]
-        if until is None and isinstance(self._startdt, datetime):
+        if until is None and isinstance(self._startdt, dt.datetime):
             until = self._startdt.date()
         elif until is None:
             until = self._startdt
 
-        if isinstance(until, datetime):
+        if isinstance(until, dt.datetime):
             until = until.date()
         self.until_edit = DateEdit(
             until, self._conf['locale']['longdateformat'],
             lambda _: None, self._conf['locale']['weeknumbers'],
             self._conf['locale']['firstweekday'],
+            self._conf['view']['monthdisplay'],
         )
 
         self._rebuild_weekday_checks()
@@ -611,8 +631,7 @@
         keys = set(rrule.keys())
         freq = rrule.get('FREQ', [None])[0]
         unsupported_rrule_parts = {
-            'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYYEARDAY',
-            'BYWEEKNO', 'BYMONTH', 'BYSETPOS',
+            'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYYEARDAY', 'BYWEEKNO', 'BYMONTH',
         }
         if keys.intersection(unsupported_rrule_parts):
             return False
@@ -624,6 +643,8 @@
             return False
         if rrule.get('BYDAY', ['1'])[0][0] == '-':
             return False
+        if rrule.get('BYSETPOS', [1])[0] != 1:
+            return False
         if freq not in ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']:
             return False
         if 'BYDAY' in keys and freq == 'YEARLY':
diff -Nru khal-0.9.10/khal/ui/__init__.py khal-0.10.2/khal/ui/__init__.py
--- khal-0.9.10/khal/ui/__init__.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/__init__.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,14 +19,15 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-from datetime import date, datetime, time, timedelta
+import datetime as dt
+import logging
 import signal
 import sys
 
 import click
 import urwid
 
-from .. import utils
+from .. import icalendar as icalendar_helpers, utils
 from ..khalendar.event import Event
 from ..khalendar.exceptions import ReadOnlyCalendarError
 from . import colors
@@ -35,6 +36,7 @@
 from .editor import EventEditor, ExportDialog
 from .calendarwidget import CalendarWidget
 
+logger = logging.getLogger('khal')
 
 #  Overview of how this all meant to fit together:
 #
@@ -128,11 +130,11 @@
 
         weekday = day.strftime('%A')
         daystr = day.strftime(dtformat)
-        if day == date.today():
+        if day == dt.date.today():
             return 'Today ({}, {})'.format(weekday, daystr)
-        elif day == date.today() + timedelta(days=1):
+        elif day == dt.date.today() + dt.timedelta(days=1):
             return 'Tomorrow ({}, {})'.format(weekday, daystr)
-        elif day == date.today() - timedelta(days=1):
+        elif day == dt.date.today() - dt.timedelta(days=1):
             return 'Yesterday ({}, {})'.format(weekday, daystr)
 
         approx_delta = utils.relative_timedelta_str(day)
@@ -164,7 +166,7 @@
         :type event: khal.event.Event
         """
         if relative:
-            if isinstance(this_date, datetime) or not isinstance(this_date, date):
+            if isinstance(this_date, dt.datetime) or not isinstance(this_date, dt.date):
                 raise ValueError('`this_date` is of type `{}`, sould be '
                                  '`datetime.date`'.format(type(this_date)))
         self.event = event
@@ -277,9 +279,9 @@
 
     def render(self, size, focus=False):
         if self._init:
-            while 'bottom' in self.ends_visible(size):
-                self.body._autoextend()
             self._init = False
+        while not isinstance(self.body, StaticDayWalker) and 'bottom' in self.ends_visible(size):
+            self.body._autoextend()
         return super().render(size, focus)
 
     def clean(self):
@@ -304,7 +306,7 @@
             key = 'down'
 
         if key in self._conf['keybindings']['today']:
-            self.parent.calendar.base_widget.set_focus_date(date.today())
+            self.parent.calendar.base_widget.set_focus_date(dt.date.today())
 
         rval = super().keypress(size, key)
         self.clean()
@@ -396,8 +398,8 @@
         :type end: datetime.date
         :type bool: bool
         """
-        start = start.date() if isinstance(start, datetime) else start
-        end = end.date() if isinstance(end, datetime) else end
+        start = start.date() if isinstance(start, dt.datetime) else start
+        end = end.date() if isinstance(end, dt.datetime) else end
 
         if everything:
             start = self[0].date
@@ -417,8 +419,8 @@
         :type start: datetime.date
         :type end: datetime.date
         """
-        start = start.date() if isinstance(start, datetime) else start
-        end = end.date() if isinstance(end, datetime) else end
+        start = start.date() if isinstance(start, dt.datetime) else start
+        end = end.date() if isinstance(end, dt.datetime) else end
 
         if everything:
             start = self[0].date
@@ -430,7 +432,7 @@
         day = start
         while day <= end:
             self.update_events_ondate(day)
-            day += timedelta(days=1)
+            day += dt.timedelta(days=1)
 
     def update_date_line(self):
         for one in self:
@@ -446,7 +448,7 @@
         return super().set_focus(position)
 
     def _autoextend(self):
-        self._last_day += timedelta(days=1)
+        self._last_day += dt.timedelta(days=1)
         pile = self._get_events(self._last_day)
         self.append(pile)
 
@@ -456,7 +458,7 @@
         # render() method does not get called otherwise, and they would
         # be indicated as the currently selected date
         self[self.focus or 0].reset_style()
-        self._first_day -= timedelta(days=1)
+        self._first_day -= dt.timedelta(days=1)
         pile = self._get_events(self._first_day)
         self.insert(0, pile)
 
@@ -505,7 +507,7 @@
         num_days = max(1, self._conf['default']['timedelta'].days)
 
         for delta in range(num_days):
-            pile = self._get_events(day + timedelta(days=delta))
+            pile = self._get_events(day + dt.timedelta(days=delta))
             if len(self) <= delta:
                 self.append(pile)
             else:
@@ -535,8 +537,8 @@
         :type start: datetime.date
         :type end: datetime.date
         """
-        start = start.date() if isinstance(start, datetime) else start
-        end = end.date() if isinstance(end, datetime) else end
+        start = start.date() if isinstance(start, dt.datetime) else start
+        end = end.date() if isinstance(end, dt.datetime) else end
 
         update = everything
         for one in self:
@@ -567,15 +569,15 @@
 
     def render(self, size, focus):
         if focus:
-            self.body[0].set_attr_map({None: 'date focused'})
+            self.body[0].set_attr_map({None: 'date header focused'})
         elif DateListBox.selected_date == self.date:
-            self.body[0].set_attr_map({None: 'date selected'})
+            self.body[0].set_attr_map({None: 'date header selected'})
         else:
             self.reset_style()
         return super().render(size, focus)
 
     def reset_style(self):
-        self.body[0].set_attr_map({None: 'date'})
+        self.body[0].set_attr_map({None: 'date header'})
 
     def set_selected_date(self, day):
         """Mark `day` as selected
@@ -694,11 +696,11 @@
                 ('alert', 'Calendar `{}` is read-only.'.format(event.calendar)))
             return
 
-        if isinstance(event.start_local, datetime):
+        if isinstance(event.start_local, dt.datetime):
             original_start = event.start_local.date()
         else:
             original_start = event.start_local
-        if isinstance(event.end_local, datetime):
+        if isinstance(event.end_local, dt.datetime):
             original_end = event.end_local.date()
         else:
             original_end = event.end_local
@@ -714,9 +716,9 @@
             """
             # TODO cleverer support for recurring events, where more than start and
             # end dates are affected (complicated)
-            if isinstance(new_start, datetime):
+            if isinstance(new_start, dt.datetime):
                 new_start = new_start.date()
-            if isinstance(new_end, datetime):
+            if isinstance(new_end, dt.datetime):
                 new_end = new_end.date()
             start = min(original_start, new_start)
             end = max(original_end, new_end)
@@ -731,9 +733,9 @@
 
         assert not self.editor
         if external_edit:
-            self.pane.window.loop.screen.stop()
+            self.pane.window.loop.stop()
             text = click.edit(event.raw)
-            self.pane.window.loop.screen.start()
+            self.pane.window.loop.start()
             if text is None:
                 return
             # KeyErrors can occurr here when we destroy DTSTART,
@@ -759,7 +761,7 @@
             new_pane = urwid.Columns([
                 ('weight', 2, ContainerWidget(editor)),
                 ('weight', 1, ContainerWidget(self.dlistbox))
-            ], dividechars=0, focus_column=0)
+            ], dividechars=2, focus_column=0)
             new_pane.title = editor.title
 
             def teardown(data):
@@ -848,9 +850,9 @@
                 self.pane.collection.writable_names[0]
             self.edit(event, always_save=True)
         start_date, end_date = event.start_local, event.end_local
-        if isinstance(start_date, datetime):
+        if isinstance(start_date, dt.datetime):
             start_date = start_date.date()
-        if isinstance(end_date, datetime):
+        if isinstance(end_date, dt.datetime):
             end_date = end_date.date()
         self.pane.eventscolumn.base_widget.update(start_date, end_date, event.recurring)
         try:
@@ -870,16 +872,16 @@
             self.pane.window.alert(('alert', 'No writable calendar.'))
             return
         if end is None:
-            start = datetime.combine(date, time(datetime.now().hour))
-            end = start + timedelta(minutes=60)
-            event = utils.new_event(
-                dtstart=start, dtend=end, summary="new event",
+            start = dt.datetime.combine(date, dt.time(dt.datetime.now().hour))
+            end = start + dt.timedelta(minutes=60)
+            event = icalendar_helpers.new_event(
+                dtstart=start, dtend=end, summary='',
                 timezone=self._conf['locale']['default_timezone'],
                 locale=self._conf['locale'],
             )
         else:
-            event = utils.new_event(
-                dtstart=date, dtend=end + timedelta(days=1), summary="new event",
+            event = icalendar_helpers.new_event(
+                dtstart=date, dtend=end + dt.timedelta(days=1), summary='',
                 allday=True, locale=self._conf['locale'],
             )
         event = self.pane.collection.new_event(
@@ -1013,7 +1015,6 @@
 
 
 class ClassicView(Pane):
-
     """default Pane for khal
 
     showing a CalendarWalker on the left and the eventList + eventviewer/editor
@@ -1034,8 +1035,8 @@
         else:
             Walker = StaticDayWalker
         daywalker = Walker(
-            date.today(), eventcolumn=self, conf=self._conf, delete_status=self.delete_status,
-            collection=self.collection,
+            dt.date.today(), eventcolumn=self, conf=self._conf,
+            delete_status=self.delete_status, collection=self.collection,
         )
         elistbox = DListBox(
             daywalker, parent=self, conf=self._conf,
@@ -1051,6 +1052,7 @@
             on_press={key: self.new_event for key in self._conf['keybindings']['new']},
             firstweekday=self._conf['locale']['firstweekday'],
             weeknumbers=self._conf['locale']['weeknumbers'],
+            monthdisplay=self._conf['view']['monthdisplay'],
             get_styles=collection.get_styles
         )
         if self._conf['view']['dynamic_days']:
@@ -1059,6 +1061,8 @@
             elistbox.set_focus_date_callback = lambda _: None
         self.calendar = ContainerWidget(calendar)
         self.lwidth = 31 if self._conf['locale']['weeknumbers'] == 'right' else 28
+        if self._conf['view']['frame'] in ["width", "color"]:
+            self.lwidth += 2
         columns = NColumns(
             [(self.lwidth, self.calendar), self.eventscolumn],
             dividechars=0,
@@ -1118,7 +1122,7 @@
     def _search(self, search_term):
         """search for events matching `search_term"""
         self.window.backtrack()
-        events = list(self.collection.search(search_term))
+        events = sorted(self.collection.search(search_term))
         event_list = []
         event_list.extend([
             urwid.AttrMap(
@@ -1151,7 +1155,7 @@
         rval = super(ClassicView, self).render(size, focus)
         if self.init:
             # starting with today's events
-            self.eventscolumn.current_date = date.today()
+            self.eventscolumn.current_date = dt.date.today()
             self.init = False
         return rval
 
@@ -1255,10 +1259,54 @@
 
 def start_pane(pane, callback, program_info='', quit_keys=['q']):
     """Open the user interface with the given initial pane."""
+
     frame = Window(
         footer=program_info + ' | {}: quit, ?: help'.format(quit_keys[0]),
         quit_keys=quit_keys,
     )
+
+    class LogPaneFormatter(logging.Formatter):
+        def get_prefix(self, level):
+            if level >= 50:
+                return 'CRITICAL'
+            if level >= 40:
+                return 'ERROR'
+            if level >= 30:
+                return 'WARNING'
+            if level >= 20:
+                return 'INFO'
+            else:
+                return 'DEBUG'
+
+        def format(self, record):
+            return (self.get_prefix(record.levelno) + ': ' + record.msg)
+
+    class HeaderFormatter(LogPaneFormatter):
+        def format(self, record):
+            return (
+                super().format(record)[:30] + '... '
+                '[Press `{}` to view log]'.format(pane._conf['keybindings']['log'][0])
+            )
+
+    class LogPaneHandler(logging.Handler):
+        def emit(self, record):
+            frame.log(self.format(record))
+
+    class LogHeaderHandler(logging.Handler):
+        def emit(self, record):
+            frame.alert(self.format(record))
+
+    if len(logger.handlers) > 0 and not isinstance(logger.handlers[-1], logging.FileHandler):
+        logger.handlers.pop()
+
+    pane_handler = LogPaneHandler()
+    pane_handler.setFormatter(LogPaneFormatter())
+    logger.addHandler(pane_handler)
+
+    header_handler = LogHeaderHandler()
+    header_handler.setFormatter(HeaderFormatter())
+    logger.addHandler(header_handler)
+
     frame.open(pane, callback)
     palette = _add_calendar_colors(
         getattr(colors, pane._conf['view']['theme']), pane.collection)
@@ -1270,10 +1318,10 @@
         # XXX TODO this currently assumes, today moves forward by exactly one
         # day, but it could either move forward more (suspend-to-disk/ram) or
         # even move backwards
-        today = date.today()
+        today = dt.date.today()
         if meta['last_today'] != today:
             meta['last_today'] = today
-            pane.calendar.original_widget.reset_styles_range(today - timedelta(days=1), today)
+            pane.calendar.original_widget.reset_styles_range(today - dt.timedelta(days=1), today)
             pane.eventscolumn.original_widget.update_date_line()
         loop.set_alarm_in(60, redraw_today, pane)
 
diff -Nru khal-0.9.10/khal/ui/widgets.py khal-0.10.2/khal/ui/widgets.py
--- khal-0.9.10/khal/ui/widgets.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/ui/widgets.py	2020-07-29 18:17:53.000000000 +0200
@@ -24,7 +24,7 @@
 Widgets that are specific to calendaring/khal should go into __init__.py or,
 if they are large, into their own files
 """
-from datetime import date, datetime, timedelta
+import datetime as dt
 import re
 
 import urwid
@@ -171,12 +171,12 @@
 
 
 class DateWidget(DateTimeWidget):
-    dtype = date
-    timedelta = timedelta(days=1)
+    dtype = dt.date
+    timedelta = dt.timedelta(days=1)
 
     def _get_current_value(self):
         try:
-            new_date = datetime.strptime(self.get_edit_text(), self.dateformat).date()
+            new_date = dt.datetime.strptime(self.get_edit_text(), self.dateformat).date()
         except ValueError:
             raise DateConversionError
         else:
@@ -184,12 +184,12 @@
 
 
 class TimeWidget(DateTimeWidget):
-    dtype = datetime
-    timedelta = timedelta(minutes=15)
+    dtype = dt.datetime
+    timedelta = dt.timedelta(minutes=15)
 
     def _get_current_value(self):
         try:
-            new_datetime = datetime.strptime(self.get_edit_text(), self.dateformat)
+            new_datetime = dt.datetime.strptime(self.get_edit_text(), self.dateformat)
         except ValueError:
             raise DateConversionError
         else:
@@ -495,7 +495,7 @@
         urwid.WidgetWrap.__init__(self, self.columns)
 
     def get_timedelta(self):
-        return timedelta(
+        return dt.timedelta(
             seconds=int(self.seconds_edit.get_edit_text()) +
             int(self.minutes_edit.get_edit_text()) * 60 +
             int(self.hours_edit.get_edit_text()) * 60 * 60 +
@@ -549,7 +549,7 @@
     def add_alarm(self, button):
         self.pile.contents.insert(
             len(self.pile.contents) - 1,
-            (self.AlarmEditor((timedelta(0), self.event.summary), self.remove_alarm),
+            (self.AlarmEditor((dt.timedelta(0), self.event.summary), self.remove_alarm),
              ('weight', 1)))
 
     def remove_alarm(self, button, editor):
diff -Nru khal-0.9.10/khal/utils.py khal-0.10.2/khal/utils.py
--- khal-0.9.10/khal/utils.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal/utils.py	2020-07-29 18:17:53.000000000 +0200
@@ -19,424 +19,17 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-"""this module contains some helper functions converting strings or list of
-strings to date(time) or event objects"""
+"""collection of utility functions"""
 
-from calendar import isleap, month_abbr
-from collections import defaultdict
-from datetime import date, datetime, timedelta, time
+
+import datetime as dt
+import pytz
 import random
-import string
 import re
-from time import strptime
+import string
+from calendar import month_abbr, timegm
 from textwrap import wrap
 
-import icalendar
-import pytz
-
-from khal.log import logger
-from khal.exceptions import FatalError
-from .khalendar.utils import sanitize
-
-
-def timefstr(dtime_list, timeformat):
-    """converts the first item of a list (a time as a string) to a datetimeobject
-
-    where the date is today and the time is given by a string
-    removes "used" elements of list
-
-    :type dtime_list: list(str)
-    :type timeformat: str
-    :rtype: datetime.datetime
-    """
-    if len(dtime_list) == 0:
-        raise ValueError()
-    time_start = datetime.strptime(dtime_list[0], timeformat)
-    time_start = time(*time_start.timetuple()[3:5])
-    day_start = date.today()
-    dtstart = datetime.combine(day_start, time_start)
-    dtime_list.pop(0)
-    return dtstart
-
-
-def datetimefstr(dtime_list, dtformat):
-    """
-    converts a datetime (as one or several string elements of a list) to
-    a datetimeobject
-
-    removes "used" elements of list
-
-    :returns: a datetime
-    :rtype: datetime.datetime
-    """
-    parts = dtformat.count(' ') + 1
-    dtstring = ' '.join(dtime_list[0:parts])
-    dtstart = datetime.strptime(dtstring, dtformat)
-    for _ in range(parts):
-        dtime_list.pop(0)
-    return dtstart
-
-
-def weekdaypstr(dayname):
-    """converts an (abbreviated) dayname to a number (mon=0, sun=6)
-
-    :param dayname: name of abbreviation of the day
-    :type dayname: str
-    :return: number of the day in a week
-    :rtype: int
-    """
-
-    if dayname in ['monday', 'mon']:
-        return 0
-    if dayname in ['tuesday', 'tue']:
-        return 1
-    if dayname in ['wednesday', 'wed']:
-        return 2
-    if dayname in ['thursday', 'thu']:
-        return 3
-    if dayname in ['friday', 'fri']:
-        return 4
-    if dayname in ['saturday', 'sat']:
-        return 5
-    if dayname in ['sunday', 'sun']:
-        return 6
-    raise ValueError('invalid weekday name `%s`' % dayname)
-
-
-def construct_daynames(date_):
-    """converts datetime.date into a string description
-
-    either `Today`, `Tomorrow` or name of weekday.
-    """
-    if date_ == date.today():
-        return 'Today'
-    elif date_ == date.today() + timedelta(days=1):
-        return 'Tomorrow'
-    else:
-        return date_.strftime('%A')
-
-
-def relative_timedelta_str(day):
-    """Converts the timespan from `day` to today into a human readable string.
-
-    :type day: datetime.date
-    :rtype: str
-    """
-    days = (day - date.today()).days
-    if days < 0:
-        direction = 'ago'
-    else:
-        direction = 'from now'
-    approx = ''
-    if abs(days) < 7:
-        unit = 'day'
-        count = abs(days)
-    elif abs(days) < 365:
-        unit = 'week'
-        count = int(abs(days) / 7)
-        if abs(days) % 7 != 0:
-            approx = '~'
-    else:
-        unit = 'year'
-        count = int(abs(days) / 365)
-        if abs(days) % 365 != 0:
-            approx = '~'
-    if count > 1:
-        unit += 's'
-
-    return '{approx}{count} {unit} {direction}'.format(
-        approx=approx,
-        count=count,
-        unit=unit,
-        direction=direction,
-    )
-
-
-def calc_day(dayname):
-    """converts a relative date's description to a datetime object
-
-    :param dayname: relative day name (like 'today' or 'monday')
-    :type dayname: str
-    :returns: date
-    :rtype: datetime.datetime
-    """
-    today = datetime.combine(date.today(), time.min)
-    dayname = dayname.lower()
-    if dayname == 'today':
-        return today
-    if dayname == 'tomorrow':
-        return today + timedelta(days=1)
-    if dayname == 'yesterday':
-        return today - timedelta(days=1)
-
-    wday = weekdaypstr(dayname)
-    days = (wday - today.weekday()) % 7
-    days = 7 if days == 0 else days
-    day = today + timedelta(days=days)
-    return day
-
-
-def datefstr_weekday(dtime_list, _):
-    """interprets first element of a list as a relative date and removes that
-    element
-
-    :param dtime_list: event description in list form
-    :type dtime_list: list
-    :returns: date
-    :rtype: datetime.datetime
-
-    """
-    if len(dtime_list) == 0:
-        raise ValueError()
-    day = calc_day(dtime_list[0])
-    dtime_list.pop(0)
-    return day
-
-
-def datetimefstr_weekday(dtime_list, timeformat):
-    if len(dtime_list) == 0:
-        raise ValueError()
-    day = calc_day(dtime_list[0])
-    this_time = timefstr(dtime_list[1:], timeformat)
-    dtime_list.pop(0)
-    dtime_list.pop(0)  # we need to pop twice as timefstr gets a copy
-    dtime = datetime.combine(day, this_time.time())
-    return dtime
-
-
-def guessdatetimefstr(dtime_list, locale, default_day=None):
-    """
-    :type dtime_list: list
-    :type locale: dict
-    :type default_day: datetime.datetime
-    :rtype: datetime.datetime
-    """
-    # if now() is called as default param, mocking with freezegun won't work
-    if default_day is None:
-        default_day = datetime.now().date()
-    # TODO rename in guessdatetimefstrLIST or something saner altogether
-
-    def timefstr_day(dtime_list, timeformat):
-        if locale['timeformat'] == '%H:%M' and dtime_list[0] == '24:00':
-            a_date = datetime.combine(default_day, time(0))
-            dtime_list.pop(0)
-        else:
-            a_date = timefstr(dtime_list, timeformat)
-            a_date = datetime(*(default_day.timetuple()[:3] + a_date.timetuple()[3:5]))
-        return a_date
-
-    def datetimefwords(dtime_list, _):
-        if len(dtime_list) > 0 and dtime_list[0].lower() == 'now':
-            dtime_list.pop(0)
-            return datetime.now()
-        raise ValueError
-
-    def datefstr_year(dtime_list, dateformat):
-        """should be used if a date(time) without year is given
-
-        we cannot use datetimefstr() here, because only time.strptime can
-        parse the 29th of Feb. if no year is given
-
-        example: dtime_list = ['17.03.', 'description']
-                 dateformat = '%d.%m.'
-        or     : dtime_list = ['17.03.', '16:00', 'description']
-                 dateformat = '%d.%m. %H:%M'
-        """
-        parts = dateformat.count(' ') + 1
-        dtstring = ' '.join(dtime_list[0:parts])
-        dtstart = strptime(dtstring, dateformat)
-        if dtstart.tm_mon == 2 and dtstart.tm_mday == 29 and not isleap(default_day.year):
-            raise ValueError
-
-        for _ in range(parts):
-            dtime_list.pop(0)
-
-        a_date = datetime(*(default_day.timetuple()[:1] + dtstart[1:5]))
-        return a_date
-
-    dtstart = None
-    for fun, dtformat, all_day, shortformat in [
-            (datefstr_year, locale['datetimeformat'], False, True),
-            (datetimefstr, locale['longdatetimeformat'], False, False),
-            (timefstr_day, locale['timeformat'], False, False),
-            (datetimefstr_weekday, locale['timeformat'], False, False),
-            (datefstr_year, locale['dateformat'], True, True),
-            (datetimefstr, locale['longdateformat'], True, False),
-            (datefstr_weekday, None, True, False),
-            (datetimefwords, None, False, False),
-    ]:
-        if shortformat and '97' in datetime(1997, 10, 11).strftime(dtformat):
-            continue
-        try:
-            dtstart = fun(dtime_list, dtformat)
-        except ValueError:
-            pass
-        else:
-            return dtstart, all_day
-    raise ValueError()
-
-
-def timedelta2str(delta):
-    # we deliberately ignore any subsecond deltas
-    total_seconds = int(abs(delta).total_seconds())
-
-    seconds = total_seconds % 60
-    total_seconds -= seconds
-    total_minutes = total_seconds // 60
-    minutes = total_minutes % 60
-    total_minutes -= minutes
-    total_hours = total_minutes // 60
-    hours = total_hours % 24
-    total_hours -= hours
-    days = total_hours // 24
-
-    s = []
-    if days:
-        s.append(str(days) + "d")
-    if hours:
-        s.append(str(hours) + "h")
-    if minutes:
-        s.append(str(minutes) + "m")
-    if seconds:
-        s.append(str(seconds) + "s")
-
-    if delta != abs(delta):
-        s = ["-" + part for part in s]
-
-    return ' '.join(s)
-
-
-def guesstimedeltafstr(delta_string):
-    """parses a timedelta from a string
-
-    :param delta_string: string encoding time-delta, e.g. '1h 15m'
-    :type delta_string: str
-    :rtype: datetime.timedelta
-    """
-
-    tups = re.split(r'(-?\d+)', delta_string)
-    if not re.match(r'^\s*$', tups[0]):
-        raise ValueError('Invalid beginning of timedelta string "%s": "%s"'
-                         % (delta_string, tups[0]))
-    tups = tups[1:]
-    res = timedelta()
-
-    for num, unit in zip(tups[0::2], tups[1::2]):
-        try:
-            numint = int(num)
-        except ValueError:
-            raise ValueError('Invalid number in timedelta string "%s": "%s"'
-                             % (delta_string, num))
-
-        ulower = unit.lower().strip()
-        if ulower == 'd' or ulower == 'day' or ulower == 'days':
-            res += timedelta(days=numint)
-        elif ulower == 'h' or ulower == 'hour' or ulower == 'hours':
-            res += timedelta(hours=numint)
-        elif (ulower == 'm' or ulower == 'minute' or ulower == 'minutes' or
-              ulower == 'min'):
-            res += timedelta(minutes=numint)
-        elif (ulower == 's' or ulower == 'second' or ulower == 'seconds' or
-              ulower == 'sec'):
-            res += timedelta(seconds=numint)
-        else:
-            raise ValueError('Invalid unit in timedelta string "%s": "%s"'
-                             % (delta_string, unit))
-
-    return res
-
-
-def guessrangefstr(daterange, locale, adjust_reasonably=False,
-                   default_timedelta_date=timedelta(days=1),
-                   default_timedelta_datetime=timedelta(hours=1),
-                   ):
-    """parses a range string
-
-    :param daterange: date1 [date2 | timedelta]
-    :type daterange: str or list
-    :param locale:
-    :returns: start and end of the date(time) range  and if
-        this is an all-day time range or not,
-        **NOTE**: the end is *exclusive* if this is an allday event
-    :rtype: (datetime, datetime, bool)
-
-    """
-    range_list = daterange
-    if isinstance(daterange, str):
-        range_list = daterange.split(' ')
-
-    if range_list == ['week']:
-        today_weekday = datetime.today().weekday()
-        start = datetime.today() - timedelta(days=(today_weekday - locale['firstweekday']))
-        end = start + timedelta(days=8)
-        return start, end, True
-
-    for i in reversed(range(1, len(range_list) + 1)):
-        start = ' '.join(range_list[:i])
-        end = ' '.join(range_list[i:])
-        allday = False
-        try:
-            # figuring out start
-            split = start.split(" ")
-            start, allday = guessdatetimefstr(split, locale)
-            if len(split) != 0:
-                continue
-
-            # and end
-            if len(end) == 0:
-                if allday:
-                    end = start + default_timedelta_date
-                else:
-                    end = start + default_timedelta_datetime
-            elif end.lower() == 'eod':
-                    end = datetime.combine(start.date(), time.max)
-            elif end.lower() == 'week':
-                start -= timedelta(days=(start.weekday() - locale['firstweekday']))
-                end = start + timedelta(days=8)
-            else:
-                try:
-                    delta = guesstimedeltafstr(end)
-                    if allday and delta.total_seconds() % (3600 * 24):
-                        # TODO better error class, no logging in here
-                        logger.fatal(
-                            "Cannot give delta containing anything but whole days for allday events"
-                        )
-                        raise FatalError()
-                    elif delta.total_seconds() == 0:
-                        logger.fatal(
-                            "Events that last no time are not allowed"
-                        )
-                        raise FatalError()
-
-                    end = start + delta
-                except ValueError:
-                    split = end.split(" ")
-                    end, end_allday = guessdatetimefstr(split, locale, default_day=start.date())
-                    if len(split) != 0:
-                        continue
-                    if allday:
-                        end += timedelta(days=1)
-
-            if adjust_reasonably:
-                if allday:
-                    # test if end's year is this year, but start's year is not
-                    today = datetime.today()
-                    if end.year == today.year and start.year != today.year:
-                        end = datetime(start.year, *end.timetuple()[1:6])
-
-                    if end < start:
-                        end = datetime(end.year + 1, *end.timetuple()[1:6])
-
-                if end < start:
-                    end = datetime(*start.timetuple()[0:3] + end.timetuple()[3:5])
-                if end < start:
-                    end = end + timedelta(days=1)
-            return start, end, allday
-        except ValueError:
-            pass
-
-    raise ValueError('Could not parse `{}` as a daterange'.format(daterange))
-
 
 def generate_random_uid():
     """generate a random uid
@@ -447,246 +40,6 @@
     return ''.join([random.choice(choice) for _ in range(36)])
 
 
-def rrulefstr(repeat, until, locale):
-    if repeat in ["daily", "weekly", "monthly", "yearly"]:
-        rrule_settings = {'freq': repeat}
-        if until:
-            until_date = None
-            for fun, dformat in [(datetimefstr, locale['datetimeformat']),
-                                 (datetimefstr, locale['longdatetimeformat']),
-                                 (timefstr, locale['timeformat']),
-                                 (datetimefstr, locale['dateformat']),
-                                 (datetimefstr, locale['longdateformat'])]:
-                try:
-                    until_date = fun(until.split(' '), dformat)
-                    break
-                except ValueError:
-                    pass
-            if until_date is None:
-                logger.fatal("Cannot parse until date: '{}'\nPlease have a look "
-                             "at the documentation.".format(until))
-                raise FatalError()
-            rrule_settings['until'] = until_date
-
-        return rrule_settings
-    else:
-        logger.fatal("Invalid value for the repeat option. \
-                Possible values are: daily, weekly, monthly or yearly")
-        raise FatalError()
-
-
-def eventinfofstr(info_string, locale, adjust_reasonably=False, localize=False):
-    """parses a string of the form START [END | DELTA] [TIMEZONE] [SUMMARY] [::
-    DESCRIPTION] into a dictionary with keys: dtstart, dtend, timezone, allday,
-    summary, description
-
-    :param info_string:
-    :type info_string: string fitting the form
-    :param locale:
-    :type locale: locale
-    :param adjust_reasonably:
-    :type adjust_reasonably: passed on to guessrangefstr
-    :rtype: dictionary
-
-    """
-    description = None
-    if " :: " in info_string:
-        info_string, description = info_string.split(' :: ')
-
-    parts = info_string.split(' ')
-    summary = None
-    start = None
-    end = None
-    tz = None
-    allday = False
-    for i in reversed(range(1, len(parts) + 1)):
-        try:
-            start, end, allday = guessrangefstr(
-                ' '.join(parts[0:i]), locale,
-                adjust_reasonably=adjust_reasonably,
-            )
-        except ValueError:
-            continue
-        if start is not None and end is not None:
-            try:
-                # next element is a valid Olson db timezone string
-                tz = pytz.timezone(parts[i])
-                i += 1
-            except (pytz.UnknownTimeZoneError, UnicodeDecodeError, IndexError):
-                tz = None
-            summary = ' '.join(parts[i:])
-            break
-
-    if start is None or end is None:
-        raise ValueError('Could not parse `{}`'.format(info_string))
-
-    if tz is None:
-        tz = locale['default_timezone']
-
-    if allday:
-        start = start.date()
-        end = end.date()
-
-    info = {}
-    info["dtstart"] = start
-    info["dtend"] = end
-    info["summary"] = summary if summary else None
-    info["description"] = description
-    info["timezone"] = tz if not allday else None
-    info["allday"] = allday
-    return info
-
-
-def new_event(locale, dtstart=None, dtend=None, summary=None, timezone=None,
-              allday=False, description=None, location=None, categories=None,
-              repeat=None, until=None, alarms=None):
-    """create a new event
-
-    :param dtstart: starttime of that event
-    :type dtstart: datetime
-    :param dtend: end time of that event, if this is a *date*, this value is
-        interpreted as being the last date the event is scheduled on, i.e.
-        the VEVENT DTEND will be *one day later*
-    :type dtend: datetime
-    :param summary: description of the event, used in the SUMMARY property
-    :type summary: unicode
-    :param timezone: timezone of the event (start and end)
-    :type timezone: pytz.timezone
-    :param allday: if set to True, we will not transform dtstart and dtend to
-        datetime
-    :type allday: bool
-    :returns: event
-    :rtype: icalendar.Event
-    """
-
-    if dtstart is None:
-        raise ValueError("no start given")
-    if dtend is None:
-        raise ValueError("no end given")
-    if summary is None:
-        raise ValueError("no summary given")
-
-    if not allday and timezone is not None:
-        dtstart = timezone.localize(dtstart)
-        dtend = timezone.localize(dtend)
-
-    event = icalendar.Event()
-    event.add('dtstart', dtstart)
-    event.add('dtend', dtend)
-    event.add('dtstamp', datetime.now())
-    event.add('summary', summary)
-    event.add('uid', generate_random_uid())
-    # event.add('sequence', 0)
-
-    if description:
-        event.add('description', description)
-    if location:
-        event.add('location', location)
-    if categories:
-        event.add('categories', categories)
-    if repeat and repeat != "none":
-        rrule = rrulefstr(repeat, until, locale)
-        event.add('rrule', rrule)
-    if alarms:
-        for alarm in alarms.split(","):
-            alarm = alarm.strip()
-            alarm_trig = -1 * guesstimedeltafstr(alarm)
-            new_alarm = icalendar.Alarm()
-            new_alarm.add('ACTION', 'DISPLAY')
-            new_alarm.add('TRIGGER', alarm_trig)
-            new_alarm.add('DESCRIPTION', description)
-            event.add_component(new_alarm)
-    return event
-
-
-def split_ics(ics, random_uid=False, default_timezone=None):
-    """split an ics string into several according to VEVENT's UIDs
-
-    and sort the right VTIMEZONEs accordingly
-    ignores all other ics components
-    :type ics: str
-    :param random_uid: assign random uids to all events
-    :type random_uid: bool
-    :rtype list:
-    """
-    cal = icalendar.Calendar.from_ical(ics)
-    tzs = {item['TZID']: item for item in cal.walk() if item.name == 'VTIMEZONE'}
-
-    events_grouped = defaultdict(list)
-    for item in cal.walk():
-        if item.name == 'VEVENT':
-            events_grouped[item['UID']].append(item)
-        else:
-            continue
-    return [ics_from_list(events, tzs, random_uid) for uid, events in
-            sorted(events_grouped.items())]
-
-
-def ics_from_list(events, tzs, random_uid=False, default_timezone=None):
-    """convert an iterable of icalendar.Events to an icalendar.Calendar
-
-    :params events: list of events all with the same uid
-    :type events: list(icalendar.cal.Event)
-    :param random_uid: assign random uids to all events
-    :type random_uid: bool
-    :param tzs: collection of timezones
-    :type tzs: dict(icalendar.cal.Vtimzone
-    """
-    calendar = icalendar.Calendar()
-    calendar.add('version', '2.0')
-    calendar.add(
-        'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN'
-    )
-
-    if random_uid:
-        new_uid = generate_random_uid()
-
-    needed_tz, missing_tz = set(), set()
-    for sub_event in events:
-        sub_event = sanitize(sub_event, default_timezone=default_timezone)
-        if random_uid:
-            sub_event['UID'] = new_uid
-        # icalendar round-trip converts `TZID=a b` to `TZID="a b"` investigate, file bug XXX
-        for prop in ['DTSTART', 'DTEND', 'DUE', 'EXDATE', 'RDATE', 'RECURRENCE-ID', 'DUE']:
-            if isinstance(sub_event.get(prop), list):
-                items = sub_event.get(prop)
-            else:
-                items = [sub_event.get(prop)]
-
-            for item in items:
-                if not (hasattr(item, 'dt') or hasattr(item, 'dts')):
-                    continue
-                # if prop is a list, all items have the same parameters
-                datetime_ = item.dts[0].dt if hasattr(item, 'dts') else item.dt
-
-                if not hasattr(datetime_, 'tzinfo'):
-                    continue
-
-                # check for datetimes' timezones which are not understood by
-                # icalendar
-                if datetime_.tzinfo is None and 'TZID' in item.params and \
-                        item.params['TZID'] not in missing_tz:
-                    logger.warning(
-                        'Cannot find timezone `{}` in .ics file, using default timezone. '
-                        'This can lead to erroneous time shifts'.format(item.params['TZID'])
-                    )
-                    missing_tz.add(item.params['TZID'])
-                elif datetime_.tzinfo and datetime_.tzinfo != pytz.UTC and \
-                        datetime_.tzinfo not in needed_tz:
-                    needed_tz.add(datetime_.tzinfo)
-
-    for tzid in needed_tz:
-        if str(tzid) in tzs:
-            calendar.add_component(tzs[str(tzid)])
-        else:
-            logger.warning(
-                'Cannot find timezone `{}` in .ics file, this could be a bug, '
-                'please report this issue at http://github.com/pimutils/khal/.'.format(tzid))
-    for sub_event in events:
-        calendar.add_component(sub_event)
-    return calendar.to_ical().decode('utf-8')
-
-
 RESET = '\x1b[0m'
 
 ansi_reset = re.compile(r'\x1b\[0m')
@@ -757,3 +110,80 @@
     abbreviated name. It depends on the locale.
     """
     return max(len(month_abbr[i]) for i in range(1, 13)) + 1
+
+
+def localize_strip_tz(dates, timezone):
+    """converts a list of dates to timezone, than removes tz info"""
+    for one_date in dates:
+        if getattr(one_date, 'tzinfo', None) is not None:
+            one_date = one_date.astimezone(timezone)
+            one_date = one_date.replace(tzinfo=None)
+        yield one_date
+
+
+def to_unix_time(dtime: dt.datetime) -> float:
+    """convert a datetime object to unix time in UTC (as a float)"""
+    if getattr(dtime, 'tzinfo', None) is not None:
+        dtime = dtime.astimezone(pytz.UTC)
+    unix_time = timegm(dtime.timetuple())
+    return unix_time
+
+
+def to_naive_utc(dtime):
+    """convert a datetime object to UTC and than remove the tzinfo, if
+    datetime is naive already, return it
+    """
+    if not hasattr(dtime, 'tzinfo') or dtime.tzinfo is None:
+        return dtime
+
+    dtime_utc = dtime.astimezone(pytz.UTC)
+    dtime_naive = dtime_utc.replace(tzinfo=None)
+    return dtime_naive
+
+
+def is_aware(dtime):
+    """test if a datetime instance is timezone aware"""
+    if dtime.tzinfo is not None and dtime.tzinfo.utcoffset(dtime) is not None:
+        return True
+    else:
+        return False
+
+
+def relative_timedelta_str(day):
+    """Converts the timespan from `day` to today into a human readable string.
+
+    :type day: datetime.date
+    :rtype: str
+    """
+    days = (day - dt.date.today()).days
+    if days < 0:
+        direction = 'ago'
+    else:
+        direction = 'from now'
+    approx = ''
+    if abs(days) < 7:
+        unit = 'day'
+        count = abs(days)
+    elif abs(days) < 365:
+        unit = 'week'
+        count = int(abs(days) / 7)
+        if abs(days) % 7 != 0:
+            approx = '~'
+    else:
+        unit = 'year'
+        count = int(abs(days) / 365)
+        if abs(days) % 365 != 0:
+            approx = '~'
+    if count > 1:
+        unit += 's'
+
+    return '{approx}{count} {unit} {direction}'.format(
+        approx=approx,
+        count=count,
+        unit=unit,
+        direction=direction,
+    )
+
+
+def get_wrapped_text(widget):
+    return widget.original_widget.get_edit_text()
diff -Nru khal-0.9.10/khal/version.py khal-0.10.2/khal/version.py
--- khal-0.9.10/khal/version.py	2018-10-09 18:05:14.000000000 +0200
+++ khal-0.10.2/khal/version.py	2020-07-29 18:32:59.000000000 +0200
@@ -1,4 +1,4 @@
 # coding: utf-8
 # file generated by setuptools_scm
 # don't change, don't track in version control
-version = '0.9.10'
+version = '0.10.2'
diff -Nru khal-0.9.10/khal.conf.sample khal-0.10.2/khal.conf.sample
--- khal-0.9.10/khal.conf.sample	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/khal.conf.sample	2020-07-29 18:17:53.000000000 +0200
@@ -22,9 +22,9 @@
 longdatetimeformat = %d.%m.%Y %H:%M
 
 firstweekday = 0
+monthdisplay = firstday
 
 [default]
-default_command = calendar
 default_calendar = home
 timedelta = 2 # the default timedelta that list uses
 highlight_event_days = True  # the default is False
diff -Nru khal-0.9.10/khal.egg-info/PKG-INFO khal-0.10.2/khal.egg-info/PKG-INFO
--- khal-0.9.10/khal.egg-info/PKG-INFO	2018-10-09 18:05:14.000000000 +0200
+++ khal-0.10.2/khal.egg-info/PKG-INFO	2020-07-29 18:32:59.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: khal
-Version: 0.9.10
+Version: 0.10.2
 Summary: A standards based terminal calendar
 Home-page: http://lostpackets.de/khal/
 Author: Christian Geier et. al.
@@ -8,8 +8,8 @@
 License: Expat/MIT
 Description: khal
         ====
-        .. image:: https://travis-ci.org/pimutils/khal.svg?branch=master
-            :target: https://travis-ci.org/pimutils/khal
+        .. image:: https://travis-ci.com/pimutils/khal.svg?branch=master
+            :target: https://travis-ci.com/pimutils/khal
         
         .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master
           :target: https://codecov.io/github/pimutils/khal?branch=master
@@ -29,7 +29,7 @@
         - fast and easy way to add new events
         - ikhal (interactive khal) lets you browse and edit calendars and events
         - no support for editing the timezones of events yet
-        - works with python 3.3+
+        - works with python 3.4+
         - khal should run on all major operating systems [1]_
         
         .. [1] except for Microsoft Windows
@@ -72,13 +72,7 @@
         
         The most appreciated way of contributing is by supplying code or documentation,
         reporting bugs, creating packages for your favorite operating system, making
-        khal better known by telling your friends about it, etc. If you don't have
-        the time or the means to contribute in any of the above mentioned ways,
-        donations are appreciated, too.
-        
-        .. image:: https://api.flattr.com/button/flattr-badge-large.png
-           :alt: flattr button
-           :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/
+        khal better known by telling your friends about it, etc.
         
         License
         -------
@@ -109,10 +103,10 @@
 Classifier: Environment :: Console :: Curses
 Classifier: Intended Audience :: End Users/Desktop
 Classifier: Operating System :: POSIX
-Classifier: Programming Language :: Python :: 3.3
 Classifier: Programming Language :: Python :: 3.4
 Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3 :: Only
 Classifier: Topic :: Utilities
 Classifier: Topic :: Communications
diff -Nru khal-0.9.10/khal.egg-info/requires.txt khal-0.10.2/khal.egg-info/requires.txt
--- khal-0.9.10/khal.egg-info/requires.txt	2018-10-09 18:05:14.000000000 +0200
+++ khal-0.10.2/khal.egg-info/requires.txt	2020-07-29 18:32:59.000000000 +0200
@@ -1,6 +1,7 @@
 click>=3.2
-icalendar
-urwid
+click_log>=0.2.0
+icalendar>=4.0.3
+urwid>=1.3.0
 pyxdg
 pytz
 python-dateutil
@@ -8,5 +9,8 @@
 atomicwrites>=0.1.7
 tzlocal>=1.0
 
+[:python_version < "3.5"]
+typing
+
 [proctitle]
 setproctitle
diff -Nru khal-0.9.10/khal.egg-info/SOURCES.txt khal-0.10.2/khal.egg-info/SOURCES.txt
--- khal-0.9.10/khal.egg-info/SOURCES.txt	2018-10-09 18:05:14.000000000 +0200
+++ khal-0.10.2/khal.egg-info/SOURCES.txt	2020-07-29 18:32:59.000000000 +0200
@@ -1,5 +1,6 @@
 .coveragerc
 .gitignore
+.readthedocs.yml
 .travis.yml
 AUTHORS.txt
 CHANGELOG.rst
@@ -18,7 +19,6 @@
 doc/requirements.txt
 doc/source/changelog.rst
 doc/source/conf.py
-doc/source/configspec.rst
 doc/source/configure.rst
 doc/source/faq.rst
 doc/source/feedback.rst
@@ -35,6 +35,7 @@
 doc/source/news/31c3.rst
 doc/source/news/callfortesting.rst
 doc/source/news/khal01.rst
+doc/source/news/khal0100.rst
 doc/source/news/khal011.rst
 doc/source/news/khal02.rst
 doc/source/news/khal03.rst
@@ -58,7 +59,6 @@
 doc/source/news/khal096.rst
 doc/source/news/khal097.rst
 doc/source/news/khal098.rst
-doc/source/news/khal099.rst
 doc/source/ystatic/.gitignore
 doc/source/ytemplates/layout.html
 doc/webpage/src/new_rss_url.rst
@@ -69,7 +69,8 @@
 khal/configwizard.py
 khal/controllers.py
 khal/exceptions.py
-khal/log.py
+khal/icalendar.py
+khal/parse_datetime.py
 khal/terminal.py
 khal/utils.py
 khal/version.py
@@ -85,7 +86,6 @@
 khal/khalendar/event.py
 khal/khalendar/exceptions.py
 khal/khalendar/khalendar.py
-khal/khalendar/utils.py
 khal/khalendar/vdir.py
 khal/settings/__init__.py
 khal/settings/exceptions.py
@@ -99,6 +99,7 @@
 khal/ui/editor.py
 khal/ui/widgets.py
 misc/__khal
+misc/khal.desktop
 misc/mutt2khal
 tests/__init__.py
 tests/backend_test.py
@@ -108,8 +109,10 @@
 tests/conftest.py
 tests/controller_test.py
 tests/event_test.py
+tests/icalendar_test.py
 tests/khalendar_test.py
 tests/khalendar_utils_test.py
+tests/parse_datetime_test.py
 tests/settings_test.py
 tests/terminal_test.py
 tests/utils.py
@@ -117,6 +120,7 @@
 tests/vdir_test.py
 tests/vtimezone_test.py
 tests/configs/nocalendars.conf
+tests/configs/one_level_calendars.conf
 tests/configs/simple.conf
 tests/configs/small.conf
 tests/ics/cal_d.ics
@@ -137,6 +141,7 @@
 tests/ics/event_dt_long.ics
 tests/ics/event_dt_mixed_awareness.ics
 tests/ics/event_dt_multi_recuid_no_master.ics
+tests/ics/event_dt_multi_uid.ics
 tests/ics/event_dt_no_end.ics
 tests/ics/event_dt_rd.ics
 tests/ics/event_dt_recuid_no_master.ics
@@ -161,10 +166,14 @@
 tests/ics/event_rrule_recuid_cancelled.ics
 tests/ics/event_rrule_recuid_invalid_tzid.ics
 tests/ics/event_rrule_recuid_update.ics
+tests/ics/invalid_tzoffset.ics
 tests/ics/mult_uids_and_recuid_no_order.ics
 tests/ics/part0.ics
 tests/ics/part1.ics
+tests/ics/tz_windows_format.ics
 tests/ui/__init__.py
+tests/ui/canvas_render.py
 tests/ui/test_calendarwidget.py
 tests/ui/test_editor.py
-tests/ui/test_widgets.py
\ Intet linjeskift ved filafslutning
+tests/ui/test_widgets.py
+tests/ui/tests_walker.py
\ Intet linjeskift ved filafslutning
diff -Nru khal-0.9.10/misc/__khal khal-0.10.2/misc/__khal
--- khal-0.9.10/misc/__khal	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/misc/__khal	2020-07-29 18:17:53.000000000 +0200
@@ -16,7 +16,8 @@
 args=( '(- *)--help[show help information]' )
 _arguments -C $args \
   {-c+,--config=}'[specify config file]:config file:_files' \
-  {-v,--verbose}"[give more output]" \
+  {-v+,--verbosity=}"[set verbosity level]:options:(CRITICAL ERROR WARNING INFO  DEBUG)" \
+  {-l+,--logfile=}'[specify the file to log to]:log file:()' \
   '(- *)--version[show version]' \
   ':subcommand:->subcommand' \
   '*::options:->options' && ret=0
diff -Nru khal-0.9.10/misc/khal.desktop khal-0.10.2/misc/khal.desktop
--- khal-0.9.10/misc/khal.desktop	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/misc/khal.desktop	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Name=ikhal
+Categories=Calendar;ConsoleOnly;
+GenericName=Calendar application
+Comment=Terminal CLI calendar application
+Exec=ikhal
+Terminal=true
+Type=Application
+
+MimeType=text/calendar
diff -Nru khal-0.9.10/misc/mutt2khal khal-0.10.2/misc/mutt2khal
--- khal-0.9.10/misc/mutt2khal	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/misc/mutt2khal	2020-07-29 18:17:53.000000000 +0200
@@ -1,5 +1,5 @@
 #!/usr/bin/awk -f
-# mutt2khal is designed to be used in conjunction with vcalendar-filter (https://github.com/datamuc/mutt-filters/blob/master/vcalendar-filter)
+# mutt2khal is designed to be used in conjunction with vcalendar-filter (https://github.com/terabyte/mutt-filters/blob/master/vcalendar-filter)
 # and was inspired by the work of Jason Ryan (https://bitbucket.org/jasonwryan/shiv/src/tip/Scripts/mutt2khal)
 # example muttrc: macro attach A "<pipe-message>vcalendar-filter | mutt2khal<enter>"
 
diff -Nru khal-0.9.10/PKG-INFO khal-0.10.2/PKG-INFO
--- khal-0.9.10/PKG-INFO	2018-10-09 18:05:15.000000000 +0200
+++ khal-0.10.2/PKG-INFO	2020-07-29 18:32:59.787957400 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: khal
-Version: 0.9.10
+Version: 0.10.2
 Summary: A standards based terminal calendar
 Home-page: http://lostpackets.de/khal/
 Author: Christian Geier et. al.
@@ -8,8 +8,8 @@
 License: Expat/MIT
 Description: khal
         ====
-        .. image:: https://travis-ci.org/pimutils/khal.svg?branch=master
-            :target: https://travis-ci.org/pimutils/khal
+        .. image:: https://travis-ci.com/pimutils/khal.svg?branch=master
+            :target: https://travis-ci.com/pimutils/khal
         
         .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master
           :target: https://codecov.io/github/pimutils/khal?branch=master
@@ -29,7 +29,7 @@
         - fast and easy way to add new events
         - ikhal (interactive khal) lets you browse and edit calendars and events
         - no support for editing the timezones of events yet
-        - works with python 3.3+
+        - works with python 3.4+
         - khal should run on all major operating systems [1]_
         
         .. [1] except for Microsoft Windows
@@ -72,13 +72,7 @@
         
         The most appreciated way of contributing is by supplying code or documentation,
         reporting bugs, creating packages for your favorite operating system, making
-        khal better known by telling your friends about it, etc. If you don't have
-        the time or the means to contribute in any of the above mentioned ways,
-        donations are appreciated, too.
-        
-        .. image:: https://api.flattr.com/button/flattr-badge-large.png
-           :alt: flattr button
-           :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/
+        khal better known by telling your friends about it, etc.
         
         License
         -------
@@ -109,10 +103,10 @@
 Classifier: Environment :: Console :: Curses
 Classifier: Intended Audience :: End Users/Desktop
 Classifier: Operating System :: POSIX
-Classifier: Programming Language :: Python :: 3.3
 Classifier: Programming Language :: Python :: 3.4
 Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3 :: Only
 Classifier: Topic :: Utilities
 Classifier: Topic :: Communications
diff -Nru khal-0.9.10/README.rst khal-0.10.2/README.rst
--- khal-0.9.10/README.rst	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/README.rst	2020-07-29 18:17:53.000000000 +0200
@@ -1,7 +1,7 @@
 khal
 ====
-.. image:: https://travis-ci.org/pimutils/khal.svg?branch=master
-    :target: https://travis-ci.org/pimutils/khal
+.. image:: https://travis-ci.com/pimutils/khal.svg?branch=master
+    :target: https://travis-ci.com/pimutils/khal
 
 .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master
   :target: https://codecov.io/github/pimutils/khal?branch=master
@@ -21,7 +21,7 @@
 - fast and easy way to add new events
 - ikhal (interactive khal) lets you browse and edit calendars and events
 - no support for editing the timezones of events yet
-- works with python 3.3+
+- works with python 3.4+
 - khal should run on all major operating systems [1]_
 
 .. [1] except for Microsoft Windows
@@ -64,13 +64,7 @@
 
 The most appreciated way of contributing is by supplying code or documentation,
 reporting bugs, creating packages for your favorite operating system, making
-khal better known by telling your friends about it, etc. If you don't have
-the time or the means to contribute in any of the above mentioned ways,
-donations are appreciated, too.
-
-.. image:: https://api.flattr.com/button/flattr-badge-large.png
-   :alt: flattr button
-   :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/
+khal better known by telling your friends about it, etc.
 
 License
 -------
diff -Nru khal-0.9.10/.readthedocs.yml khal-0.10.2/.readthedocs.yml
--- khal-0.9.10/.readthedocs.yml	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/.readthedocs.yml	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,15 @@
+# .readthedocs.yml
+
+version: 2
+sphinx:
+    configuration: doc/source/conf.py
+
+python:
+  version: 3.7
+  install:
+    - requirements: doc/requirements.txt
+    - method: setuptools
+      path: .
+
+# This is the default, you can omit this
+formats: []
diff -Nru khal-0.9.10/setup.py khal-0.10.2/setup.py
--- khal-0.9.10/setup.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/setup.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,9 +1,10 @@
 #!/usr/bin/env python3
-from setuptools import setup
 import sys
 
-if sys.version_info < (3, 3):
-    errstr = "khal only supports python version 3.3+. Please Upgrade.\n"
+from setuptools import setup
+
+if sys.version_info < (3, 4):
+    errstr = "khal only supports python version 3.4+. Please Upgrade.\n"
     sys.stderr.write("#" * len(errstr) + '\n')
     sys.stderr.write(errstr)
     sys.stderr.write("#" * len(errstr) + '\n')
@@ -11,8 +12,9 @@
 
 requirements = [
     'click>=3.2',
-    'icalendar',
-    'urwid',
+    'click_log>=0.2.0',
+    'icalendar>=4.0.3',
+    'urwid>=1.3.0',
     'pyxdg',
     'pytz',
     'python-dateutil',
@@ -23,11 +25,13 @@
 ]
 
 test_requirements = [
-    'freezegun'
+    'freezegun',
+    'vdirsyncer',
 ]
 
 extra_requirements = {
     'proctitle': ['setproctitle'],
+    ':python_version < "3.5"': 'typing',
 }
 
 setup(
@@ -61,10 +65,10 @@
         "Environment :: Console :: Curses",
         "Intended Audience :: End Users/Desktop",
         "Operating System :: POSIX",
-        "Programming Language :: Python :: 3.3",
         "Programming Language :: Python :: 3.4",
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3 :: Only",
         "Topic :: Utilities",
         "Topic :: Communications",
diff -Nru khal-0.9.10/tests/backend_test.py khal-0.10.2/tests/backend_test.py
--- khal-0.9.10/tests/backend_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/backend_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,18 +1,14 @@
 
-import pytest
-
-import pkg_resources
+import datetime as dt
+from operator import itemgetter
 
-from datetime import date, datetime, timedelta, time
 import icalendar
-
+import pkg_resources
+import pytest
 from khal.khalendar import backend
-from khal.khalendar.event import LocalizedEvent, EventStandIn
 from khal.khalendar.exceptions import OutdatedDbVersionError, UpdateFailed
 
-from .utils import _get_text, \
-    BERLIN, LONDON, SYDNEY, \
-    LOCALE_BERLIN, LOCALE_SYDNEY
+from .utils import BERLIN, LOCALE_BERLIN, _get_text
 
 calname = 'home'
 
@@ -27,91 +23,52 @@
 def test_event_rrule_recurrence_id():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert dbi.list(calname) == list()
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 8, 26, 0, 0)))
+    events = dbi.get_localized(
+        BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)),
+    )
     assert list(events) == list()
     dbi.update(_get_text('event_rrule_recuid'), href='12345.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('12345.ics', 'abcd')]
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 8, 26, 0, 0)))
-    events = sorted(events, key=lambda x: x.start)
-    assert len(events) == 6
-
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0))
-    assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 7, 0))
-
     events = dbi.get_localized(
-        BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-        BERLIN.localize(datetime(2014, 8, 26, 0, 0)),
-        minimal=True,
+        BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)),
     )
-    events = list(events)
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 6
-    for event in events:
-        assert isinstance(event, EventStandIn)
-
 
-def test_event_different_timezones():
-    dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
-    dbi.update(_get_text('event_dt_london'), href='12345.ics', etag='abcd', calendar=calname)
-
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 9, 0, 0)),
-                               BERLIN.localize(datetime(2014, 4, 9, 23, 59)))
-    events = list(events)
-    assert len(events) == 1
-    event = events[0]
-    assert event.start_local == LONDON.localize(datetime(2014, 4, 9, 14))
-    assert event.end_local == LONDON.localize(datetime(2014, 4, 9, 19))
-    assert event.start == LONDON.localize(datetime(2014, 4, 9, 14))
-    assert event.end == LONDON.localize(datetime(2014, 4, 9, 19))
-
-    # no event scheduled on the next day
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 10, 0, 0)),
-                               BERLIN.localize(datetime(2014, 4, 10, 23, 59)))
-    events = list(events)
-    assert len(events) == 0
-
-    # now setting the local_timezone to Sydney
-    dbi.locale = LOCALE_SYDNEY
-    events = dbi.get_localized(SYDNEY.localize(datetime(2014, 4, 9, 0, 0)),
-                               SYDNEY.localize(datetime(2014, 4, 9, 23, 59)))
-    events = list(events)
-    assert len(events) == 1
-    event = events[0]
-    assert event.start_local == SYDNEY.localize(datetime(2014, 4, 9, 23))
-    assert event.end_local == SYDNEY.localize(datetime(2014, 4, 10, 4))
-    assert event.start == LONDON.localize(datetime(2014, 4, 9, 14))
-    assert event.end == LONDON.localize(datetime(2014, 4, 9, 19))
-
-    # the event spans midnight Sydney, therefor it should also show up on the
-    # next day
-    events = dbi.get_localized(SYDNEY.localize(datetime(2014, 4, 10, 0, 0)),
-                               SYDNEY.localize(datetime(2014, 4, 10, 23, 59)))
-    events = list(events)
-    assert len(events) == 1
-    assert event.start_local == SYDNEY.localize(datetime(2014, 4, 9, 23))
-    assert event.end_local == SYDNEY.localize(datetime(2014, 4, 10, 4))
+    # start
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0))
+    assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
+
+    calendars = dbi.get_localized_calendars(
+        BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)),
+    )
+    calendars = list(calendars)
+    assert len(calendars) == 6
 
 
 def test_event_rrule_recurrence_id_invalid_tzid():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(_get_text('event_rrule_recuid_invalid_tzid'), href='12345.ics', etag='abcd',
                calendar=calname)
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
-    events = sorted(events)
+    events = dbi.get_localized(
+        BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 6
 
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0))
-    assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 7, 0))
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0))
+    assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
 
 
 event_rrule_recurrence_id_reverse = """
@@ -140,22 +97,24 @@
     """
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert dbi.list(calname) == list()
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 8, 26, 0, 0)))
+    events = dbi.get_localized(
+        BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)))
     assert list(events) == list()
     dbi.update(event_rrule_recurrence_id_reverse, href='12345.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('12345.ics', 'abcd')]
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 8, 26, 0, 0)))
-    events = sorted(events, key=lambda x: x.start)
+    events = dbi.get_localized(
+        BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)))
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 6
 
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0))
-    assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 7, 0))
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0))
+    assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
 
 
 def test_event_rrule_recurrence_id_update_with_exclude():
@@ -167,15 +126,15 @@
     dbi.update(_get_text('event_rrule_recuid'), href='12345.ics', etag='abcd', calendar=calname)
     dbi.update(_get_text('event_rrule_recuid_update'),
                href='12345.ics', etag='abcd', calendar=calname)
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
-    events = sorted(events, key=lambda x: x.start)
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 5
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 7, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 8, 4, 7, 0))
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 7, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
 
 
 def test_event_recuid_no_master():
@@ -187,15 +146,13 @@
     dbi.update(_get_text('event_dt_recuid_no_master'),
                href='12345.ics', etag='abcd', calendar=calname)
     events = dbi.get_floating(
-        datetime(2017, 3, 1, 0, 0), datetime(2017, 4, 1, 0, 0),
+        dt.datetime(2017, 3, 1, 0, 0), dt.datetime(2017, 4, 1, 0, 0),
     )
-    events = sorted(events, key=lambda x: x.start)
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 1
-    assert events[0].start == datetime(2017, 3, 29, 16)
-    assert events[0].end == datetime(2017, 3, 29, 16, 25)
-    assert events[0].format(
-        '{title}', relative_to=date(2017, 3, 29)
-    ) == 'Infrastructure Planning\x1b[0m'
+    assert events[0][2] == dt.datetime(2017, 3, 29, 16)
+    assert events[0][3] == dt.datetime(2017, 3, 29, 16, 25)
+    assert 'SUMMARY:Infrastructure Planning' in events[0][0]
 
 
 def test_event_recuid_rrule_no_master():
@@ -209,16 +166,15 @@
         href='12345.ics', etag='abcd', calendar=calname,
     )
     events = dbi.get_floating(
-        datetime(2010, 1, 1, 0, 0), datetime(2020, 1, 1, 0, 0),
+        dt.datetime(2010, 1, 1, 0, 0), dt.datetime(2020, 1, 1, 0, 0),
     )
-    events = sorted(events, key=lambda x: x.start)
+    events = sorted(events, key=itemgetter(2))
     assert len(list(events)) == 2
-    assert events[0].start == datetime(2014, 6, 30, 7, 30)
-    assert events[0].end == datetime(2014, 6, 30, 12, 0)
-    assert events[1].start == datetime(2014, 7, 7, 8, 30)
-    assert events[1].end == datetime(2014, 7, 7, 12, 0)
+    assert events[0][2] == dt.datetime(2014, 6, 30, 7, 30)
+    assert events[0][3] == dt.datetime(2014, 6, 30, 12, 0)
+    assert events[1][2] == dt.datetime(2014, 7, 7, 8, 30)
+    assert events[1][3] == dt.datetime(2014, 7, 7, 12, 0)
     events = dbi.search('VEVENT')
-    events = sorted(events, key=lambda x: x.start)
     assert len(list(events)) == 2
 
 
@@ -226,31 +182,29 @@
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(_get_text('event_dt_local_missing_tz'),
                href='12345.ics', etag='abcd', calendar=calname)
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 9, 0, 0)),
-                               BERLIN.localize(datetime(2014, 4, 10, 0, 0)))
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 9, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 4, 10, 0, 0)))
     events = sorted(list(events))
     assert len(events) == 1
     event = events[0]
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
-    assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
+    assert event[2] == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event[3] == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
 
 
 def test_event_delete():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert dbi.list(calname) == list()
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 8, 26, 0, 0)))
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)))
     assert list(events) == list()
     dbi.update(event_rrule_recurrence_id_reverse, href='12345.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('12345.ics', 'abcd')]
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
     assert len(list(events)) == 6
     dbi.delete('12345.ics', calendar=calname)
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
     assert len(list(events)) == 0
 
 
@@ -308,29 +262,28 @@
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(event_rrule_this_and_future, href='12345.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('12345.ics', 'abcd')]
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
-    events = sorted(events, key=lambda x: x.start)
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 6
 
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 9, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 9, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 9, 0))
-    assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 9, 0))
-
-    assert events[0].end == BERLIN.localize(datetime(2014, 6, 30, 12, 0))
-    assert events[1].end == BERLIN.localize(datetime(2014, 7, 7, 18, 0))
-    assert events[2].end == BERLIN.localize(datetime(2014, 7, 14, 18, 0))
-    assert events[3].end == BERLIN.localize(datetime(2014, 7, 21, 18, 0))
-    assert events[4].end == BERLIN.localize(datetime(2014, 7, 28, 18, 0))
-    assert events[5].end == BERLIN.localize(datetime(2014, 8, 4, 18, 0))
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 9, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 9, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 9, 0))
+    assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 9, 0))
+
+    assert events[0][3] == BERLIN.localize(dt.datetime(2014, 6, 30, 12, 0))
+    assert events[1][3] == BERLIN.localize(dt.datetime(2014, 7, 7, 18, 0))
+    assert events[2][3] == BERLIN.localize(dt.datetime(2014, 7, 14, 18, 0))
+    assert events[3][3] == BERLIN.localize(dt.datetime(2014, 7, 21, 18, 0))
+    assert events[4][3] == BERLIN.localize(dt.datetime(2014, 7, 28, 18, 0))
+    assert events[5][3] == BERLIN.localize(dt.datetime(2014, 8, 4, 18, 0))
 
-    assert str(events[0].summary) == 'Arbeit'
+    assert 'SUMMARY:Arbeit\n' in events[0][0]
     for num, event in enumerate(events[1:]):
-        assert event.raw  # just making sure we don't raise any exception
-        assert str(event.summary) == 'Arbeit (lang)'
+        assert 'SUMMARY:Arbeit (lang)\n' in event[0]
 
 
 event_rrule_this_and_future_multi_day_shift = \
@@ -342,28 +295,28 @@
     dbi.update(event_rrule_this_and_future_multi_day_shift,
                href='12345.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('12345.ics', 'abcd')]
-    events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 30, 0, 0)),
-                               BERLIN.localize(datetime(2014, 9, 26, 0, 0)))
-    events = sorted(events, key=lambda x: x.start)
+    events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)),
+                               BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0)))
+    events = sorted(events, key=itemgetter(2))
     assert len(events) == 6
 
-    assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0))
-    assert events[1].start == BERLIN.localize(datetime(2014, 7, 8, 9, 0))
-    assert events[2].start == BERLIN.localize(datetime(2014, 7, 15, 9, 0))
-    assert events[3].start == BERLIN.localize(datetime(2014, 7, 22, 9, 0))
-    assert events[4].start == BERLIN.localize(datetime(2014, 7, 29, 9, 0))
-    assert events[5].start == BERLIN.localize(datetime(2014, 8, 5, 9, 0))
-
-    assert events[0].end == BERLIN.localize(datetime(2014, 6, 30, 12, 0))
-    assert events[1].end == BERLIN.localize(datetime(2014, 7, 9, 15, 0))
-    assert events[2].end == BERLIN.localize(datetime(2014, 7, 16, 15, 0))
-    assert events[3].end == BERLIN.localize(datetime(2014, 7, 23, 15, 0))
-    assert events[4].end == BERLIN.localize(datetime(2014, 7, 30, 15, 0))
-    assert events[5].end == BERLIN.localize(datetime(2014, 8, 6, 15, 0))
+    assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0))
+    assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 8, 9, 0))
+    assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 15, 9, 0))
+    assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 22, 9, 0))
+    assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 29, 9, 0))
+    assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 5, 9, 0))
+
+    assert events[0][3] == BERLIN.localize(dt.datetime(2014, 6, 30, 12, 0))
+    assert events[1][3] == BERLIN.localize(dt.datetime(2014, 7, 9, 15, 0))
+    assert events[2][3] == BERLIN.localize(dt.datetime(2014, 7, 16, 15, 0))
+    assert events[3][3] == BERLIN.localize(dt.datetime(2014, 7, 23, 15, 0))
+    assert events[4][3] == BERLIN.localize(dt.datetime(2014, 7, 30, 15, 0))
+    assert events[5][3] == BERLIN.localize(dt.datetime(2014, 8, 6, 15, 0))
 
-    assert str(events[0].summary) == 'Arbeit'
+    assert 'SUMMARY:Arbeit\n' in events[0][0]
     for event in events[1:]:
-        assert str(event.summary) == 'Arbeit (lang)'
+        assert 'SUMMARY:Arbeit (lang)\n' in event[0]
 
 
 event_rrule_this_and_future_allday_temp = """
@@ -394,35 +347,26 @@
     dbi.update(event_rrule_this_and_future_allday,
                href='rrule_this_and_future_allday.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('rrule_this_and_future_allday.ics', 'abcd')]
-    events = list(dbi.get_floating(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0)))
+    events = list(dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0)))
     assert len(events) == 6
 
-    assert events[0].start == date(2014, 6, 30)
-    assert events[1].start == date(2014, 7, 8)
-    assert events[2].start == date(2014, 7, 15)
-    assert events[3].start == date(2014, 7, 22)
-    assert events[4].start == date(2014, 7, 29)
-    assert events[5].start == date(2014, 8, 5)
-
-    assert events[0].end == date(2014, 6, 30)
-    assert events[1].end == date(2014, 7, 8)
-    assert events[2].end == date(2014, 7, 15)
-    assert events[3].end == date(2014, 7, 22)
-    assert events[4].end == date(2014, 7, 29)
-    assert events[5].end == date(2014, 8, 5)
+    assert events[0][2] == dt.date(2014, 6, 30)
+    assert events[1][2] == dt.date(2014, 7, 8)
+    assert events[2][2] == dt.date(2014, 7, 15)
+    assert events[3][2] == dt.date(2014, 7, 22)
+    assert events[4][2] == dt.date(2014, 7, 29)
+    assert events[5][2] == dt.date(2014, 8, 5)
+
+    assert events[0][3] == dt.date(2014, 7, 1)
+    assert events[1][3] == dt.date(2014, 7, 9)
+    assert events[2][3] == dt.date(2014, 7, 16)
+    assert events[3][3] == dt.date(2014, 7, 23)
+    assert events[4][3] == dt.date(2014, 7, 30)
+    assert events[5][3] == dt.date(2014, 8, 6)
 
-    assert str(events[0].summary) == 'Arbeit'
+    assert 'SUMMARY:Arbeit\n' in events[0][0]
     for event in events[1:]:
-        assert str(event.summary) == 'Arbeit (lang)'
-
-    events = list(dbi.get_floating(
-        datetime(2014, 4, 30, 0, 0),
-        datetime(2014, 9, 27, 0, 0),
-        minimal=True,
-    ))
-    assert len(events) == 6
-    for event in events:
-        assert isinstance(event, EventStandIn)
+        assert 'SUMMARY:Arbeit (lang)\n' in event[0]
 
 
 def test_event_rrule_this_and_future_allday_prior():
@@ -432,27 +376,27 @@
     dbi.update(event_rrule_this_and_future_allday_prior,
                href='rrule_this_and_future_allday.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('rrule_this_and_future_allday.ics', 'abcd')]
-    events = list(dbi.get_floating(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0)))
+    events = list(dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0)))
 
     assert len(events) == 6
 
-    assert events[0].start == date(2014, 6, 30)
-    assert events[1].start == date(2014, 7, 5)
-    assert events[2].start == date(2014, 7, 12)
-    assert events[3].start == date(2014, 7, 19)
-    assert events[4].start == date(2014, 7, 26)
-    assert events[5].start == date(2014, 8, 2)
-
-    assert events[0].end == date(2014, 6, 30)
-    assert events[1].end == date(2014, 7, 5)
-    assert events[2].end == date(2014, 7, 12)
-    assert events[3].end == date(2014, 7, 19)
-    assert events[4].end == date(2014, 7, 26)
-    assert events[5].end == date(2014, 8, 2)
+    assert events[0][2] == dt.date(2014, 6, 30)
+    assert events[1][2] == dt.date(2014, 7, 5)
+    assert events[2][2] == dt.date(2014, 7, 12)
+    assert events[3][2] == dt.date(2014, 7, 19)
+    assert events[4][2] == dt.date(2014, 7, 26)
+    assert events[5][2] == dt.date(2014, 8, 2)
+
+    assert events[0][3] == dt.date(2014, 7, 1)
+    assert events[1][3] == dt.date(2014, 7, 6)
+    assert events[2][3] == dt.date(2014, 7, 13)
+    assert events[3][3] == dt.date(2014, 7, 20)
+    assert events[4][3] == dt.date(2014, 7, 27)
+    assert events[5][3] == dt.date(2014, 8, 3)
 
-    assert str(events[0].summary) == 'Arbeit'
+    assert 'SUMMARY:Arbeit\n' in events[0][0]
     for event in events[1:]:
-        assert str(event.summary) == 'Arbeit (lang)'
+        assert 'SUMMARY:Arbeit (lang)\n' in event[0]
 
 
 event_rrule_multi_this_and_future_allday = """BEGIN:VCALENDAR
@@ -485,28 +429,30 @@
     dbi.update(event_rrule_multi_this_and_future_allday,
                href='event_rrule_multi_this_and_future_allday.ics', etag='abcd', calendar=calname)
     assert dbi.list(calname) == [('event_rrule_multi_this_and_future_allday.ics', 'abcd')]
-    events = sorted(dbi.get_floating(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0)))
+    events = sorted(
+        dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0)),
+    )
     assert len(events) == 6
 
-    assert events[0].start == date(2014, 6, 30)
-    assert events[1].start == date(2014, 7, 12)
-    assert events[2].start == date(2014, 7, 17)
-    assert events[3].start == date(2014, 7, 19)
-    assert events[4].start == date(2014, 7, 24)
-    assert events[5].start == date(2014, 7, 31)
-
-    assert events[0].end == date(2014, 6, 30)
-    assert events[1].end == date(2014, 7, 13)
-    assert events[2].end == date(2014, 7, 17)
-    assert events[3].end == date(2014, 7, 20)
-    assert events[4].end == date(2014, 7, 24)
-    assert events[5].end == date(2014, 7, 31)
+    assert events[0][2] == dt.date(2014, 6, 30)
+    assert events[1][2] == dt.date(2014, 7, 12)
+    assert events[2][2] == dt.date(2014, 7, 17)
+    assert events[3][2] == dt.date(2014, 7, 19)
+    assert events[4][2] == dt.date(2014, 7, 24)
+    assert events[5][2] == dt.date(2014, 7, 31)
+
+    assert events[0][3] == dt.date(2014, 7, 1)
+    assert events[1][3] == dt.date(2014, 7, 14)
+    assert events[2][3] == dt.date(2014, 7, 18)
+    assert events[3][3] == dt.date(2014, 7, 21)
+    assert events[4][3] == dt.date(2014, 7, 25)
+    assert events[5][3] == dt.date(2014, 8, 1)
 
-    assert str(events[0].summary) == 'Arbeit'
+    assert 'SUMMARY:Arbeit\n' in events[0][0]
     for event in [events[1], events[3]]:
-        assert str(event.summary) == 'Arbeit (lang)'
+        assert 'SUMMARY:Arbeit (lang)\n' in event[0]
     for event in [events[2], events[4], events[5]]:
-        assert str(event.summary) == 'Arbeit (neu)'
+        assert 'SUMMARY:Arbeit (neu)\n' in event[0]
 
 
 master = """BEGIN:VEVENT
@@ -535,9 +481,9 @@
 
 
 def test_calc_shift_deltas():
-    assert (timedelta(hours=2), timedelta(hours=5)) == \
+    assert (dt.timedelta(hours=2), dt.timedelta(hours=5)) == \
         backend.calc_shift_deltas(recuid_this_future)
-    assert (timedelta(hours=2), timedelta(hours=4, minutes=30)) == \
+    assert (dt.timedelta(hours=2), dt.timedelta(hours=4, minutes=30)) == \
         backend.calc_shift_deltas(recuid_this_future_duration)
 
 
@@ -571,34 +517,35 @@
     assert dbi.list(home) == [('12345.ics', 'abcd')]
     assert dbi.list(work) == [('12345.ics', 'abcd')]
     dbi.calendars = [home]
-    events_a = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
+    events_a = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
     dbi.calendars = [work]
-    events_b = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
+    events_b = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
     assert len(events_a) == 4
     assert len(events_b) == 4
     dbi.calendars = [work, home]
-    events_c = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
+    events_c = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
     assert len(events_c) == 8
-    assert [event.calendar for event in events_c].count(home) == 4
-    assert [event.calendar for event in events_c].count(work) == 4
+    # count events from a given calendar
+    assert [event[6] for event in events_c].count(home) == 4
+    assert [event[6] for event in events_c].count(work) == 4
 
     dbi.delete('12345.ics', calendar=home)
     dbi.calendars = [home]
-    events_a = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
+    events_a = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
     dbi.calendars = [work]
-    events_b = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
+    events_b = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
     assert len(events_a) == 0
     assert len(events_b) == 4
     dbi.calendars = [work, home]
-    events_c = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)),
-                                      BERLIN.localize(datetime(2014, 7, 26, 0, 0))))
-    assert [event.calendar for event in events_c].count('home') == 0
-    assert [event.calendar for event in events_c].count('work') == 4
+    events_c = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)),
+                                      BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0))))
+    assert [event[6] for event in events_c].count('home') == 0
+    assert [event[6] for event in events_c].count('work') == 4
     assert dbi.list(home) == []
     assert dbi.list(work) == [('12345.ics', 'abcd')]
 
@@ -607,35 +554,22 @@
     """test if an THISANDFUTURE param effects other events as well"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     db.update(_get_text('event_d_15'), href='first', calendar=calname)
-    events = db.get_floating(datetime(2015, 4, 9, 0, 0), datetime(2015, 4, 10, 0, 0))
+    events = db.get_floating(dt.datetime(2015, 4, 9, 0, 0), dt.datetime(2015, 4, 10, 0, 0))
     assert len(list(events)) == 1
     db.update(event_rrule_multi_this_and_future_allday, href='second', calendar=calname)
-    events = list(db.get_floating(datetime(2015, 4, 9, 0, 0), datetime(2015, 4, 10, 0, 0)))
-    assert len(events) == 1
-
-
-def test_zulu_events():
-    """test if events in Zulu time are correctly recognized as locaized events"""
-    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
-    db.update(_get_text('event_dt_simple_zulu'), href='event_zulu', calendar=calname)
-    events = db.get_localized(BERLIN.localize(datetime(2014, 4, 9, 0, 0)),
-                              BERLIN.localize(datetime(2014, 4, 10, 0, 0)))
-    events = list(events)
+    events = list(db.get_floating(dt.datetime(2015, 4, 9, 0, 0), dt.datetime(2015, 4, 10, 0, 0)))
     assert len(events) == 1
-    event = events[0]
-    assert type(event) == LocalizedEvent
-    assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 11, 30))
 
 
 def test_no_dtend():
     """test support for events with no dtend"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     db.update(_get_text('event_dt_no_end'), href='event_dt_no_end', calendar=calname)
-    events = db.get_floating(datetime(2016, 1, 16, 0, 0),
-                             datetime(2016, 1, 17, 0, 0))
+    events = db.get_floating(
+        dt.datetime(2016, 1, 16, 0, 0), dt.datetime(2016, 1, 17, 0, 0))
     event = list(events)[0]
-    assert event.start == date(2016, 1, 16)
-    assert event.end == date(2016, 1, 16)
+    assert event[2] == dt.date(2016, 1, 16)
+    assert event[3] == dt.date(2016, 1, 17)
 
 
 event_rdate_period = """BEGIN:VEVENT
@@ -721,79 +655,144 @@
 END:VCARD
 """
 
-day = date(1971, 3, 11)
-start = datetime.combine(day, time.min)
-end = datetime.combine(day, time.max)
+card_anniversary = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+X-ANNIVERSARY:19710311
+END:VCARD
+"""
+
+card_abdate = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+ITEM1.X-ABDATE:19710311
+ITEM1.X-ABLabel:spouse's birthday
+END:VCARD
+"""
+
+card_abdate_nolabel = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+ITEM1.X-ABDATE:19710311
+END:VCARD
+"""
+
+card_v3 = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+BDAY:1971-03-11
+END:VCARD
+"""
+
+day = dt.date(1971, 3, 11)
+start = dt.datetime.combine(day, dt.time.min)
+end = dt.datetime.combine(day, dt.time.max)
 
 
 def test_birthdays():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
-    db.update_birthday(card, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
     assert len(events) == 1
-    assert events[0].summary == 'Unix\'s 0th birthday'
-    events = list(db.get_floating(datetime(2016, 3, 11, 0, 0),
-                                  datetime(2016, 3, 11, 23, 59, 59, 999)))
-    assert events[0].summary == 'Unix\'s 45th birthday'
+    assert 'SUMMARY:Unix\'s birthday' in events[0][0]
+
+    events = list(
+        db.get_floating(
+            dt.datetime(2016, 3, 11, 0, 0),
+            dt.datetime(2016, 3, 11, 23, 59, 59, 999)))
+    assert 'SUMMARY:Unix\'s birthday' in events[0][0]
 
 
 def test_birthdays_update():
     """test if we can update a birthday"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
-    db.update_birthday(card, 'unix.vcf', calendar=calname)
-    db.update_birthday(card, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card, 'unix.vcf', calendar=calname)
 
 
-def test_birthdays_29feb():
-    """test how we deal with birthdays on 29th of feb in leap years"""
-    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
-    db.update_birthday(card_29thfeb, 'leap.vcf', calendar=calname)
-    events = list(
-        db.get_floating(datetime(2004, 1, 1, 0, 0), datetime(2004, 12, 31))
-    )
-    assert len(events) == 1
-    assert events[0].summary == 'leapyear\'s 4th birthday (29th of Feb.)'
-    assert events[0].start == date(2004, 2, 29)
-    events = list(
-        db.get_floating(datetime(2005, 1, 1, 0, 0), datetime(2005, 12, 31))
-    )
+def test_birthdays_no_fn():
+    db = backend.SQLiteDb(['home'], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(dt.datetime(1941, 9, 9, 0, 0),
+                                dt.datetime(1941, 9, 9, 23, 59, 59, 9999))) == list()
+    db.update_vcf_dates(card_no_fn, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(dt.datetime(1941, 9, 9, 0, 0),
+                                  dt.datetime(1941, 9, 9, 23, 59, 59, 9999)))
     assert len(events) == 1
-    assert events[0].summary == 'leapyear\'s 5th birthday (29th of Feb.)'
-    assert events[0].start == date(2005, 3, 1)
+    assert 'SUMMARY:Dennis MacAlistair Ritchie\'s birthday' in events[0][0]
+
+
+def test_birthday_does_not_parse():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_does_not_parse, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
+    assert len(events) == 0
+
+
+def test_vcard_two_birthdays():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_two_birthdays, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
+    assert len(events) == 0
 
 
-def test_birthdays_no_year():
+def test_anniversary():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
-    db.update_birthday(card_no_year, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card_anniversary, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
     assert len(events) == 1
-    assert events[0].summary == 'Unix\'s birthday'
+    assert 'SUMMARY:Unix\'s anniversary' in events[0][0]
+
+    events = list(
+        db.get_floating(
+            dt.datetime(2016, 3, 11, 0, 0),
+            dt.datetime(2016, 3, 11, 23, 59, 59, 999)))
+    assert 'SUMMARY:Unix\'s anniversary' in events[0][0]
 
 
-def test_birthdays_no_fn():
-    db = backend.SQLiteDb(['home'], ':memory:', locale=LOCALE_BERLIN)
-    assert list(db.get_floating(datetime(1941, 9, 9, 0, 0),
-                                datetime(1941, 9, 9, 23, 59, 59, 9999))) == list()
-    db.update_birthday(card_no_fn, 'unix.vcf', calendar=calname)
-    events = list(db.get_floating(datetime(1941, 9, 9, 0, 0),
-                                  datetime(1941, 9, 9, 23, 59, 59, 9999)))
+def test_abdate():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_abdate, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
     assert len(events) == 1
-    assert events[0].summary == 'Dennis MacAlistair Ritchie\'s 0th birthday'
+    assert 'SUMMARY:Unix\'s spouse\'s birthday' in events[0][0]
 
+    events = list(
+        db.get_floating(
+            dt.datetime(2016, 3, 11, 0, 0),
+            dt.datetime(2016, 3, 11, 23, 59, 59, 999)))
+    assert 'SUMMARY:Unix\'s spouse\'s birthday' in events[0][0]
 
-def test_birthday_does_not_parse():
+
+def test_abdate_nolabel():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
-    db.update_birthday(card_does_not_parse, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card_abdate_nolabel, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
-    assert len(events) == 0
+    assert len(events) == 1
+    assert 'SUMMARY:Unix\'s custom event from vcard' in events[0][0]
+
+    events = list(
+        db.get_floating(
+            dt.datetime(2016, 3, 11, 0, 0),
+            dt.datetime(2016, 3, 11, 23, 59, 59, 999)))
+    assert 'SUMMARY:Unix\'s custom event from vcard' in events[0][0]
 
 
-def test_vcard_two_birthdays():
+def test_birthday_v3():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
-    db.update_birthday(card_two_birthdays, 'unix.vcf', calendar=calname)
+    db.update_vcf_dates(card_v3, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
-    assert len(events) == 0
+    assert len(events) == 1
+    assert 'SUMMARY:Unix\'s birthday' in events[0][0]
+
+    events = list(
+        db.get_floating(
+            dt.datetime(2016, 3, 11, 0, 0),
+            dt.datetime(2016, 3, 11, 23, 59, 59, 999)))
+    assert 'SUMMARY:Unix\'s birthday' in events[0][0]
diff -Nru khal-0.9.10/tests/cal_display_test.py khal-0.10.2/tests/cal_display_test.py
--- khal-0.9.10/tests/cal_display_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/cal_display_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,40 +1,101 @@
-import datetime
+import datetime as dt
 import locale
 import platform
-
 import unicodedata
-import pytest
-
-from khal.calendar_display import vertical_month, getweeknumber, str_week
 
+import pytest
+from khal.calendar_display import (getweeknumber, str_week, vertical_month,
+                                   get_calendar_color, get_color_list)
 
-today = datetime.date.today()
-yesterday = today - datetime.timedelta(days=1)
-tomorrow = today + datetime.timedelta(days=1)
+today = dt.date.today()
+yesterday = today - dt.timedelta(days=1)
+tomorrow = today + dt.timedelta(days=1)
 
 
 def test_getweeknumber():
-    assert getweeknumber(datetime.date(2011, 12, 12)) == 50
-    assert getweeknumber(datetime.date(2011, 12, 31)) == 52
-    assert getweeknumber(datetime.date(2012, 1, 1)) == 52
-    assert getweeknumber(datetime.date(2012, 1, 2)) == 1
+    assert getweeknumber(dt.date(2011, 12, 12)) == 50
+    assert getweeknumber(dt.date(2011, 12, 31)) == 52
+    assert getweeknumber(dt.date(2012, 1, 1)) == 52
+    assert getweeknumber(dt.date(2012, 1, 2)) == 1
 
 
 def test_str_week():
-    aday = datetime.date(2012, 6, 1)
-    bday = datetime.date(2012, 6, 8)
-    week = [datetime.date(2012, 6, 6),
-            datetime.date(2012, 6, 7),
-            datetime.date(2012, 6, 8),
-            datetime.date(2012, 6, 9),
-            datetime.date(2012, 6, 10),
-            datetime.date(2012, 6, 11),
-            datetime.date(2012, 6, 12),
-            datetime.date(2012, 6, 13)]
+    aday = dt.date(2012, 6, 1)
+    bday = dt.date(2012, 6, 8)
+    week = [dt.date(2012, 6, 6),
+            dt.date(2012, 6, 7),
+            dt.date(2012, 6, 8),
+            dt.date(2012, 6, 9),
+            dt.date(2012, 6, 10),
+            dt.date(2012, 6, 11),
+            dt.date(2012, 6, 12),
+            dt.date(2012, 6, 13)]
     assert str_week(week, aday) == ' 6  7  8  9 10 11 12 13 '
     assert str_week(week, bday) == ' 6  7 \x1b[7m 8\x1b[0m  9 10 11 12 13 '
 
 
+class testCollection():
+    def __init__(self):
+        self._calendars = {}
+
+    def addCalendar(self, name, color, priority):
+        self._calendars[name] = {'color': color, 'priority': priority}
+
+
+def test_get_calendar_color():
+
+    exampleCollection = testCollection()
+    exampleCollection.addCalendar('testCalendar1', 'dark red', 20)
+    exampleCollection.addCalendar('testCalendar2', 'light green', 10)
+    exampleCollection.addCalendar('testCalendar3', '', 10)
+
+    assert get_calendar_color('testCalendar1', 'light blue', exampleCollection) == 'dark red'
+    assert get_calendar_color('testCalendar2', 'light blue', exampleCollection) == 'light green'
+
+    # test default color
+    assert get_calendar_color('testCalendar3', 'light blue', exampleCollection) == 'light blue'
+
+
+def test_get_color_list():
+
+    exampleCalendarList = ['testCalendar1', 'testCalendar2']
+
+    # test different priorities
+    exampleCollection1 = testCollection()
+    exampleCollection1.addCalendar('testCalendar1', 'dark red', 20)
+    exampleCollection1.addCalendar('testCalendar2', 'light green', 10)
+
+    testList1 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection1)
+    assert 'dark red' in testList1
+    assert len(testList1) == 1
+
+    # test same priorities
+    exampleCollection2 = testCollection()
+    exampleCollection2.addCalendar('testCalendar1', 'dark red', 20)
+    exampleCollection2.addCalendar('testCalendar2', 'light green', 20)
+
+    testList2 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection2)
+    assert 'dark red' in testList2
+    assert 'light green' in testList2
+    assert len(testList2) == 2
+
+    # test duplicated colors
+    exampleCollection3 = testCollection()
+    exampleCollection3.addCalendar('testCalendar1', 'dark red', 20)
+    exampleCollection3.addCalendar('testCalendar2', 'dark red', 20)
+
+    testList3 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection3)
+    assert len(testList3) == 1
+
+    # test indexing operator (required by str_highlight_day())
+    exampleCollection4 = testCollection()
+    exampleCollection4.addCalendar('testCalendar1', 'dark red', 20)
+    exampleCollection4.addCalendar('testCalendar2', 'dark red', 20)
+
+    testList3 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection4)
+    assert testList3[0] == 'dark red'
+
+
 example1 = [
     '\x1b[1m    Mo Tu We Th Fr Sa Su \x1b[0m',
     '\x1b[1mDec \x1b[0m28 29 30  1  2  3  4 ',
@@ -52,6 +113,23 @@
     '    20 21 22 23 24 25 26 ',
     '\x1b[1mMar \x1b[0m27 28 29  1  2  3  4 ']
 
+example2 = [
+    '\x1b[1m    Mo Tu We Th Fr Sa Su \x1b[0m',
+    '    28 29 30  1  2  3  4 ',
+    '\x1b[1mDec \x1b[0m 5  6  7  8  9 10 11 ',
+    '    \x1b[7m12\x1b[0m 13 14 15 16 17 18 ',
+    '    19 20 21 22 23 24 25 ',
+    '    26 27 28 29 30 31  1 ',
+    '\x1b[1mJan \x1b[0m 2  3  4  5  6  7  8 ',
+    '     9 10 11 12 13 14 15 ',
+    '    16 17 18 19 20 21 22 ',
+    '    23 24 25 26 27 28 29 ',
+    '    30 31  1  2  3  4  5 ',
+    '\x1b[1mFeb \x1b[0m 6  7  8  9 10 11 12 ',
+    '    13 14 15 16 17 18 19 ',
+    '    20 21 22 23 24 25 26 ',
+    '    27 28 29  1  2  3  4 ']
+
 
 example_weno = [
     '\x1b[1m    Mo Tu We Th Fr Sa Su   \x1b[0m',
@@ -194,17 +272,22 @@
     try:
         locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
         vert_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12))
+                                  today=dt.date(2011, 12, 12))
         assert vert_str == example1
 
+        vert_str = vertical_month(month=12, year=2011,
+                                  today=dt.date(2011, 12, 12),
+                                  monthdisplay='firstfullweek')
+        assert vert_str == example2
+
         weno_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12),
+                                  today=dt.date(2011, 12, 12),
                                   weeknumber='right')
         assert weno_str == example_weno
 
         we_start_su_str = vertical_month(
             month=12, year=2011,
-            today=datetime.date(2011, 12, 12),
+            today=dt.date(2011, 12, 12),
             firstweekday=6)
         assert we_start_su_str == example_we_start_su
     except locale.Error as error:
@@ -223,7 +306,7 @@
     try:
         locale.setlocale(locale.LC_ALL, 'de_DE.UTF-8')
         vert_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12))
+                                  today=dt.date(2011, 12, 12))
         # de_DE locale on at least Net and FreeBSD is different from the one
         # commonly used on linux systems
         if platform.system() == 'FreeBSD':
@@ -252,7 +335,7 @@
     try:
         locale.setlocale(locale.LC_ALL, 'cs_CZ.UTF-8')
         vert_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12))
+                                  today=dt.date(2011, 12, 12))
         assert [line.lower() for line in vert_str] == [line.lower() for line in example_cz]
         '\n'.join(vert_str)  # issue 142/293
     except locale.Error as error:
@@ -279,7 +362,7 @@
     try:
         locale.setlocale(locale.LC_ALL, 'el_GR.UTF-8')
         vert_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12))
+                                  today=dt.date(2011, 12, 12))
         # on some OSes, Greek locale's abbreviated day of the week and
         # month names have accents, on some they haven't
         assert strip_accents('\n'.join([line.lower() for line in vert_str])) == \
@@ -304,7 +387,7 @@
     try:
         locale.setlocale(locale.LC_ALL, 'fr_FR.UTF-8')
         vert_str = vertical_month(month=12, year=2011,
-                                  today=datetime.date(2011, 12, 12))
+                                  today=dt.date(2011, 12, 12))
         assert '\n'.join(vert_str) == '\n'.join(example_fr)
     except locale.Error as error:
         if str(error) == 'unsupported locale setting':
diff -Nru khal-0.9.10/tests/cli_test.py khal-0.10.2/tests/cli_test.py
--- khal-0.9.10/tests/cli_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/cli_test.py	2020-07-29 18:32:26.000000000 +0200
@@ -1,16 +1,13 @@
-import datetime
+import datetime as dt
 import os
 import sys
-from unittest import mock
-from datetime import timedelta
 
 import pytest
-from freezegun import freeze_time
 from click.testing import CliRunner
+from freezegun import freeze_time
+from khal.cli import main_ikhal, main_khal
 
-from khal.cli import main_khal, main_ikhal
-
-from .utils import _get_text, _get_ics_filepath
+from .utils import _get_ics_filepath, _get_text
 
 
 class CustomCliRunner(CliRunner):
@@ -47,8 +44,7 @@
     monkeypatch.setattr('xdg.BaseDirectory.xdg_config_home', str(xdg_config_home))
     monkeypatch.setattr('xdg.BaseDirectory.xdg_config_dirs', [str(xdg_config_home)])
 
-    def inner(default_command='list', print_new=False, default_calendar=True, days=2,
-              **kwargs):
+    def inner(print_new=False, default_calendar=True, days=2, **kwargs):
         if default_calendar:
             default_calendar = 'default_calendar = one'
         else:
@@ -56,7 +52,6 @@
         if not os.path.exists(str(xdg_config_home.join('khal'))):
             os.makedirs(str(xdg_config_home.join('khal')))
         config_file.write(config_template.format(
-            default_command=default_command,
             delta=str(days) + 'd',
             calpath=str(calendar), calpath2=str(calendar2), calpath3=str(calendar3),
             default_calendar=default_calendar,
@@ -96,7 +91,6 @@
 firstweekday = 0
 
 [default]
-default_command = {default_command}
 {default_calendar}
 timedelta = {delta}
 print_new = {print_new}
@@ -107,11 +101,11 @@
 
 
 def test_direct_modification(runner):
-    runner = runner(default_command='list')
+    runner = runner()
 
     result = runner.invoke(main_khal, ['list'])
-    assert not result.exception
     assert result.output == 'No events\n'
+    assert not result.exception
 
     cal_dt = _get_text('event_dt_simple')
     event = runner.calendars['one'].join('test.ics')
@@ -129,19 +123,18 @@
 
 
 def test_simple(runner):
-    runner = runner(default_command='list', days=2)
-
-    result = runner.invoke(main_khal)
+    runner = runner(days=2)
+    result = runner.invoke(main_khal, ['list'])
     assert not result.exception
     assert result.output == 'No events\n'
 
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
+    now = dt.datetime.now().strftime('%d.%m.%Y')
     result = runner.invoke(
         main_khal, 'new {} 18:00 myevent'.format(now).split())
     assert result.output == ''
     assert not result.exception
 
-    result = runner.invoke(main_khal)
+    result = runner.invoke(main_khal, ['list'])
     print(result.output)
     assert 'myevent' in result.output
     assert '18:00' in result.output
@@ -151,32 +144,31 @@
 
 
 def test_simple_color(runner):
-    runner = runner(default_command='list', days=2)
-
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
     result = runner.invoke(main_khal, 'new {} 18:00 myevent'.format(now).split())
     assert result.output == ''
     assert not result.exception
 
-    result = runner.invoke(main_khal, color=True)
+    result = runner.invoke(main_khal, ['list'], color=True)
     assert not result.exception
     assert '\x1b[34m' in result.output
 
 
 def test_days(runner):
-    runner = runner(default_command='list', days=9)
+    runner = runner(days=9)
 
-    when = (datetime.datetime.now() + timedelta(days=7)).strftime('%d.%m.%Y')
+    when = (dt.datetime.now() + dt.timedelta(days=7)).strftime('%d.%m.%Y')
     result = runner.invoke(main_khal, 'new {} 18:00 nextweek'.format(when).split())
     assert result.output == ''
     assert not result.exception
 
-    when = (datetime.datetime.now() + timedelta(days=30)).strftime('%d.%m.%Y')
+    when = (dt.datetime.now() + dt.timedelta(days=30)).strftime('%d.%m.%Y')
     result = runner.invoke(main_khal, 'new {} 18:00 nextmonth'.format(when).split())
     assert result.output == ''
     assert not result.exception
 
-    result = runner.invoke(main_khal)
+    result = runner.invoke(main_khal, ['list'])
     assert 'nextweek' in result.output
     assert 'nextmonth' not in result.output
     assert '18:00' in result.output
@@ -185,7 +177,7 @@
 
 def test_notstarted(runner):
     with freeze_time('2015-6-1 15:00'):
-        runner = runner(default_command='calendar', days=2)
+        runner = runner(days=2)
         for command in [
                 'new 30.5.2015 5.6.2015 long event',
                 'new 2.6.2015 4.6.2015 two day event',
@@ -248,8 +240,8 @@
 
 def test_calendar(runner):
     with freeze_time('2015-6-1'):
-        runner = runner(default_command='calendar', days=0)
-        result = runner.invoke(main_khal)
+        runner = runner(days=0)
+        result = runner.invoke(main_khal, ['calendar'])
         assert not result.exception
         assert result.exit_code == 0
         output = '\n'.join([
@@ -275,8 +267,8 @@
 
 def test_long_calendar(runner):
     with freeze_time('2015-6-1'):
-        runner = runner(default_command='calendar', days=100)
-        result = runner.invoke(main_khal)
+        runner = runner(days=100)
+        result = runner.invoke(main_khal, ['calendar'])
         assert not result.exception
         assert result.exit_code == 0
         output = '\n'.join([
@@ -305,24 +297,16 @@
 
 
 def test_default_command_empty(runner):
-    runner = runner(default_command='', days=2)
+    runner = runner(days=2)
 
     result = runner.invoke(main_khal)
     assert result.exception
-    assert result.exit_code == 1
+    assert result.exit_code == 2
     assert result.output.startswith('Usage: ')
 
 
-def test_default_command_nonempty(runner):
-    runner = runner(default_command='list', days=2)
-
-    result = runner.invoke(main_khal)
-    assert not result.exception
-    assert result.output == 'No events\n'
-
-
 def test_invalid_calendar(runner):
-    runner = runner(default_command='', days=2)
+    runner = runner(days=2)
     result = runner.invoke(
         main_khal, ['new'] + '-a one 18:00 myevent'.split())
     assert not result.exception
@@ -334,7 +318,7 @@
 
 
 def test_attach_calendar(runner):
-    runner = runner(default_command='calendar', days=2)
+    runner = runner(days=2)
     result = runner.invoke(main_khal, ['printcalendars'])
     assert set(result.output.split('\n')[:3]) == set(['one', 'two', 'three'])
     assert not result.exception
@@ -351,24 +335,24 @@
     'BEGIN:VCALENDAR\nBEGIN:VTODO\nEND:VTODO\nEND:VCALENDAR\n'
 ])
 def test_no_vevent(runner, tmpdir, contents):
-    runner = runner(default_command='list', days=2)
+    runner = runner(days=2)
     broken_item = runner.calendars['one'].join('broken_item.ics')
     broken_item.write(contents.encode('utf-8'), mode='wb')
 
-    result = runner.invoke(main_khal)
+    result = runner.invoke(main_khal, ['list'])
     assert not result.exception
     assert 'No events' in result.output
 
 
 def test_printformats(runner):
-    runner = runner(default_command='printformats', days=2)
+    runner = runner(days=2)
 
-    result = runner.invoke(main_khal)
-    assert '\n'.join(['longdatetimeformat: 21.12.2013 10:09',
-                      'datetimeformat: 21.12. 10:09',
+    result = runner.invoke(main_khal, ['printformats'])
+    assert '\n'.join(['longdatetimeformat: 21.12.2013 21:45',
+                      'datetimeformat: 21.12. 21:45',
                       'longdateformat: 21.12.2013',
                       'dateformat: 21.12.',
-                      'timeformat: 10:09',
+                      'timeformat: 21:45',
                       '']) == result.output
     assert not result.exception
 
@@ -376,9 +360,9 @@
 # "see #810"
 @pytest.mark.xfail
 def test_repeating(runner):
-    runner = runner(default_command='list', days=2)
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
-    end_date = datetime.datetime.now() + datetime.timedelta(days=10)
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
+    end_date = dt.datetime.now() + dt.timedelta(days=10)
     result = runner.invoke(
         main_khal, 'new {} 18:00 myevent -r weekly -u {}'.format(
             now, end_date.strftime('%d.%m.%Y')).split())
@@ -387,9 +371,9 @@
 
 
 def test_at(runner):
-    runner = runner(default_command='calendar', days=2)
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
-    end_date = datetime.datetime.now() + datetime.timedelta(days=10)
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
+    end_date = dt.datetime.now() + dt.timedelta(days=10)
     result = runner.invoke(
         main_khal,
         'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split())
@@ -400,9 +384,9 @@
 
 
 def test_at_day_format(runner):
-    runner = runner(default_command='calendar', days=2)
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
-    end_date = datetime.datetime.now() + datetime.timedelta(days=10)
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
+    end_date = dt.datetime.now() + dt.timedelta(days=10)
     result = runner.invoke(
         main_khal,
         'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split())
@@ -413,12 +397,11 @@
 
 
 def test_list(runner):
-    runner = runner(default_command='calendar', days=2)
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
-    end_date = datetime.datetime.now() + datetime.timedelta(days=10)
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
     result = runner.invoke(
         main_khal,
-        'new {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split())
+        'new {} 18:00 myevent'.format(now).split())
     format = '{red}{start-end-time-style}{reset} {title} :: {description}'
     args = ['--color', 'list', '--format', format, '--day-format', 'header', '18:30']
     result = runner.invoke(main_khal, args)
@@ -428,8 +411,8 @@
 
 
 def test_search(runner):
-    runner = runner(default_command='calendar', days=2)
-    now = datetime.datetime.now().strftime('%d.%m.%Y')
+    runner = runner(days=2)
+    now = dt.datetime.now().strftime('%d.%m.%Y')
     result = runner.invoke(main_khal, 'new {} 18:00 myevent'.format(now).split())
     format = '{red}{start-end-time-style}{reset} {title} :: {description}'
     result = runner.invoke(main_khal, ['--color', 'search', '--format', format, 'myevent'])
@@ -487,6 +470,19 @@
     assert result.output == '09.04.-09.04. An Event\n'
 
 
+def test_import_proper_invalid_timezone(runner):
+    runner = runner()
+    result = runner.invoke(
+        main_khal, ['import', _get_ics_filepath('invalid_tzoffset')], input='0\ny\n')
+    assert result.output.startswith(
+        'warning: Invalid timezone offset encountered, timezone information may be wrong')
+    assert not result.exception
+    result = runner.invoke(main_khal, ['search', 'Event'])
+    assert result.output.startswith(
+        'warning: Invalid timezone offset encountered, timezone information may be wrong')
+    assert '02.12. 08:00-02.12. 09:30 Some event' in result.output
+
+
 def test_import_invalid_choice_and_prefix(runner):
     runner = runner()
     result = runner.invoke(main_khal, ['import', _get_ics_filepath('cal_d')], input='9\nth\ny\n')
@@ -497,20 +493,35 @@
     assert result.output == '09.04.-09.04. An Event\n'
 
 
-def test_import_from_stdin(runner):
+def test_import_from_stdin(runner, monkeypatch):
     ics_data = 'This is some really fake icalendar data'
 
-    with mock.patch('khal.controllers.import_ics') as mocked_import:
-        runner = runner()
-        result = runner.invoke(main_khal, ['import'], input=ics_data)
+    class FakeImport():
+        args, kwargs = None, None
+        call_count = 0
+
+        def clean(self):
+            self.args, self.kwargs = None, None
+
+        def import_ics(self, *args, **kwargs):
+            print('saving args')
+            print(args)
+            self.call_count += 1
+            self.args = args
+            self.kwargs = kwargs
+
+    importer = FakeImport()
+    monkeypatch.setattr('khal.controllers.import_ics', importer.import_ics)
+    runner = runner()
+    result = runner.invoke(main_khal, ['import'], input=ics_data)
 
     assert not result.exception
-    assert mocked_import.call_count == 1
-    assert mocked_import.call_args[1]['ics'] == ics_data
+    assert importer.call_count == 1
+    assert importer.kwargs['ics'] == ics_data
 
 
 def test_interactive_command(runner, monkeypatch):
-    runner = runner(default_command='list', days=2)
+    runner = runner(days=2)
     token = "hooray"
 
     def fake_ui(*a, **kw):
@@ -529,12 +540,12 @@
 
 
 def test_color_option(runner):
-    runner = runner(default_command='list', days=2)
+    runner = runner(days=2)
 
-    result = runner.invoke(main_khal, ['--no-color'])
+    result = runner.invoke(main_khal, ['--no-color', 'list'])
     assert result.output == 'No events\n'
 
-    result = runner.invoke(main_khal, ['--color'])
+    result = runner.invoke(main_khal, ['--color', 'list'])
     assert 'No events' in result.output
     assert result.output != 'No events\n'
 
@@ -645,18 +656,21 @@
 
 
 def test_print_ics_command(runner):
-    runner = runner(command='printics', days=2)
+    runner = runner()
     # Input is empty and loading from stdin
-    result = runner.invoke(main_khal, ['-'])
+    result = runner.invoke(main_khal, ['printics', '-'])
     assert result.exception
 
     # Non existing file
     result = runner.invoke(main_khal, ['printics', 'nonexisting_file'])
     assert result.exception
-    assert ('Error: Invalid value for "ics": Could not open file: ' \
-        in result.output or \
-            'Error: Invalid value for "[ICS]": Could not open file:' \
-            in result.output)
+    assert (
+        'Error: Invalid value for "ics": Could not open file: ' in result.output or
+        'Error: Invalid value for "[ICS]": Could not open file:'
+        in result.output or
+        'Error: Invalid value for \'ics\': Could not open file: ' in result.output or
+        'Error: Invalid value for \'[ICS]\': Could not open file:'
+        in result.output)
 
     # Run on test files
     result = runner.invoke(main_khal, ['printics', _get_ics_filepath('cal_d')])
@@ -665,7 +679,7 @@
     assert not result.exception
 
     # Test with some nice format strings
-    form = '{title}\t{description}\t{start}\t{start-long}\t{start-date}' \
+    form = '{uid}\t{title}\t{description}\t{start}\t{start-long}\t{start-date}' \
            '\t{start-date-long}\t{start-time}\t{end}\t{end-long}\t{end-date}' \
            '\t{end-date-long}\t{end-time}\t{repeat-symbol}\t{description}' \
            '\t{description-separator}\t{location}\t{calendar}' \
@@ -674,11 +688,11 @@
     result = runner.invoke(main_khal, [
         'printics', '-f', form, _get_ics_filepath('cal_dt_two_tz')])
     assert not result.exception
-    assert 24 == len(result.output.split('\t'))
+    assert 25 == len(result.output.split('\t'))
     result = runner.invoke(main_khal, [
         'printics', '-f', form, _get_ics_filepath('cal_dt_two_tz')])
     assert not result.exception
-    assert 24 == len(result.output.split('\t'))
+    assert 25 == len(result.output.split('\t'))
 
 
 def test_printics_read_from_stdin(runner):
@@ -818,6 +832,15 @@
     assert result.exit_code == 0
 
 
+def test_debug(runner):
+    runner = runner()
+    result = runner.invoke(main_khal, ['-v', 'debug', 'printformats'])
+    assert result.output.startswith('debug: khal 0.')
+    assert 'using the config file at' in result.output
+    assert 'debug: Using config:\ndebug: [calendars]' in result.output
+    assert not result.exception
+
+
 @freeze_time('2015-6-1 8:00')
 def test_new_interactive_extensive(runner):
     runner = runner(print_new='path', default_calendar=False)
diff -Nru khal-0.9.10/tests/configs/one_level_calendars.conf khal-0.10.2/tests/configs/one_level_calendars.conf
--- khal-0.9.10/tests/configs/one_level_calendars.conf	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/configs/one_level_calendars.conf	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,3 @@
+[calendars]
+path = /home/user/.nextcloud/
+type = discover
diff -Nru khal-0.9.10/tests/configs/small.conf khal-0.10.2/tests/configs/small.conf
--- khal-0.9.10/tests/configs/small.conf	2018-02-20 19:53:01.000000000 +0100
+++ khal-0.10.2/tests/configs/small.conf	2020-07-29 18:17:53.000000000 +0200
@@ -3,6 +3,7 @@
   [[home]]
     path = ~/.calendars/home/
     color = dark green
+    priority = 20
 
   [[work]]
     path = ~/.calendars/work/
diff -Nru khal-0.9.10/tests/configwizard_test.py khal-0.10.2/tests/configwizard_test.py
--- khal-0.9.10/tests/configwizard_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/configwizard_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,6 +1,5 @@
 import click
 import pytest
-
 from khal.configwizard import validate_int
 
 
diff -Nru khal-0.9.10/tests/conftest.py khal-0.10.2/tests/conftest.py
--- khal-0.9.10/tests/conftest.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/conftest.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,13 +1,13 @@
+import logging
 import os
 from time import sleep
 
 import pytest
 import pytz
-
 from khal.khalendar import CalendarCollection
 from khal.khalendar.vdir import Vdir
 
-from .utils import LOCALE_BERLIN, example_cals, cal1
+from .utils import LOCALE_BERLIN, cal1, example_cals
 
 
 @pytest.fixture
@@ -25,6 +25,21 @@
     return coll, vdirs
 
 
+ at pytest.fixture
+def coll_vdirs_birthday(tmpdir):
+    calendars, vdirs = dict(), dict()
+    for name in example_cals:
+        path = str(tmpdir) + '/' + name
+        os.makedirs(path, mode=0o770)
+        readonly = True if name == 'a_calendar' else False
+        calendars[name] = {'name': name, 'path': path, 'color': 'dark blue',
+                           'readonly': readonly, 'unicode_symbols': True, 'ctype': 'birthdays'}
+        vdirs[name] = Vdir(path, '.vcf')
+    coll = CalendarCollection(calendars=calendars, dbpath=':memory:', locale=LOCALE_BERLIN)
+    coll.default_calendar_name = cal1
+    return coll, vdirs
+
+
 @pytest.fixture(autouse=True)
 def never_echo_bytes(monkeypatch):
     '''Click's echo function will not strip colorcodes if we call `click.echo`
@@ -95,3 +110,11 @@
     """Return the version of pytz as a tuple."""
     year, month = pytz.__version__.split('.')
     return int(year), int(month)
+
+
+ at pytest.fixture
+def fix_caplog(monkeypatch):
+    """Temporarily undoes the logging setup by click-log such that the caplog fixture can be used"""
+    logger = logging.getLogger('khal')
+    monkeypatch.setattr(logger, 'handlers', [])
+    monkeypatch.setattr(logger, 'propagate', True)
diff -Nru khal-0.9.10/tests/controller_test.py khal-0.10.2/tests/controller_test.py
--- khal-0.9.10/tests/controller_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/controller_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,16 +1,14 @@
 import datetime as dt
 from textwrap import dedent
 
-from freezegun import freeze_time
 import pytest
-
-from khal.khalendar.vdir import Item
-from khal.controllers import import_ics, khal_list, start_end_from_daterange
+from freezegun import freeze_time
 from khal import exceptions
+from khal.controllers import import_ics, khal_list, start_end_from_daterange
+from khal.khalendar.vdir import Item
 
-from .utils import _get_text
 from . import utils
-
+from .utils import _get_text
 
 today = dt.date.today()
 yesterday = today - dt.timedelta(days=1)
diff -Nru khal-0.9.10/tests/event_test.py khal-0.10.2/tests/event_test.py
--- khal-0.9.10/tests/event_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/event_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,17 +1,14 @@
-from datetime import datetime, date, timedelta
-import pytz
+import datetime as dt
 
 import pytest
+import pytz
 from freezegun import freeze_time
-
 from icalendar import vRecur, vText
+from khal.khalendar.event import (AllDayEvent, Event, FloatingEvent,
+                                  LocalizedEvent, create_timezone)
 
-from khal.khalendar.event import Event, AllDayEvent, LocalizedEvent, FloatingEvent, \
-    create_timezone
-
-from .utils import normalize_component, _get_text, \
-    LOCALE_BERLIN, LOCALE_MIXED, LOCALE_BOGOTA, \
-    BERLIN, NEW_YORK, BOGOTA, GMTPLUS3
+from .utils import (BERLIN, BOGOTA, GMTPLUS3, LOCALE_BERLIN, LOCALE_BOGOTA,
+                    LOCALE_MIXED, NEW_YORK, _get_text, normalize_component)
 
 EVENT_KWARGS = {'calendar': 'foobar', 'locale': LOCALE_BERLIN}
 
@@ -32,19 +29,19 @@
 
 def test_raw_dt():
     event_dt = _get_text('event_dt_simple')
-    start = BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    end = BERLIN.localize(datetime(2014, 4, 9, 10, 30))
+    start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
     event = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS)
     with freeze_time('2016-1-1'):
         assert normalize_component(event.raw) == \
             normalize_component(_get_text('event_dt_simple_inkl_vtimezone'))
 
     event = Event.fromString(event_dt, **EVENT_KWARGS)
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-10:30 An Event\x1b[0m'
     assert event.recurring is False
-    assert event.duration == timedelta(hours=1)
+    assert event.duration == dt.timedelta(hours=1)
     assert event.uid == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU'
     assert event.organizer == ''
 
@@ -55,7 +52,7 @@
     event.update_summary('A not so simple Event')
     event.update_description('Everything has changed')
     event.update_location('anywhere')
-    event.update_categories('meeting')
+    event.update_categories(['meeting'])
     assert normalize_component(event.raw) == normalize_component(event_updated.raw)
 
 
@@ -64,7 +61,7 @@
     event = Event.fromString(_get_text('event_dt_no_end'), **EVENT_KWARGS)
     # TODO make sure the event also gets converted to an all day event, as we
     # usually do
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 12)) == \
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 12)) == \
         '16.01.2016 08:00-17.01.2016 08:00 Test\x1b[0m'
 
 
@@ -95,7 +92,7 @@
 def test_update_remove_categories():
     event = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS)
     event_nocat = Event.fromString(_get_text('event_dt_simple_nocat'), **EVENT_KWARGS)
-    event.update_categories('    ')
+    event.update_categories([])
     assert normalize_component(event.raw) == normalize_component(event_nocat.raw)
 
 
@@ -103,8 +100,8 @@
     event_d = _get_text('event_d')
     event = Event.fromString(event_d, **EVENT_KWARGS)
     assert event.raw.split('\r\n') == _get_text('cal_d').split('\n')
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == ' An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == '09.04.2014 An Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == ' An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == '09.04.2014 An Event\x1b[0m'
 
 
 def test_update_sequence():
@@ -126,28 +123,28 @@
     event_d = _get_text('event_d')
     event = Event.fromString(event_d, **EVENT_KWARGS)
     assert isinstance(event, AllDayEvent)
-    start = BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    end = BERLIN.localize(datetime(2014, 4, 9, 10, 30))
+    start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
     event.update_start_end(start, end)
     assert isinstance(event, LocalizedEvent)
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-10:30 An Event\x1b[0m'
     analog_event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS)
     assert normalize_component(event.raw) == normalize_component(analog_event.raw)
 
     with pytest.raises(ValueError):
-        event.update_start_end(start, date(2014, 4, 9))
+        event.update_start_end(start, dt.date(2014, 4, 9))
 
 
 def test_update_event_d():
     event_d = _get_text('event_d')
     event = Event.fromString(event_d, **EVENT_KWARGS)
-    event.update_start_end(date(2014, 4, 20), date(2014, 4, 22))
-    assert event.format(LIST_FORMAT, date(2014, 4, 20)) == '↦ An Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 21)) == '↔ An Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 22)) == '⇥ An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 20)) == \
+    event.update_start_end(dt.date(2014, 4, 20), dt.date(2014, 4, 22))
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 20)) == '↦ An Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 21)) == '↔ An Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 22)) == '⇥ An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 20)) == \
         '20.04.2014-22.04.2014 An Event\x1b[0m'
     assert 'DTSTART;VALUE=DATE:20140420' in event.raw.split('\r\n')
     assert 'DTEND;VALUE=DATE:20140423' in event.raw.split('\r\n')
@@ -156,14 +153,14 @@
 def test_update_event_duration():
     event_dur = _get_text('event_dt_duration')
     event = Event.fromString(event_dur, **EVENT_KWARGS)
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
-    assert event.duration == timedelta(hours=1)
-    event.update_start_end(BERLIN.localize(datetime(2014, 4, 9, 8, 0)),
-                           BERLIN.localize(datetime(2014, 4, 9, 12, 0)))
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 8, 0))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 12, 0))
-    assert event.duration == timedelta(hours=4)
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
+    assert event.duration == dt.timedelta(hours=1)
+    event.update_start_end(BERLIN.localize(dt.datetime(2014, 4, 9, 8, 0)),
+                           BERLIN.localize(dt.datetime(2014, 4, 9, 12, 0)))
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 8, 0))
+    assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 12, 0))
+    assert event.duration == dt.timedelta(hours=4)
 
 
 def test_dt_two_tz():
@@ -174,13 +171,13 @@
     with freeze_time('2016-02-16 12:00:00'):
         assert normalize_component(cal_dt_two_tz) == normalize_component(event.raw)
 
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == NEW_YORK.localize(datetime(2014, 4, 9, 10, 30))
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end == NEW_YORK.localize(dt.datetime(2014, 4, 9, 10, 30))
     # local (Berlin) time!
-    assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 16, 30))
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-16:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 16, 30))
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-16:30 An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-16:30 An Event\x1b[0m'
 
 
@@ -188,11 +185,12 @@
     """event has no end, but duration"""
     event_dt_duration = _get_text('event_dt_duration')
     event = Event.fromString(event_dt_duration, **EVENT_KWARGS)
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-10:30 An Event\x1b[0m'
+    assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m'
 
 
 def test_event_dt_floating():
@@ -200,35 +198,37 @@
     event_str = _get_text('event_dt_floating')
     event = Event.fromString(event_str, **EVENT_KWARGS)
     assert isinstance(event, FloatingEvent)
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m'
+    assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-10:30 An Event\x1b[0m'
-    assert event.start == datetime(2014, 4, 9, 9, 30)
-    assert event.end == datetime(2014, 4, 9, 10, 30)
-    assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
+    assert event.start == dt.datetime(2014, 4, 9, 9, 30)
+    assert event.end == dt.datetime(2014, 4, 9, 10, 30)
+    assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
 
     event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED)
-    assert event.start == datetime(2014, 4, 9, 9, 30)
-    assert event.end == datetime(2014, 4, 9, 10, 30)
-    assert event.start_local == BOGOTA.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end_local == BOGOTA.localize(datetime(2014, 4, 9, 10, 30))
+    assert event.start == dt.datetime(2014, 4, 9, 9, 30)
+    assert event.end == dt.datetime(2014, 4, 9, 10, 30)
+    assert event.start_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 10, 30))
 
 
 def test_event_dt_tz_missing():
     """localized event DTSTART;TZID=foo, but VTIMEZONE components missing"""
     event_str = _get_text('event_dt_local_missing_tz')
     event = Event.fromString(event_str, **EVENT_KWARGS)
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
-    assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
+    assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
+    assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m'
 
     event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED)
-    assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30))
-    assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30))
-    assert event.start_local == BOGOTA.localize(datetime(2014, 4, 9, 2, 30))
-    assert event.end_local == BOGOTA.localize(datetime(2014, 4, 9, 3, 30))
+    assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30))
+    assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
+    assert event.start_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 2, 30))
+    assert event.end_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 3, 30))
 
 
 def test_event_dt_rr():
@@ -236,27 +236,27 @@
     event = Event.fromString(event_dt_rr, **EVENT_KWARGS)
     assert event.recurring is True
 
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event ⟳\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event ⟳\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 09:30-10:30 An Event ⟳\x1b[0m'
-    assert event.format('{repeat-pattern}', date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m'
+    assert event.format('{repeat-pattern}', dt.date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m'
 
 
 def test_event_d_rr():
     event_d_rr = _get_text('event_d_rr')
     event = Event.fromString(event_d_rr, **EVENT_KWARGS)
     assert event.recurring is True
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == ' Another Event ⟳\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == ' Another Event ⟳\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \
         '09.04.2014 Another Event ⟳\x1b[0m'
-    assert event.format('{repeat-pattern}', date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m'
+    assert event.format('{repeat-pattern}', dt.date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m'
 
-    start = date(2014, 4, 10)
-    end = date(2014, 4, 11)
+    start = dt.date(2014, 4, 10)
+    end = dt.date(2014, 4, 11)
     event = Event.fromString(event_d_rr, start=start, end=end, **EVENT_KWARGS)
     assert event.recurring is True
-    assert event.format(LIST_FORMAT, date(2014, 4, 10)) == ' Another Event ⟳\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 10)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == ' Another Event ⟳\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \
         '10.04.2014 Another Event ⟳\x1b[0m'
 
 
@@ -269,22 +269,23 @@
 def test_event_d_long():
     event_d_long = _get_text('event_d_long')
     event = Event.fromString(event_d_long, **EVENT_KWARGS)
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '↦ Another Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '↔ Another Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 11)) == '⇥ Another Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 12)) == ' Another Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 16)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '↦ Another Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '↔ Another Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 11)) == '⇥ Another Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == ' Another Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 16)) == \
         '09.04.2014-11.04.2014 Another Event\x1b[0m'
+    assert event.format('{duration}', relative_to=dt.date(2014, 4, 11)) == '3d\x1b[0m'
 
 
 def test_event_d_two_days():
     event_d_long = _get_text('event_d_long')
     event = Event.fromString(event_d_long, **EVENT_KWARGS)
-    event.update_start_end(date(2014, 4, 9), date(2014, 4, 10))
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '↦ Another Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '⇥ Another Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 12)) == ' Another Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 10)) == \
+    event.update_start_end(dt.date(2014, 4, 9), dt.date(2014, 4, 10))
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '↦ Another Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '⇥ Another Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == ' Another Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \
         '09.04.2014-10.04.2014 Another Event\x1b[0m'
 
 
@@ -292,10 +293,10 @@
     event_dt_long = _get_text('event_dt_long')
     event = Event.fromString(event_dt_long, **EVENT_KWARGS)
 
-    assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30→ An Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '↔ An Event\x1b[0m'
-    assert event.format(LIST_FORMAT, date(2014, 4, 12)) == '→10:30 An Event\x1b[0m'
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 10)) == \
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30→ An Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '↔ An Event\x1b[0m'
+    assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == '→10:30 An Event\x1b[0m'
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \
         '09.04.2014 09:30-12.04.2014 10:30 An Event\x1b[0m'
 
 
@@ -311,7 +312,7 @@
         )
 
     assert normalize_component(event.raw) == normalize_component(cal_no_dst)
-    assert event.format(SEARCH_FORMAT, date(2014, 4, 10)) == \
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \
         '09.04.2014 09:30-10:30 An Event\x1b[0m'
 
 
@@ -333,6 +334,13 @@
         '''END:VCALENDAR\r\n'''])
 
 
+def test_zulu_events():
+    """test if events in Zulu time are correctly recognized as localized events"""
+    event = Event.fromString(_get_text('event_dt_simple_zulu'), **EVENT_KWARGS)
+    assert type(event) == LocalizedEvent
+    assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 11, 30))
+
+
 def test_dtend_equals_dtstart():
     event = Event.fromString(_get_text('event_d_same_start_end'),
                              calendar='foobar', locale=LOCALE_BERLIN)
@@ -351,10 +359,10 @@
 def test_cancelled_instance():
     orig_event_str = _get_text('event_rrule_recuid_cancelled')
     event = Event.fromString(orig_event_str, ref='1405314000', **EVENT_KWARGS)
-    assert event.format(SEARCH_FORMAT, date(2014, 7, 14)) == \
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 7, 14)) == \
         'CANCELLED 14.07.2014 07:00-12:00 Arbeit ⟳\x1b[0m'
     event = Event.fromString(orig_event_str, ref='PROTO', **EVENT_KWARGS)
-    assert event.format(SEARCH_FORMAT, date(2014, 7, 14)) == \
+    assert event.format(SEARCH_FORMAT, dt.date(2014, 7, 14)) == \
         '30.06.2014 07:00-12:00 Arbeit ⟳\x1b[0m'
 
 
@@ -381,9 +389,9 @@
 def test_remove_instance_from_rrule():
     """removing an instance from a recurring event"""
     event = Event.fromString(_get_text('event_dt_rr'), **EVENT_KWARGS)
-    event.delete_instance(datetime(2014, 4, 10, 9, 30))
+    event.delete_instance(dt.datetime(2014, 4, 10, 9, 30))
     assert 'EXDATE:20140410T093000' in event.raw.split('\r\n')
-    event.delete_instance(datetime(2014, 4, 12, 9, 30))
+    event.delete_instance(dt.datetime(2014, 4, 12, 9, 30))
     assert 'EXDATE:20140410T093000,20140412T093000' in event.raw.split('\r\n')
 
 
@@ -391,7 +399,7 @@
     """removing an instance from a recurring event"""
     event = Event.fromString(_get_text('event_dt_rd'), **EVENT_KWARGS)
     assert 'RDATE' in event.raw
-    event.delete_instance(datetime(2014, 4, 10, 9, 30))
+    event.delete_instance(dt.datetime(2014, 4, 10, 9, 30))
     assert 'RDATE' not in event.raw
 
 
@@ -399,7 +407,7 @@
     """removing an instance from a recurring event which has two RDATE props"""
     event = Event.fromString(_get_text('event_dt_two_rd'), **EVENT_KWARGS)
     assert event.raw.count('RDATE') == 2
-    event.delete_instance(datetime(2014, 4, 10, 9, 30))
+    event.delete_instance(dt.datetime(2014, 4, 10, 9, 30))
     assert event.raw.count('RDATE') == 1
     assert 'RDATE:20140411T093000,20140412T093000' in event.raw.split('\r\n')
 
@@ -409,7 +417,7 @@
     with the same UID (which we call `recuid` here"""
     event = Event.fromString(_get_text('event_rrule_recuid'), **EVENT_KWARGS)
     assert event.raw.split('\r\n').count('UID:event_rrule_recurrence_id') == 2
-    event.delete_instance(BERLIN.localize(datetime(2014, 7, 7, 7, 0)))
+    event.delete_instance(BERLIN.localize(dt.datetime(2014, 7, 7, 7, 0)))
     assert event.raw.split('\r\n').count('UID:event_rrule_recurrence_id') == 1
     assert 'EXDATE;TZID=Europe/Berlin:20140707T070000' in event.raw.split('\r\n')
 
@@ -418,12 +426,12 @@
     """test if events ending at 00:00/24:00 are displayed as ending the day
     before"""
     event_dt = _get_text('event_dt_simple')
-    start = BERLIN.localize(datetime(2014, 4, 9, 19, 30))
-    end = BERLIN.localize(datetime(2014, 4, 10))
+    start = BERLIN.localize(dt.datetime(2014, 4, 9, 19, 30))
+    end = BERLIN.localize(dt.datetime(2014, 4, 10))
     event = Event.fromString(event_dt, **EVENT_KWARGS)
     event.update_start_end(start, end)
     format_ = '{start-end-time-style} {title}{repeat-symbol}'
-    assert event.format(format_, date(2014, 4, 9)) == '19:30-24:00 An Event\x1b[0m'
+    assert event.format(format_, dt.date(2014, 4, 9)) == '19:30-24:00 An Event\x1b[0m'
 
 
 def test_invalid_format_string():
@@ -431,21 +439,21 @@
     event = Event.fromString(event_dt, **EVENT_KWARGS)
     format_ = '{start-end-time-style} {title}{foo}'
     with pytest.raises(KeyError):
-        event.format(format_, date(2014, 4, 9))
+        event.format(format_, dt.date(2014, 4, 9))
 
 
 def test_format_colors():
     event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS)
     format_ = '{red}{title}{reset}'
-    assert event.format(format_, date(2014, 4, 9)) == '\x1b[31mAn Event\x1b[0m\x1b[0m'
-    assert event.format(format_, date(2014, 4, 9), colors=False) == 'An Event'
+    assert event.format(format_, dt.date(2014, 4, 9)) == '\x1b[31mAn Event\x1b[0m\x1b[0m'
+    assert event.format(format_, dt.date(2014, 4, 9), colors=False) == 'An Event'
 
 
 def test_event_alarm():
     event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS)
     assert event.alarms == []
-    event.update_alarms([(timedelta(-1, 82800), 'new event')])
-    assert event.alarms == [(timedelta(-1, 82800), vText('new event'))]
+    event.update_alarms([(dt.timedelta(-1, 82800), 'new event')])
+    assert event.alarms == [(dt.timedelta(-1, 82800), vText('new event'))]
 
 
 def test_create_timezone_static():
@@ -463,8 +471,8 @@
         b'END:VTIMEZONE',
     ]
     event_dt = _get_text('event_dt_simple')
-    start = GMTPLUS3.localize(datetime(2014, 4, 9, 9, 30))
-    end = GMTPLUS3.localize(datetime(2014, 4, 9, 10, 30))
+    start = GMTPLUS3.localize(dt.datetime(2014, 4, 9, 9, 30))
+    end = GMTPLUS3.localize(dt.datetime(2014, 4, 9, 10, 30))
     event = Event.fromString(event_dt, **EVENT_KWARGS)
     event.update_start_end(start, end)
     with freeze_time('2016-1-1'):
diff -Nru khal-0.9.10/tests/icalendar_test.py khal-0.10.2/tests/icalendar_test.py
--- khal-0.9.10/tests/icalendar_test.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/icalendar_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,80 @@
+import icalendar
+import random
+import textwrap
+
+from khal.icalendar import split_ics
+
+from .utils import (LOCALE_BERLIN, _get_text, normalize_component)
+
+
+def _get_TZIDs(lines):
+    """from a list of strings, get all unique strings that start with TZID"""
+    return sorted((line for line in lines if line.startswith('TZID')))
+
+
+def test_normalize_component():
+    assert normalize_component(textwrap.dedent("""
+    BEGIN:VEVENT
+    DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000
+    END:VEVENT
+    """)) != normalize_component(textwrap.dedent("""
+    BEGIN:VEVENT
+    DTSTART;TZID=Oyrope/Berlin;VALUE=DATE-TIME:20140409T093000
+    END:VEVENT
+    """))
+
+
+def test_split_ics():
+    cal = _get_text('cal_lots_of_timezones')
+    vevents = split_ics(cal)
+
+    vevents0 = vevents[0].split('\r\n')
+    vevents1 = vevents[1].split('\r\n')
+
+    part0 = _get_text('part0').split('\n')
+    part1 = _get_text('part1').split('\n')
+
+    assert _get_TZIDs(vevents0) == _get_TZIDs(part0)
+    assert _get_TZIDs(vevents1) == _get_TZIDs(part1)
+
+    assert sorted(vevents0) == sorted(part0)
+    assert sorted(vevents1) == sorted(part1)
+
+
+def test_split_ics_random_uid():
+    random.seed(123)
+    cal = _get_text('cal_lots_of_timezones')
+    vevents = split_ics(cal, random_uid=True)
+
+    part0 = _get_text('part0').split('\n')
+    part1 = _get_text('part1').split('\n')
+
+    for item in icalendar.Calendar.from_ical(vevents[0]).walk():
+        if item.name == 'VEVENT':
+            assert item['UID'] == 'DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1'
+    for item in icalendar.Calendar.from_ical(vevents[1]).walk():
+        if item.name == 'VEVENT':
+            assert item['UID'] == '4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB'
+
+    # after replacing the UIDs, everything should be as above
+    vevents0 = vevents[0].replace('DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1', '123').split('\r\n')
+    vevents1 = vevents[1].replace('4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB', 'abcde').split('\r\n')
+
+    assert _get_TZIDs(vevents0) == _get_TZIDs(part0)
+    assert _get_TZIDs(vevents1) == _get_TZIDs(part1)
+
+    assert sorted(vevents0) == sorted(part0)
+    assert sorted(vevents1) == sorted(part1)
+
+
+def test_split_ics_missing_timezone():
+    """testing if we detect the missing timezone in splitting"""
+    cal = _get_text('event_dt_local_missing_tz')
+    split_ics(cal, random_uid=True, default_timezone=LOCALE_BERLIN['default_timezone'])
+
+
+def test_windows_timezone(caplog):
+    """Test if a windows tz format works"""
+    cal = _get_text("tz_windows_format")
+    split_ics(cal)
+    assert "Cannot find timezone `Pacific/Auckland`" not in caplog.text
diff -Nru khal-0.9.10/tests/ics/event_dt_multi_uid.ics khal-0.10.2/tests/ics/event_dt_multi_uid.ics
--- khal-0.9.10/tests/ics/event_dt_multi_uid.ics	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/ics/event_dt_multi_uid.ics	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,24 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN
+BEGIN:VEVENT
+SUMMARY:Test original
+DTSTART;VALUE=DATE-TIME:20171228T190000
+DTEND;VALUE=DATE-TIME:20171228T200000
+DTSTAMP;VALUE=DATE-TIME:20171228T190731Z
+UID:abc
+SEQUENCE:0
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Test next
+DTSTART;VALUE=DATE-TIME:20180104T220000
+DTEND;VALUE=DATE-TIME:20180104T230000
+UID:def
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Test last
+DTSTART;VALUE=DATE-TIME:20180104T220000
+DTEND;VALUE=DATE-TIME:20180104T230000
+UID:def
+END:VEVENT
+END:VCALENDAR
diff -Nru khal-0.9.10/tests/ics/invalid_tzoffset.ics khal-0.10.2/tests/ics/invalid_tzoffset.ics
--- khal-0.9.10/tests/ics/invalid_tzoffset.ics	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/ics/invalid_tzoffset.ics	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,35 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+BEGIN:STANDARD
+DTSTART:18930401T000000
+RDATE;VALUE=DATE-TIME:18930401T000000
+TZNAME:CEST
+TZOFFSETFROM:+5328
+TZOFFSETTO:+0100
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19810329T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19961027T030000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+CREATED:20121120T195219Z
+UID:1234567890DC8-468D-B140-D2B41B1013E9
+DTEND;TZID=Europe/Berlin:20121202T093000
+SUMMARY:Some event
+DTSTART;TZID=Europe/Berlin:20121202T080000
+DTSTAMP:20121120T195239Z
+END:VEVENT
+END:VCALENDAR
diff -Nru khal-0.9.10/tests/ics/tz_windows_format.ics khal-0.10.2/tests/ics/tz_windows_format.ics
--- khal-0.9.10/tests/ics/tz_windows_format.ics	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/ics/tz_windows_format.ics	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,26 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN
+BEGIN:VTIMEZONE
+TZID:New Zealand Standard Time
+BEGIN:STANDARD
+DTSTART;VALUE=DATE-TIME:20191027T020000
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART;VALUE=DATE-TIME:20200329T030000
+TZNAME:CEST
+TZOFFSETFROM:+1200
+TZOFFSETTO:+1300
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+SUMMARY:Event with Windows TZ name
+DTSTART;TZID=New Zealand Standard Time;VALUE=DATE-TIME:20191120T130000
+DTEND;TZID=New Zealand Standard Time;VALUE=DATE-TIME:20191120T143000
+DTSTAMP;VALUE=DATE-TIME:20191105T094904Z
+UID:PERSOYPOYGVR55JMICVAFVDWWBIKPC9PTAG6SN4B2YT
+END:VEVENT
+END:VCALENDAR
diff -Nru khal-0.9.10/tests/khalendar_test.py khal-0.10.2/tests/khalendar_test.py
--- khal-0.9.10/tests/khalendar_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/khalendar_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,25 +1,29 @@
-from datetime import datetime, date, timedelta, time
+import datetime as dt
+import logging
 import os
-from time import sleep
 from textwrap import dedent
+from time import sleep
 
-import pytest
-
-from khal.khalendar.vdir import Item
-
+import khal.khalendar.exceptions
 import khal.utils
+import pytest
+from freezegun import freeze_time
+from khal import icalendar as icalendar_helpers
 from khal.khalendar import CalendarCollection
-from khal.khalendar.event import Event
 from khal.khalendar.backend import CouldNotCreateDbDir
-import khal.khalendar.exceptions
-from .utils import _get_text, cal1, cal2, cal3, normalize_component
+from khal.khalendar.event import Event
+from khal.khalendar.vdir import Item
+
 from . import utils
+from .utils import (_get_text, cal1, cal2, cal3, normalize_component, DumbItem,
+                    BERLIN, LONDON, SYDNEY, LOCALE_SYDNEY, LOCALE_BERLIN)
 
-from freezegun import freeze_time
+today = dt.date.today()
+yesterday = today - dt.timedelta(days=1)
+tomorrow = today + dt.timedelta(days=1)
 
-today = date.today()
-yesterday = today - timedelta(days=1)
-tomorrow = today + timedelta(days=1)
+aday = dt.date(2014, 4, 9)
+bday = dt.date(2014, 4, 10)
 
 event_allday_template = """BEGIN:VEVENT
 SEQUENCE:0
@@ -94,36 +98,27 @@
 class TestVdirsyncerCompat(object):
     def test_list(self, coll_vdirs):
         coll, vdirs = coll_vdirs
-        event = Event.fromString(event_d, calendar=cal1, locale=utils.LOCALE_BERLIN)
+        event = Event.fromString(_get_text('event_d'), calendar=cal1, locale=LOCALE_BERLIN)
         assert event.etag is None
         assert event.href is None
         coll.new(event)
         assert event.etag is not None
         assert event.href == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU.ics'
-        event = Event.fromString(event_today, calendar=cal1, locale=utils.LOCALE_BERLIN)
+        event = Event.fromString(event_today, calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event)
-        hrefs = sorted(href for href, uid in coll._backend.list(cal1))
-        assert set(str(coll._backend.get(href, calendar=cal1).uid) for href in hrefs) == set((
+        hrefs = sorted(href for href, etag in coll._backend.list(cal1))
+        assert set(str(coll.get_event(href, calendar=cal1).uid) for href in hrefs) == set((
             'uid3 at host1.com',
             'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU',
         ))
 
 
-aday = date(2014, 4, 9)
-bday = date(2014, 4, 10)
-
-
-event_dt = _get_text('event_dt_simple')
-event_d = _get_text('event_d')
-event_d_no_value = _get_text('event_d_no_value')
-
-
 class TestCollection(object):
 
-    astart = datetime.combine(aday, time.min)
-    aend = datetime.combine(aday, time.max)
-    bstart = datetime.combine(bday, time.min)
-    bend = datetime.combine(bday, time.max)
+    astart = dt.datetime.combine(aday, dt.time.min)
+    aend = dt.datetime.combine(aday, dt.time.max)
+    bstart = dt.datetime.combine(bday, dt.time.min)
+    bend = dt.datetime.combine(bday, dt.time.max)
     astart_berlin = utils.BERLIN.localize(astart)
     aend_berlin = utils.BERLIN.localize(aend)
     bstart_berlin = utils.BERLIN.localize(bstart)
@@ -133,14 +128,14 @@
         calendars = {
             'foobar': {'name': 'foobar', 'path': str(tmpdir), 'readonly': True},
             'home': {'name': 'home', 'path': str(tmpdir)},
-            'work': {'name': 'work', 'path': str(tmpdir), 'readonly': True},
+            "Dad's Calendar": {'name': "Dad's calendar", 'path': str(tmpdir), 'readonly': True},
         }
         coll = CalendarCollection(
-            calendars=calendars, locale=utils.LOCALE_BERLIN, dbpath=':memory:',
+            calendars=calendars, locale=LOCALE_BERLIN, dbpath=':memory:',
         )
         assert coll.default_calendar_name is None
         with pytest.raises(ValueError):
-            coll.default_calendar_name = 'work'
+            coll.default_calendar_name = "Dad's calendar"
         assert coll.default_calendar_name is None
         with pytest.raises(ValueError):
             coll.default_calendar_name = 'unknownstuff'
@@ -151,8 +146,8 @@
 
     def test_empty(self, coll_vdirs):
         coll, vdirs = coll_vdirs
-        start = datetime.combine(today, time.min)
-        end = datetime.combine(today, time.max)
+        start = dt.datetime.combine(today, dt.time.min)
+        end = dt.datetime.combine(today, dt.time.max)
         assert list(coll.get_floating(start, end)) == list()
         assert list(coll.get_localized(utils.BERLIN.localize(start),
                                        utils.BERLIN.localize(end))) == list()
@@ -160,8 +155,9 @@
     def test_insert(self, coll_vdirs):
         """insert a localized event"""
         coll, vdirs = coll_vdirs
-        event = Event.fromString(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN)
-        coll.new(event, cal1)
+        coll.new(
+            Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN),
+            cal1)
         events = list(coll.get_localized(self.astart_berlin, self.aend_berlin))
         assert len(events) == 1
         assert events[0].color == 'dark blue'
@@ -180,7 +176,7 @@
     def test_insert_d(self, coll_vdirs):
         """insert a floating event"""
         coll, vdirs = coll_vdirs
-        event = Event.fromString(event_d, calendar=cal1, locale=utils.LOCALE_BERLIN)
+        event = Event.fromString(_get_text('event_d'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         events = list(coll.get_events_on(aday))
         assert len(events) == 1
@@ -194,9 +190,10 @@
     def test_insert_d_no_value(self, coll_vdirs):
         """insert a date event with no VALUE=DATE option"""
         coll, vdirs = coll_vdirs
-
-        event = Event.fromString(event_d_no_value, calendar=cal1, locale=utils.LOCALE_BERLIN)
-        coll.new(event, cal1)
+        coll.new(
+            Event.fromString(
+                _get_text('event_d_no_value'), calendar=cal1, locale=LOCALE_BERLIN),
+            cal1)
         events = list(coll.get_events_on(aday))
         assert len(events) == 1
         assert events[0].calendar == cal1
@@ -209,7 +206,7 @@
         """test getting an event by its href"""
         coll, vdirs = coll_vdirs
         event = Event.fromString(
-            event_dt, href='xyz.ics', calendar=cal1, locale=utils.LOCALE_BERLIN,
+            _get_text('event_dt_simple'), href='xyz.ics', calendar=cal1, locale=LOCALE_BERLIN,
         )
         coll.new(event, cal1)
         event_from_db = coll.get_event(SIMPLE_EVENT_UID + '.ics', cal1)
@@ -220,7 +217,7 @@
     def test_change(self, coll_vdirs):
         """moving an event from one calendar to another"""
         coll, vdirs = coll_vdirs
-        event = Event.fromString(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN)
+        event = Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         event = list(coll.get_events_on(aday))[0]
         assert event.calendar == cal1
@@ -234,7 +231,7 @@
         """updating one event"""
         coll, vdirs = coll_vdirs
         event = Event.fromString(
-            _get_text('event_dt_simple'), calendar=cal1, locale=utils.LOCALE_BERLIN)
+            _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         events = coll.get_events_on(aday)
         event = list(events)[0]
@@ -249,11 +246,11 @@
 
     def test_newevent(self, coll_vdirs):
         coll, vdirs = coll_vdirs
-        bday = datetime.combine(aday, time.min)
-        anend = bday + timedelta(hours=1)
-        event = khal.utils.new_event(
+        bday = dt.datetime.combine(aday, dt.time.min)
+        anend = bday + dt.timedelta(hours=1)
+        event = icalendar_helpers.new_event(
             dtstart=bday, dtend=anend, summary="hi", timezone=utils.BERLIN,
-            locale=utils.LOCALE_BERLIN,
+            locale=LOCALE_BERLIN,
         )
         event = coll.new_event(event.to_ical(), coll.default_calendar_name)
         assert event.allday is False
@@ -262,7 +259,7 @@
         coll, vdirs = coll_vdirs
         coll._calendars[cal1]['readonly'] = True
         coll._calendars[cal3]['readonly'] = True
-        event = Event.fromString(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN)
+        event = Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN)
 
         with pytest.raises(khal.khalendar.exceptions.ReadOnlyCalendarError):
             coll.new(event, cal1)
@@ -274,11 +271,11 @@
         coll, vdirs = coll_vdirs
         assert len(list(coll.search('Event'))) == 0
         event = Event.fromString(
-            _get_text('event_dt_simple'), calendar=cal1, locale=utils.LOCALE_BERLIN)
+            _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         assert len(list(coll.search('Event'))) == 1
         event = Event.fromString(
-            _get_text('event_dt_floating'), calendar=cal1, locale=utils.LOCALE_BERLIN)
+            _get_text('event_dt_floating'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         assert len(list(coll.search('Search for me'))) == 1
         assert len(list(coll.search('Event'))) == 2
@@ -289,7 +286,7 @@
         coll, vdirs = coll_vdirs
         assert len(list(coll.search('Event'))) == 0
         event = Event.fromString(
-            _get_text('event_dt_recuid_no_master'), calendar=cal1, locale=utils.LOCALE_BERLIN)
+            _get_text('event_dt_recuid_no_master'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         assert len(list(coll.search('Event'))) == 1
 
@@ -299,36 +296,62 @@
         coll, vdirs = coll_vdirs
         assert len(list(coll.search('Event'))) == 0
         event = Event.fromString(
-            _get_text('event_dt_multi_recuid_no_master'), calendar=cal1, locale=utils.LOCALE_BERLIN)
+            _get_text('event_dt_multi_recuid_no_master'), calendar=cal1, locale=LOCALE_BERLIN)
         coll.new(event, cal1)
         events = list(sorted(coll.search('Event')))
         assert len(events) == 2
         assert events[0].format(
-            '{start} {end} {title}', date.today()) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m'
+            '{start} {end} {title}', dt.date.today()) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m'
         assert events[1].format(
-            '{start} {end} {title}', date.today()) == '07.07. 08:30 07.07. 12:00 Arbeit\x1b[0m'
+            '{start} {end} {title}', dt.date.today()) == '07.07. 08:30 07.07. 12:00 Arbeit\x1b[0m'
 
     def test_delete_two_events(self, coll_vdirs, sleep_time):
-            """testing if we can delete any of two events in two different
-            calendars with the same filename"""
-            coll, vdirs = coll_vdirs
-            event1 = Event.fromString(
-                _get_text('event_dt_simple'), calendar=cal1, locale=utils.LOCALE_BERLIN)
-            event2 = Event.fromString(
-                _get_text('event_dt_simple'), calendar=cal2, locale=utils.LOCALE_BERLIN)
-            coll.new(event1, cal1)
-            sleep(sleep_time)  # make sure the etags are different
-            coll.new(event2, cal2)
-            etag1 = list(vdirs[cal1].list())[0][1]
-            etag2 = list(vdirs[cal2].list())[0][1]
-            events = list(coll.get_localized(self.astart_berlin, self.aend_berlin))
-            assert len(events) == 2
-            assert events[0].calendar != events[1].calendar
-            for event in events:
-                if event.calendar == cal1:
-                    assert event.etag == etag1
-                if event.calendar == cal2:
-                    assert event.etag == etag2
+        """testing if we can delete any of two events in two different
+        calendars with the same filename"""
+        coll, vdirs = coll_vdirs
+        event1 = Event.fromString(
+            _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN)
+        event2 = Event.fromString(
+            _get_text('event_dt_simple'), calendar=cal2, locale=LOCALE_BERLIN)
+        coll.new(event1, cal1)
+        sleep(sleep_time)  # make sure the etags are different
+        coll.new(event2, cal2)
+        etag1 = list(vdirs[cal1].list())[0][1]
+        etag2 = list(vdirs[cal2].list())[0][1]
+        events = list(coll.get_localized(self.astart_berlin, self.aend_berlin))
+        assert len(events) == 2
+        assert events[0].calendar != events[1].calendar
+        for event in events:
+            if event.calendar == cal1:
+                assert event.etag == etag1
+            if event.calendar == cal2:
+                assert event.etag == etag2
+
+    def test_invalid_timezones(self, coll_vdirs):
+        """testing if we can delete any of two events in two different
+        calendars with the same filename"""
+        coll, vdirs = coll_vdirs
+        event = Event.fromString(
+            _get_text('invalid_tzoffset'), calendar=cal1, locale=LOCALE_BERLIN)
+        coll.new(event, cal1)
+        events = list(sorted(coll.search('Event')))
+        assert len(events) == 1
+        assert events[0].format('{start} {end} {title}', dt.date.today()) == \
+            '02.12. 08:00 02.12. 09:30 Some event\x1b[0m'
+
+    def test_multi_uid_vdir(self, coll_vdirs, caplog, fix_caplog):
+        coll, vdirs = coll_vdirs
+        caplog.set_level(logging.WARNING)
+        vdirs[cal1].upload(DumbItem(_get_text('event_dt_multi_uid'), uid='12345'))
+        coll.update_db()
+        assert list(coll.search('')) == []
+        messages = [rec.message for rec in caplog.records]
+        assert messages[0].startswith(
+            "The .ics file at foobar/12345.ics contains multiple UIDs.\n"
+        )
+        assert messages[1].startswith(
+            "Skipping foobar/12345.ics: \nThis event will not be available in khal."
+        )
 
 
 class TestDbCreation(object):
@@ -341,7 +364,7 @@
 
         assert not os.path.isdir(dbdir)
         calendars = {cal1: {'name': cal1, 'path': vdirpath}}
-        CalendarCollection(calendars, dbpath=dbpath, locale=utils.LOCALE_BERLIN)
+        CalendarCollection(calendars, dbpath=dbpath, locale=LOCALE_BERLIN)
         assert os.path.isdir(dbdir)
 
     def test_failed_create_db(self, tmpdir):
@@ -351,7 +374,56 @@
 
         calendars = {cal1: {'name': cal1, 'path': str(tmpdir)}}
         with pytest.raises(CouldNotCreateDbDir):
-            CalendarCollection(calendars, dbpath=dbpath, locale=utils.LOCALE_BERLIN)
+            CalendarCollection(calendars, dbpath=dbpath, locale=LOCALE_BERLIN)
+
+
+def test_event_different_timezones(coll_vdirs):
+    coll, vdirs = coll_vdirs
+    vdirs[cal1].upload(DumbItem(_get_text('event_dt_london'), uid='12345'))
+    coll.update_db()
+
+    events = coll.get_localized(
+        BERLIN.localize(dt.datetime(2014, 4, 9, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 4, 9, 23, 59)),
+    )
+    events = list(events)
+    assert len(events) == 1
+    event = events[0]
+    assert event.start_local == LONDON.localize(dt.datetime(2014, 4, 9, 14))
+    assert event.end_local == LONDON.localize(dt.datetime(2014, 4, 9, 19))
+    assert event.start == LONDON.localize(dt.datetime(2014, 4, 9, 14))
+    assert event.end == LONDON.localize(dt.datetime(2014, 4, 9, 19))
+
+    # no event scheduled on the next day
+    events = coll.get_localized(
+        BERLIN.localize(dt.datetime(2014, 4, 10, 0, 0)),
+        BERLIN.localize(dt.datetime(2014, 4, 10, 23, 59)),
+    )
+    events = list(events)
+    assert len(events) == 0
+
+    # now setting the local_timezone to Sydney
+    coll.locale = LOCALE_SYDNEY
+    events = coll.get_localized(
+        SYDNEY.localize(dt.datetime(2014, 4, 9, 0, 0)),
+        SYDNEY.localize(dt.datetime(2014, 4, 9, 23, 59)),
+    )
+    events = list(events)
+    assert len(events) == 1
+    event = events[0]
+    assert event.start_local == SYDNEY.localize(dt.datetime(2014, 4, 9, 23))
+    assert event.end_local == SYDNEY.localize(dt.datetime(2014, 4, 10, 4))
+    assert event.start == LONDON.localize(dt.datetime(2014, 4, 9, 14))
+    assert event.end == LONDON.localize(dt.datetime(2014, 4, 9, 19))
+
+    # the event spans midnight Sydney, therefor it should also show up on the
+    # next day
+    events = coll.get_localized(SYDNEY.localize(dt.datetime(2014, 4, 10, 0, 0)),
+                                SYDNEY.localize(dt.datetime(2014, 4, 10, 23, 59)))
+    events = list(events)
+    assert len(events) == 1
+    assert event.start_local == SYDNEY.localize(dt.datetime(2014, 4, 9, 23))
+    assert event.end_local == SYDNEY.localize(dt.datetime(2014, 4, 10, 4))
 
 
 def test_default_calendar(coll_vdirs, sleep_time):
@@ -428,3 +500,84 @@
     coll.update_db()
     sleep(sleep_time)
     assert updated_hrefs == [href_three]
+
+
+card = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+BDAY:19710311
+END:VCARD
+"""
+
+card_29thfeb = """BEGIN:VCARD
+VERSION:3.0
+FN:leapyear
+BDAY:20000229
+END:VCARD
+"""
+
+card_no_year = """BEGIN:VCARD
+VERSION:3.0
+FN:Unix
+BDAY:--0311
+END:VCARD
+"""
+
+
+def test_birthdays(coll_vdirs_birthday):
+    coll, vdirs = coll_vdirs_birthday
+    assert list(
+        coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59))
+    ) == list()
+    vdirs[cal1].upload(DumbItem(card, 'unix'))
+    coll.update_db()
+    assert 'Unix\'s 41st birthday' == list(
+        coll.get_floating(dt.datetime(2012, 3, 11), dt.datetime(2012, 3, 11)))[0].summary
+    assert 'Unix\'s 42nd birthday' == list(
+        coll.get_floating(dt.datetime(2013, 3, 11), dt.datetime(2013, 3, 11)))[0].summary
+    assert 'Unix\'s 43rd birthday' == list(
+        coll.get_floating(dt.datetime(2014, 3, 11), dt.datetime(2014, 3, 11)))[0].summary
+
+
+def test_birthdays_29feb(coll_vdirs_birthday):
+    """test how we deal with birthdays on 29th of feb in leap years"""
+    coll, vdirs = coll_vdirs_birthday
+    vdirs[cal1].upload(DumbItem(card_29thfeb, 'leap'))
+    coll.update_db()
+    events = list(
+        coll.get_floating(dt.datetime(2004, 1, 1, 0, 0), dt.datetime(2004, 12, 31))
+    )
+    assert len(events) == 1
+    assert events[0].summary == 'leapyear\'s 4th birthday (29th of Feb.)'
+    assert events[0].start == dt.date(2004, 2, 29)
+    events = list(
+        coll.get_floating(dt.datetime(2005, 1, 1, 0, 0), dt.datetime(2005, 12, 31))
+    )
+    assert len(events) == 1
+    assert events[0].summary == 'leapyear\'s 5th birthday (29th of Feb.)'
+    assert events[0].start == dt.date(2005, 3, 1)
+    assert list(
+        coll.get_floating(dt.datetime(2001, 1, 1), dt.datetime(2001, 12, 31))
+    )[0].summary == 'leapyear\'s 1st birthday (29th of Feb.)'
+    assert list(
+        coll.get_floating(dt.datetime(2002, 1, 1), dt.datetime(2002, 12, 31))
+    )[0].summary == 'leapyear\'s 2nd birthday (29th of Feb.)'
+    assert list(
+        coll.get_floating(dt.datetime(2003, 1, 1), dt.datetime(2003, 12, 31))
+    )[0].summary == 'leapyear\'s 3rd birthday (29th of Feb.)'
+    assert list(
+        coll.get_floating(dt.datetime(2023, 1, 1), dt.datetime(2023, 12, 31))
+    )[0].summary == 'leapyear\'s 23rd birthday (29th of Feb.)'
+    assert events[0].start == dt.date(2005, 3, 1)
+
+
+def test_birthdays_no_year(coll_vdirs_birthday):
+    coll, vdirs = coll_vdirs_birthday
+    assert list(
+        coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59))
+    ) == list()
+    vdirs[cal1].upload(DumbItem(card_no_year, 'vcard.vcf'))
+    coll.update_db()
+    events = list(coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59)))
+    assert len(events) == 1
+    assert 'Unix\'s birthday' == events[0].summary
diff -Nru khal-0.9.10/tests/khalendar_utils_test.py khal-0.10.2/tests/khalendar_utils_test.py
--- khal-0.9.10/tests/khalendar_utils_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/khalendar_utils_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,9 +1,9 @@
-from datetime import date, datetime, timedelta
+import datetime as dt
+
 import icalendar
+from khal import icalendar as icalendar_helpers, utils
 import pytz
 
-from khal.khalendar import utils
-
 from .utils import _get_text, _get_vevent_file
 
 # FIXME this file is in urgent need of a clean up
@@ -232,86 +232,86 @@
 
 class TestExpand(object):
     dtstartend_berlin = [
-        (berlin.localize(datetime(2013, 3, 1, 14, 0, )),
-         berlin.localize(datetime(2013, 3, 1, 16, 0, ))),
-        (berlin.localize(datetime(2013, 5, 1, 14, 0, )),
-         berlin.localize(datetime(2013, 5, 1, 16, 0, ))),
-        (berlin.localize(datetime(2013, 7, 1, 14, 0, )),
-         berlin.localize(datetime(2013, 7, 1, 16, 0, ))),
-        (berlin.localize(datetime(2013, 9, 1, 14, 0, )),
-         berlin.localize(datetime(2013, 9, 1, 16, 0, ))),
-        (berlin.localize(datetime(2013, 11, 1, 14, 0,)),
-         berlin.localize(datetime(2013, 11, 1, 16, 0,))),
-        (berlin.localize(datetime(2014, 1, 1, 14, 0, )),
-         berlin.localize(datetime(2014, 1, 1, 16, 0, )))
+        (berlin.localize(dt.datetime(2013, 3, 1, 14, 0, )),
+         berlin.localize(dt.datetime(2013, 3, 1, 16, 0, ))),
+        (berlin.localize(dt.datetime(2013, 5, 1, 14, 0, )),
+         berlin.localize(dt.datetime(2013, 5, 1, 16, 0, ))),
+        (berlin.localize(dt.datetime(2013, 7, 1, 14, 0, )),
+         berlin.localize(dt.datetime(2013, 7, 1, 16, 0, ))),
+        (berlin.localize(dt.datetime(2013, 9, 1, 14, 0, )),
+         berlin.localize(dt.datetime(2013, 9, 1, 16, 0, ))),
+        (berlin.localize(dt.datetime(2013, 11, 1, 14, 0,)),
+         berlin.localize(dt.datetime(2013, 11, 1, 16, 0,))),
+        (berlin.localize(dt.datetime(2014, 1, 1, 14, 0, )),
+         berlin.localize(dt.datetime(2014, 1, 1, 16, 0, )))
     ]
 
     dtstartend_utc = [
-        (datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)),
-        (datetime(2013, 5, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 5, 1, 16, 0, tzinfo=pytz.utc)),
-        (datetime(2013, 7, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 7, 1, 16, 0, tzinfo=pytz.utc)),
-        (datetime(2013, 9, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 9, 1, 16, 0, tzinfo=pytz.utc)),
-        (datetime(2013, 11, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 11, 1, 16, 0, tzinfo=pytz.utc)),
-        (datetime(2014, 1, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2014, 1, 1, 16, 0, tzinfo=pytz.utc))
+        (dt.datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2013, 5, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 5, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2013, 7, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 7, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2013, 9, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 9, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2013, 11, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 11, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2014, 1, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2014, 1, 1, 16, 0, tzinfo=pytz.utc))
     ]
 
     dtstartend_float = [
-        (datetime(2013, 3, 1, 14, 0),
-         datetime(2013, 3, 1, 16, 0)),
-        (datetime(2013, 5, 1, 14, 0),
-         datetime(2013, 5, 1, 16, 0)),
-        (datetime(2013, 7, 1, 14, 0),
-         datetime(2013, 7, 1, 16, 0)),
-        (datetime(2013, 9, 1, 14, 0),
-         datetime(2013, 9, 1, 16, 0)),
-        (datetime(2013, 11, 1, 14, 0),
-         datetime(2013, 11, 1, 16, 0)),
-        (datetime(2014, 1, 1, 14, 0),
-         datetime(2014, 1, 1, 16, 0))
+        (dt.datetime(2013, 3, 1, 14, 0),
+         dt.datetime(2013, 3, 1, 16, 0)),
+        (dt.datetime(2013, 5, 1, 14, 0),
+         dt.datetime(2013, 5, 1, 16, 0)),
+        (dt.datetime(2013, 7, 1, 14, 0),
+         dt.datetime(2013, 7, 1, 16, 0)),
+        (dt.datetime(2013, 9, 1, 14, 0),
+         dt.datetime(2013, 9, 1, 16, 0)),
+        (dt.datetime(2013, 11, 1, 14, 0),
+         dt.datetime(2013, 11, 1, 16, 0)),
+        (dt.datetime(2014, 1, 1, 14, 0),
+         dt.datetime(2014, 1, 1, 16, 0))
     ]
     dstartend = [
-        (date(2013, 3, 1,),
-         date(2013, 3, 2,)),
-        (date(2013, 5, 1,),
-         date(2013, 5, 2,)),
-        (date(2013, 7, 1,),
-         date(2013, 7, 2,)),
-        (date(2013, 9, 1,),
-         date(2013, 9, 2,)),
-        (date(2013, 11, 1),
-         date(2013, 11, 2)),
-        (date(2014, 1, 1,),
-         date(2014, 1, 2,))
+        (dt.date(2013, 3, 1,),
+         dt.date(2013, 3, 2,)),
+        (dt.date(2013, 5, 1,),
+         dt.date(2013, 5, 2,)),
+        (dt.date(2013, 7, 1,),
+         dt.date(2013, 7, 2,)),
+        (dt.date(2013, 9, 1,),
+         dt.date(2013, 9, 2,)),
+        (dt.date(2013, 11, 1),
+         dt.date(2013, 11, 2)),
+        (dt.date(2014, 1, 1,),
+         dt.date(2014, 1, 2,))
     ]
     offset_berlin = [
-        timedelta(0, 3600),
-        timedelta(0, 7200),
-        timedelta(0, 7200),
-        timedelta(0, 7200),
-        timedelta(0, 3600),
-        timedelta(0, 3600)
+        dt.timedelta(0, 3600),
+        dt.timedelta(0, 7200),
+        dt.timedelta(0, 7200),
+        dt.timedelta(0, 7200),
+        dt.timedelta(0, 3600),
+        dt.timedelta(0, 3600)
     ]
 
     offset_utc = [
-        timedelta(0, 0),
-        timedelta(0, 0),
-        timedelta(0, 0),
-        timedelta(0, 0),
-        timedelta(0, 0),
-        timedelta(0, 0),
+        dt.timedelta(0, 0),
+        dt.timedelta(0, 0),
+        dt.timedelta(0, 0),
+        dt.timedelta(0, 0),
+        dt.timedelta(0, 0),
+        dt.timedelta(0, 0),
     ]
 
     offset_none = [None, None, None, None, None, None]
 
     def test_expand_dt(self):
         vevent = _get_vevent(event_dt)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_berlin
         assert [start.utcoffset()
                 for start, _ in dtstart] == self.offset_berlin
@@ -319,7 +319,7 @@
 
     def test_expand_dtb(self):
         vevent = _get_vevent(event_dtb)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_berlin
         assert [start.utcoffset()
                 for start, _ in dtstart] == self.offset_berlin
@@ -327,77 +327,77 @@
 
     def test_expand_dttz(self):
         vevent = _get_vevent(event_dttz)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_utc
         assert [start.utcoffset() for start, _ in dtstart] == self.offset_utc
         assert [end.utcoffset() for _, end in dtstart] == self.offset_utc
 
     def test_expand_dtf(self):
         vevent = _get_vevent(event_dtf)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_float
         assert [start.utcoffset() for start, _ in dtstart] == self.offset_none
         assert [end.utcoffset() for _, end in dtstart] == self.offset_none
 
     def test_expand_d(self):
         vevent = _get_vevent(event_d)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dstartend
 
     def test_expand_dtz(self):
         vevent = _get_vevent(event_dtz)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dstartend
 
     def test_expand_dtzb(self):
         vevent = _get_vevent(event_dtzb)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dstartend
 
     def test_expand_invalid_exdate(self):
         """testing if we can expand an event with EXDATEs that do not much
         its RRULE"""
         vevent = _get_vevent_file('event_invalid_exdate')
-        dtstartl = utils.expand(vevent, berlin)
+        dtstartl = icalendar_helpers.expand(vevent, berlin)
         # TODO test for logging message
         assert dtstartl == [
-            (new_york.localize(datetime(2011, 11, 12, 15, 50)),
-             new_york.localize(datetime(2011, 11, 12, 17, 0))),
-            (new_york.localize(datetime(2011, 11, 19, 15, 50)),
-             new_york.localize(datetime(2011, 11, 19, 17, 0))),
-            (new_york.localize(datetime(2011, 12, 3, 15, 50)),
-             new_york.localize(datetime(2011, 12, 3, 17, 0))),
+            (new_york.localize(dt.datetime(2011, 11, 12, 15, 50)),
+             new_york.localize(dt.datetime(2011, 11, 12, 17, 0))),
+            (new_york.localize(dt.datetime(2011, 11, 19, 15, 50)),
+             new_york.localize(dt.datetime(2011, 11, 19, 17, 0))),
+            (new_york.localize(dt.datetime(2011, 12, 3, 15, 50)),
+             new_york.localize(dt.datetime(2011, 12, 3, 17, 0))),
         ]
 
 
 class TestExpandNoRR(object):
     dtstartend_berlin = [
-        (berlin.localize(datetime(2013, 3, 1, 14, 0)),
-         berlin.localize(datetime(2013, 3, 1, 16, 0))),
+        (berlin.localize(dt.datetime(2013, 3, 1, 14, 0)),
+         berlin.localize(dt.datetime(2013, 3, 1, 16, 0))),
     ]
 
     dtstartend_utc = [
-        (datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc),
-         datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)),
+        (dt.datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc),
+         dt.datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)),
     ]
 
     dtstartend_float = [
-        (datetime(2013, 3, 1, 14, 0),
-         datetime(2013, 3, 1, 16, 0)),
+        (dt.datetime(2013, 3, 1, 14, 0),
+         dt.datetime(2013, 3, 1, 16, 0)),
     ]
     offset_berlin = [
-        timedelta(0, 3600),
+        dt.timedelta(0, 3600),
     ]
 
     offset_utc = [
-        timedelta(0, 0),
+        dt.timedelta(0, 0),
     ]
 
     offset_none = [None]
 
     def test_expand_dt(self):
         vevent = _get_vevent(event_dt_norr)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_berlin
         assert [start.utcoffset()
                 for start, _ in dtstart] == self.offset_berlin
@@ -405,7 +405,7 @@
 
     def test_expand_dtb(self):
         vevent = _get_vevent(event_dtb_norr)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_berlin
         assert [start.utcoffset()
                 for start, _ in dtstart] == self.offset_berlin
@@ -413,31 +413,31 @@
 
     def test_expand_dttz(self):
         vevent = _get_vevent(event_dttz_norr)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_utc
         assert [start.utcoffset() for start, _ in dtstart] == self.offset_utc
         assert [end.utcoffset() for _, end in dtstart] == self.offset_utc
 
     def test_expand_dtf(self):
         vevent = _get_vevent(event_dtf_norr)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == self.dtstartend_float
         assert [start.utcoffset() for start, _ in dtstart] == self.offset_none
         assert [end.utcoffset() for _, end in dtstart] == self.offset_none
 
     def test_expand_d(self):
         vevent = _get_vevent(event_d_norr)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert dtstart == [
-            (date(2013, 3, 1,),
-             date(2013, 3, 2,)),
+            (dt.date(2013, 3, 1,),
+             dt.date(2013, 3, 2,)),
         ]
 
     def test_expand_dtr_exdatez(self):
         """a recurring event with an EXDATE in Zulu time while DTSTART is
         localized"""
         vevent = _get_vevent_file('event_dtr_exdatez')
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 3
 
     def test_expand_rrule_exdate_z(self):
@@ -445,16 +445,16 @@
         exdate
         """
         vevent = _get_vevent_file('event_dtr_no_tz_exdatez')
-        vevent = utils.sanitize(vevent, berlin, '', '')
-        dtstart = utils.expand(vevent, berlin)
+        vevent = icalendar_helpers.sanitize(vevent, berlin, '', '')
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 5
         dtstarts = [start for start, end in dtstart]
         assert dtstarts == [
-            berlin.localize(datetime(2012, 4, 3, 10, 0)),
-            berlin.localize(datetime(2012, 5, 3, 10, 0)),
-            berlin.localize(datetime(2012, 7, 3, 10, 0)),
-            berlin.localize(datetime(2012, 8, 3, 10, 0)),
-            berlin.localize(datetime(2012, 9, 3, 10, 0)),
+            berlin.localize(dt.datetime(2012, 4, 3, 10, 0)),
+            berlin.localize(dt.datetime(2012, 5, 3, 10, 0)),
+            berlin.localize(dt.datetime(2012, 7, 3, 10, 0)),
+            berlin.localize(dt.datetime(2012, 8, 3, 10, 0)),
+            berlin.localize(dt.datetime(2012, 9, 3, 10, 0)),
         ]
 
     def test_expand_rrule_notz_until_z(self):
@@ -462,18 +462,18 @@
         exdate
         """
         vevent = _get_vevent_file('event_dtr_notz_untilz')
-        vevent = utils.sanitize(vevent, new_york, '', '')
-        dtstart = utils.expand(vevent, new_york)
+        vevent = icalendar_helpers.sanitize(vevent, new_york, '', '')
+        dtstart = icalendar_helpers.expand(vevent, new_york)
         assert len(dtstart) == 7
         dtstarts = [start for start, end in dtstart]
         assert dtstarts == [
-            new_york.localize(datetime(2012, 7, 26, 13, 0)),
-            new_york.localize(datetime(2012, 8, 9, 13, 0)),
-            new_york.localize(datetime(2012, 8, 23, 13, 0)),
-            new_york.localize(datetime(2012, 9, 6, 13, 0)),
-            new_york.localize(datetime(2012, 9, 20, 13, 0)),
-            new_york.localize(datetime(2012, 10, 4, 13, 0)),
-            new_york.localize(datetime(2012, 10, 18, 13, 0)),
+            new_york.localize(dt.datetime(2012, 7, 26, 13, 0)),
+            new_york.localize(dt.datetime(2012, 8, 9, 13, 0)),
+            new_york.localize(dt.datetime(2012, 8, 23, 13, 0)),
+            new_york.localize(dt.datetime(2012, 9, 6, 13, 0)),
+            new_york.localize(dt.datetime(2012, 9, 20, 13, 0)),
+            new_york.localize(dt.datetime(2012, 10, 4, 13, 0)),
+            new_york.localize(dt.datetime(2012, 10, 18, 13, 0)),
         ]
 
 
@@ -580,108 +580,108 @@
 
     def test_count(self):
         vevent = _get_vevent(vevent_count)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         starts = [start for start, _ in dtstart]
         assert len(starts) == 18
-        assert dtstart[0][0] == datetime(2014, 2, 3, 7, 0)
-        assert dtstart[-1][0] == datetime(2014, 2, 20, 7, 0)
+        assert dtstart[0][0] == dt.datetime(2014, 2, 3, 7, 0)
+        assert dtstart[-1][0] == dt.datetime(2014, 2, 20, 7, 0)
 
     def test_until_notz(self):
         vevent = _get_vevent(vevent_until_notz)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         starts = [start for start, _ in dtstart]
         assert len(starts) == 18
         assert dtstart[0][0] == berlin.localize(
-            datetime(2014, 2, 3, 7, 0))
+            dt.datetime(2014, 2, 3, 7, 0))
         assert dtstart[-1][0] == berlin.localize(
-            datetime(2014, 2, 20, 7, 0))
+            dt.datetime(2014, 2, 20, 7, 0))
 
     def test_until_d_notz(self):
         vevent = _get_vevent(event_until_d_notz)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         starts = [start for start, _ in dtstart]
         assert len(starts) == 6
-        assert dtstart[0][0] == date(2014, 1, 10)
-        assert dtstart[-1][0] == date(2014, 2, 14)
+        assert dtstart[0][0] == dt.date(2014, 1, 10)
+        assert dtstart[-1][0] == dt.date(2014, 2, 14)
 
     def test_latest_bug(self):
         vevent = _get_vevent(latest_bug)
-        dtstart = utils.expand(vevent, berlin)
-        assert dtstart[0][0] == date(2009, 10, 31)
-        assert dtstart[-1][0] == date(2037, 10, 31)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
+        assert dtstart[0][0] == dt.date(2009, 10, 31)
+        assert dtstart[-1][0] == dt.date(2037, 10, 31)
 
     def test_recurrence_id_with_timezone(self):
         vevent = _get_vevent(recurrence_id_with_timezone)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 1
         assert dtstart[0][0] == berlin.localize(
-            datetime(2013, 11, 13, 19, 0))
+            dt.datetime(2013, 11, 13, 19, 0))
 
     def test_event_exdate_dt(self):
         """recurring event, one date excluded via EXCLUDE"""
         vevent = _get_vevent(event_exdate_dt)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 9
         assert dtstart[0][0] == berlin.localize(
-            datetime(2014, 7, 2, 19, 0))
+            dt.datetime(2014, 7, 2, 19, 0))
         assert dtstart[-1][0] == berlin.localize(
-            datetime(2014, 7, 11, 19, 0))
+            dt.datetime(2014, 7, 11, 19, 0))
 
     def test_event_exdates_dt(self):
         """recurring event, two dates excluded via EXCLUDE"""
         vevent = _get_vevent(event_exdates_dt)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 8
         assert dtstart[0][0] == berlin.localize(
-            datetime(2014, 7, 2, 19, 0))
+            dt.datetime(2014, 7, 2, 19, 0))
         assert dtstart[-1][0] == berlin.localize(
-            datetime(2014, 7, 11, 19, 0))
+            dt.datetime(2014, 7, 11, 19, 0))
 
     def test_event_exdatesl_dt(self):
         """recurring event, three dates exclude via two EXCLUDEs"""
         vevent = _get_vevent(event_exdatesl_dt)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 7
         assert dtstart[0][0] == berlin.localize(
-            datetime(2014, 7, 2, 19, 0))
+            dt.datetime(2014, 7, 2, 19, 0))
         assert dtstart[-1][0] == berlin.localize(
-            datetime(2014, 7, 11, 19, 0))
+            dt.datetime(2014, 7, 11, 19, 0))
 
     def test_event_exdates_remove(self):
         """check if we can remove one more instance"""
         vevent = _get_vevent(event_exdatesl_dt)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 7
 
-        exdate1 = pytz.UTC.localize(datetime(2014, 7, 11, 17, 0))
-        utils.delete_instance(vevent, exdate1)
-        dtstart = utils.expand(vevent, berlin)
+        exdate1 = pytz.UTC.localize(dt.datetime(2014, 7, 11, 17, 0))
+        icalendar_helpers.delete_instance(vevent, exdate1)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 6
 
-        exdate2 = berlin.localize(datetime(2014, 7, 9, 19, 0))
-        utils.delete_instance(vevent, exdate2)
-        dtstart = utils.expand(vevent, berlin)
+        exdate2 = berlin.localize(dt.datetime(2014, 7, 9, 19, 0))
+        icalendar_helpers.delete_instance(vevent, exdate2)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 5
 
     def test_event_dt_rrule_invalid_until(self):
         """DTSTART and RRULE:UNTIL should be of the same type, but might not
         be"""
         vevent = _get_vevent(_get_text('event_dt_rrule_invalid_until'))
-        dtstart = utils.expand(vevent, berlin)
-        assert dtstart == [(date(2007, 12, 1), date(2007, 12, 2)),
-                           (date(2008, 1, 1), date(2008, 1, 2)),
-                           (date(2008, 2, 1), date(2008, 2, 2))]
+        dtstart = icalendar_helpers.expand(vevent, berlin)
+        assert dtstart == [(dt.date(2007, 12, 1), dt.date(2007, 12, 2)),
+                           (dt.date(2008, 1, 1), dt.date(2008, 1, 2)),
+                           (dt.date(2008, 2, 1), dt.date(2008, 2, 2))]
 
     def test_event_dt_rrule_invalid_until2(self):
         """same as above, but now dtstart is of type date and until is datetime
         """
         vevent = _get_vevent(_get_text('event_dt_rrule_invalid_until2'))
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 35
-        assert dtstart[0] == (berlin.localize(datetime(2014, 4, 9, 9, 30)),
-                              berlin.localize(datetime(2014, 4, 9, 10, 30)))
-        assert dtstart[-1] == (berlin.localize(datetime(2014, 12, 3, 9, 30)),
-                               berlin.localize(datetime(2014, 12, 3, 10, 30)))
+        assert dtstart[0] == (berlin.localize(dt.datetime(2014, 4, 9, 9, 30)),
+                              berlin.localize(dt.datetime(2014, 4, 9, 10, 30)))
+        assert dtstart[-1] == (berlin.localize(dt.datetime(2014, 12, 3, 9, 30)),
+                               berlin.localize(dt.datetime(2014, 12, 3, 10, 30)))
 
 
 simple_rdate = """BEGIN:VEVENT
@@ -708,30 +708,30 @@
     """Testing expanding of recurrence rules"""
     def test_simple_rdate(self):
         vevent = _get_vevent(simple_rdate)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 4
 
     def test_rrule_and_rdate(self):
         vevent = _get_vevent(rrule_and_rdate)
-        dtstart = utils.expand(vevent, berlin)
+        dtstart = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstart) == 7
 
     def test_rrule_past(self):
         vevent = _get_vevent_file('event_r_past')
         assert vevent is not None
-        dtstarts = utils.expand(vevent, berlin)
+        dtstarts = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstarts) == 73
-        assert dtstarts[0][0] == date(1965, 4, 23)
-        assert dtstarts[-1][0] == date(2037, 4, 23)
+        assert dtstarts[0][0] == dt.date(1965, 4, 23)
+        assert dtstarts[-1][0] == dt.date(2037, 4, 23)
 
     def test_rdate_date(self):
         vevent = _get_vevent_file('event_d_rdate')
-        dtstarts = utils.expand(vevent, berlin)
+        dtstarts = icalendar_helpers.expand(vevent, berlin)
         assert len(dtstarts) == 4
-        assert dtstarts == [(date(2015, 8, 12), date(2015, 8, 13)),
-                            (date(2015, 8, 13), date(2015, 8, 14)),
-                            (date(2015, 8, 14), date(2015, 8, 15)),
-                            (date(2015, 8, 15), date(2015, 8, 16))]
+        assert dtstarts == [(dt.date(2015, 8, 12), dt.date(2015, 8, 13)),
+                            (dt.date(2015, 8, 13), dt.date(2015, 8, 14)),
+                            (dt.date(2015, 8, 14), dt.date(2015, 8, 15)),
+                            (dt.date(2015, 8, 15), dt.date(2015, 8, 16))]
 
 
 noend_date = """
@@ -770,36 +770,36 @@
 
     def test_noend_date(self):
         vevent = _get_vevent(noend_date)
-        vevent = utils.sanitize(vevent, berlin, '', '')
-        assert vevent['DTSTART'].dt == date(2014, 8, 29)
-        assert vevent['DTEND'].dt == date(2014, 8, 30)
+        vevent = icalendar_helpers.sanitize(vevent, berlin, '', '')
+        assert vevent['DTSTART'].dt == dt.date(2014, 8, 29)
+        assert vevent['DTEND'].dt == dt.date(2014, 8, 30)
 
     def test_noend_datetime(self):
         vevent = _get_vevent(noend_datetime)
-        vevent = utils.sanitize(vevent, berlin, '', '')
-        assert vevent['DTSTART'].dt == date(2014, 8, 29)
-        assert vevent['DTEND'].dt == date(2014, 8, 30)
+        vevent = icalendar_helpers.sanitize(vevent, berlin, '', '')
+        assert vevent['DTSTART'].dt == dt.date(2014, 8, 29)
+        assert vevent['DTEND'].dt == dt.date(2014, 8, 30)
 
     def test_duration(self):
         vevent = _get_vevent_file('event_dtr_exdatez')
-        vevent = utils.sanitize(vevent, berlin, '', '')
+        vevent = icalendar_helpers.sanitize(vevent, berlin, '', '')
 
     def test_instant(self):
         vevent = _get_vevent(instant)
-        assert vevent['DTEND'].dt - vevent['DTSTART'].dt == timedelta()
-        vevent = utils.sanitize(vevent, berlin, '', '')
-        assert vevent['DTEND'].dt - vevent['DTSTART'].dt == timedelta(hours=1)
+        assert vevent['DTEND'].dt - vevent['DTSTART'].dt == dt.timedelta()
+        vevent = icalendar_helpers.sanitize(vevent, berlin, '', '')
+        assert vevent['DTEND'].dt - vevent['DTSTART'].dt == dt.timedelta(hours=1)
 
 
 class TestIsAware():
     def test_naive(self):
-        assert utils.is_aware(datetime.now()) is False
+        assert utils.is_aware(dt.datetime.now()) is False
 
     def test_berlin(self):
-        assert utils.is_aware(BERLIN.localize(datetime.now())) is True
+        assert utils.is_aware(BERLIN.localize(dt.datetime.now())) is True
 
     def test_bogota(self):
-        assert utils.is_aware(BOGOTA.localize(datetime.now())) is True
+        assert utils.is_aware(BOGOTA.localize(dt.datetime.now())) is True
 
     def test_utc(self):
-        assert utils.is_aware(pytz.UTC.localize(datetime.now())) is True
+        assert utils.is_aware(pytz.UTC.localize(dt.datetime.now())) is True
diff -Nru khal-0.9.10/tests/parse_datetime_test.py khal-0.10.2/tests/parse_datetime_test.py
--- khal-0.9.10/tests/parse_datetime_test.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/parse_datetime_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,562 @@
+import datetime as dt
+from collections import OrderedDict
+
+import pytest
+from freezegun import freeze_time
+from khal.exceptions import DateTimeParseError, FatalError
+from khal.parse_datetime import (construct_daynames, eventinfofstr,
+                                 guessdatetimefstr, guessrangefstr,
+                                 guesstimedeltafstr, timedelta2str,
+                                 weekdaypstr)
+from khal.icalendar import new_event
+
+from .utils import (LOCALE_BERLIN, LOCALE_NEW_YORK, _replace_uid,
+                    normalize_component)
+
+
+def _create_testcases(*cases):
+    return [(userinput, ('\r\n'.join(output) + '\r\n').encode('utf-8'))
+            for userinput, output in cases]
+
+
+def _construct_event(info, locale,
+                     defaulttimelen=60, defaultdatelen=1, description=None,
+                     location=None, categories=None, repeat=None, until=None,
+                     alarm=None, **kwargs):
+    info = eventinfofstr(' '.join(info), locale,
+                         dt.timedelta(days=1),
+                         dt.timedelta(hours=1),
+                         adjust_reasonably=True, localize=False)
+    if description is not None:
+        info["description"] = description
+    event = new_event(
+        locale=locale, location=location,
+        categories=categories, repeat=repeat, until=until,
+        alarms=alarm,
+        **info)
+    return event
+
+
+def _create_vevent(*args):
+    """
+    Adapt and return a default vevent for testing.
+
+    Accepts an arbitrary amount of strings like 'DTSTART;VALUE=DATE:2013015'.
+    Updates the default vevent if the key (the first word) is found and
+    appends the value otherwise.
+    """
+    def_vevent = OrderedDict(
+        [('BEGIN', 'BEGIN:VEVENT'),
+         ('SUMMARY', 'SUMMARY:Äwesöme Event'),
+         ('DTSTART', 'DTSTART;VALUE=DATE:20131025'),
+         ('DTEND', 'DTEND;VALUE=DATE:20131026'),
+         ('DTSTAMP', 'DTSTAMP;VALUE=DATE-TIME:20140216T120000Z'),
+         ('UID', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA')])
+
+    for row in args:
+        key = row.replace(':', ';').split(';')[0]
+        def_vevent[key] = row
+
+    def_vevent['END'] = 'END:VEVENT'
+    return list(def_vevent.values())
+
+
+class TestTimeDelta2Str:
+
+    def test_single(self):
+        assert timedelta2str(dt.timedelta(minutes=10)) == '10m'
+
+    def test_negative(self):
+        assert timedelta2str(dt.timedelta(minutes=-10)) == '-10m'
+
+    def test_days(self):
+        assert timedelta2str(dt.timedelta(days=2)) == '2d'
+
+    def test_multi(self):
+        assert timedelta2str(
+            dt.timedelta(days=6, hours=-3, minutes=10, seconds=-3)
+        ) == '5d 21h 9m 57s'
+
+
+def test_weekdaypstr():
+    for string, weekdayno in [
+            ('monday', 0),
+            ('tue', 1),
+            ('wednesday', 2),
+            ('thursday', 3),
+            ('fri', 4),
+            ('saturday', 5),
+            ('sun', 6),
+    ]:
+        assert weekdaypstr(string) == weekdayno
+
+
+def test_weekdaypstr_invalid():
+    with pytest.raises(ValueError):
+        weekdaypstr('foobar')
+
+
+def test_construct_daynames():
+    with freeze_time('2016-9-19'):
+        assert construct_daynames(dt.date(2016, 9, 19)) == 'Today'
+        assert construct_daynames(dt.date(2016, 9, 20)) == 'Tomorrow'
+        assert construct_daynames(dt.date(2016, 9, 21)) == 'Wednesday'
+
+
+class TestGuessDatetimefstr:
+
+    @freeze_time('2016-9-19T8:00')
+    def test_today(self):
+        assert (dt.datetime(2016, 9, 19, 13), False) == \
+            guessdatetimefstr(['today', '13:00'], LOCALE_BERLIN)
+        assert dt.date.today() == guessdatetimefstr(['today'], LOCALE_BERLIN)[0].date()
+
+    @freeze_time('2016-9-19T8:00')
+    def test_tomorrow(self):
+        assert (dt.datetime(2016, 9, 20, 16), False) == \
+            guessdatetimefstr('tomorrow 16:00 16:00'.split(), locale=LOCALE_BERLIN)
+
+    @freeze_time('2016-9-19T8:00')
+    def test_time_tomorrow(self):
+        assert (dt.datetime(2016, 9, 20, 16), False) == \
+            guessdatetimefstr(
+                '16:00'.split(), locale=LOCALE_BERLIN, default_day=dt.date(2016, 9, 20))
+
+    @freeze_time('2016-9-19T8:00')
+    def test_time_yesterday(self):
+        assert (dt.datetime(2016, 9, 18, 16), False) == guessdatetimefstr(
+            'Yesterday 16:00'.split(),
+            locale=LOCALE_BERLIN,
+            default_day=dt.datetime.today())
+
+    @freeze_time('2016-9-19')
+    def test_time_weekday(self):
+        assert (dt.datetime(2016, 9, 23, 16), False) == guessdatetimefstr(
+            'Friday 16:00'.split(),
+            locale=LOCALE_BERLIN,
+            default_day=dt.datetime.today())
+
+    @freeze_time('2016-9-19 17:53')
+    def test_time_now(self):
+        assert (dt.datetime(2016, 9, 19, 17, 53), False) == guessdatetimefstr(
+            'now'.split(), locale=LOCALE_BERLIN, default_day=dt.datetime.today())
+
+    @freeze_time('2016-12-30 17:53')
+    def test_long_not_configured(self):
+        """long version is not configured, but short contains the year"""
+        locale = {
+            'timeformat': '%H:%M',
+            'dateformat': '%Y-%m-%d',
+            'longdateformat': '',
+            'datetimeformat': '%Y-%m-%d %H:%M',
+            'longdatetimeformat': '',
+        }
+        assert (dt.datetime(2017, 1, 1), True) == guessdatetimefstr(
+            '2017-1-1'.split(), locale=locale, default_day=dt.datetime.today())
+        assert (dt.datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr(
+            '2017-1-1 16:30'.split(), locale=locale, default_day=dt.datetime.today())
+
+    @freeze_time('2016-12-30 17:53')
+    def test_short_format_contains_year(self):
+        """if the non long versions of date(time)format contained a year, the
+        current year would be used instead of the given one, see #545"""
+        locale = {
+            'timeformat': '%H:%M',
+            'dateformat': '%Y-%m-%d',
+            'longdateformat': '%Y-%m-%d',
+            'datetimeformat': '%Y-%m-%d %H:%M',
+            'longdatetimeformat': '%Y-%m-%d %H:%M',
+        }
+        assert (dt.datetime(2017, 1, 1), True) == guessdatetimefstr(
+            '2017-1-1'.split(), locale=locale, default_day=dt.datetime.today())
+        assert (dt.datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr(
+            '2017-1-1 16:30'.split(), locale=locale, default_day=dt.datetime.today())
+
+
+class TestGuessTimedeltafstr:
+
+    def test_single(self):
+        assert dt.timedelta(minutes=10) == guesstimedeltafstr('10m')
+
+    def test_seconds(self):
+        assert dt.timedelta(seconds=10) == guesstimedeltafstr('10s')
+
+    def test_negative(self):
+        assert dt.timedelta(minutes=-10) == guesstimedeltafstr('-10m')
+
+    def test_multi(self):
+        assert dt.timedelta(days=1, hours=-3, minutes=10) == \
+            guesstimedeltafstr(' 1d -3H 10min ')
+
+    def test_multi_nospace(self):
+        assert dt.timedelta(days=1, hours=-3, minutes=10) == \
+            guesstimedeltafstr('1D-3hour10m')
+
+    def test_garbage(self):
+        with pytest.raises(ValueError):
+            guesstimedeltafstr('10mbar')
+
+    def test_moregarbage(self):
+        with pytest.raises(ValueError):
+            guesstimedeltafstr('foo10m')
+
+    def test_same(self):
+        assert dt.timedelta(minutes=20) == \
+            guesstimedeltafstr('10min 10minutes')
+
+
+class TestGuessRangefstr:
+
+    @freeze_time('2016-9-19')
+    def test_today(self):
+        assert (dt.datetime(2016, 9, 19, 13), dt.datetime(2016, 9, 19, 14), False) == \
+            guessrangefstr('13:00 14:00', locale=LOCALE_BERLIN)
+        assert (dt.datetime(2016, 9, 19), dt.datetime(2016, 9, 21), True) == \
+            guessrangefstr('today tomorrow', LOCALE_BERLIN)
+
+    @freeze_time('2016-9-19 16:34')
+    def test_tomorrow(self):
+        # XXX remove this funtionality, we shouldn't support this anyway
+        assert (dt.datetime(2016, 9, 19), dt.datetime(2016, 9, 21, 16), True) == \
+            guessrangefstr('today tomorrow 16:00', locale=LOCALE_BERLIN)
+
+    @freeze_time('2016-9-19 13:34')
+    def test_time_tomorrow(self):
+        assert (dt.datetime(2016, 9, 19, 16), dt.datetime(2016, 9, 19, 17), False) == \
+            guessrangefstr('16:00', locale=LOCALE_BERLIN)
+        assert (dt.datetime(2016, 9, 19, 16), dt.datetime(2016, 9, 19, 17), False) == \
+            guessrangefstr('16:00 17:00', locale=LOCALE_BERLIN)
+
+    def test_start_and_end_date(self):
+        assert (dt.datetime(2016, 1, 1), dt.datetime(2017, 1, 2), True) == \
+            guessrangefstr('1.1.2016 1.1.2017', locale=LOCALE_BERLIN)
+
+    def test_start_and_no_end_date(self):
+        assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 2), True) == \
+            guessrangefstr('1.1.2016', locale=LOCALE_BERLIN)
+
+    def test_start_and_end_date_time(self):
+        assert (dt.datetime(2016, 1, 1, 10), dt.datetime(2017, 1, 1, 22), False) == \
+            guessrangefstr(
+                '1.1.2016 10:00 1.1.2017 22:00', locale=LOCALE_BERLIN)
+
+    def test_start_and_eod(self):
+        start, end = dt.datetime(2016, 1, 1, 10), dt.datetime(2016, 1, 1, 23, 59, 59, 999999)
+        assert (start, end, False) == guessrangefstr('1.1.2016 10:00 eod', locale=LOCALE_BERLIN)
+
+    def test_start_and_week(self):
+        assert (dt.datetime(2015, 12, 28), dt.datetime(2016, 1, 5), True) == \
+            guessrangefstr('1.1.2016 week', locale=LOCALE_BERLIN)
+
+    def test_start_and_delta_1d(self):
+        assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 2), True) == \
+            guessrangefstr('1.1.2016 1d', locale=LOCALE_BERLIN)
+
+    def test_start_and_delta_3d(self):
+        assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 4), True) == \
+            guessrangefstr('1.1.2016 3d', locale=LOCALE_BERLIN)
+
+    def test_start_dt_and_delta(self):
+        assert (dt.datetime(2016, 1, 1, 10), dt.datetime(2016, 1, 4, 10), False) == \
+            guessrangefstr('1.1.2016 10:00 3d', locale=LOCALE_BERLIN)
+
+    def test_start_allday_and_delta_datetime(self):
+        with pytest.raises(FatalError):
+            guessrangefstr('1.1.2016 3d3m', locale=LOCALE_BERLIN)
+
+    def test_start_zero_day_delta(self):
+        with pytest.raises(FatalError):
+            guessrangefstr('1.1.2016 0d', locale=LOCALE_BERLIN)
+
+    @freeze_time('20160216')
+    def test_week(self):
+        assert (dt.datetime(2016, 2, 15), dt.datetime(2016, 2, 23), True) == \
+            guessrangefstr('week', locale=LOCALE_BERLIN)
+
+    def test_invalid(self):
+        with pytest.raises(DateTimeParseError):
+            guessrangefstr('3d', locale=LOCALE_BERLIN)
+        with pytest.raises(DateTimeParseError):
+            guessrangefstr('35.1.2016', locale=LOCALE_BERLIN)
+        with pytest.raises(DateTimeParseError):
+            guessrangefstr('1.1.2016 2x', locale=LOCALE_BERLIN)
+        with pytest.raises(DateTimeParseError):
+            guessrangefstr('1.1.2016x', locale=LOCALE_BERLIN)
+        with pytest.raises(DateTimeParseError):
+            guessrangefstr('xxx yyy zzz', locale=LOCALE_BERLIN)
+
+    @freeze_time('2016-12-30 17:53')
+    def test_short_format_contains_year(self):
+        """if the non long versions of date(time)format contained a year, the
+        current year would be used instead of the given one, see #545
+
+        same as above, but for guessrangefstr
+        """
+        locale = {
+            'timeformat': '%H:%M',
+            'dateformat': '%Y-%m-%d',
+            'longdateformat': '%Y-%m-%d',
+            'datetimeformat': '%Y-%m-%d %H:%M',
+            'longdatetimeformat': '%Y-%m-%d %H:%M',
+        }
+        assert (dt.datetime(2017, 1, 1), dt.datetime(2017, 1, 2), True) == \
+            guessrangefstr('2017-1-1 2017-1-1', locale=locale)
+
+
+test_set_format_de = _create_testcases(
+    # all-day-events
+    # one day only
+    ('25.10.2013 Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20131025',
+                    'DTEND;VALUE=DATE:20131026')),
+
+    # 2 day
+    ('15.08.2014 16.08. Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20140815',
+                    'DTEND;VALUE=DATE:20140817')),  # XXX
+
+    # end date in next year and not specified
+    ('29.12.2014 03.01. Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20141229',
+                    'DTEND;VALUE=DATE:20150104')),
+
+    # end date in next year
+    ('29.12.2014 03.01.2015 Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20141229',
+                    'DTEND;VALUE=DATE:20150104')),
+
+    # datetime events
+    # start and end date same, no explicit end date given
+    ('25.10.2013 18:00 20:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T200000')),
+
+    # start and end date same, ends 24:00 which should be 00:00 (start) of next
+    # day
+    ('25.10.2013 18:00 24:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T000000')),
+
+    # start and end date same, explicit end date (but no year) given
+    ('25.10.2013 18:00 26.10. 20:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T200000')),
+
+    ('30.12.2013 18:00 2.1. 20:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131230T180000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140102T200000')),
+
+    # only start date given (no year, past day and month)
+    ('25.01. 18:00 20:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20150125T180000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20150125T200000')),
+
+    # date ends next day, but end date not given
+    ('25.10.2013 23:00 0:30 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T230000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T003000')),
+
+    ('2.2. 23:00 0:30 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20150202T230000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20150203T003000')),
+
+    # only start datetime given
+    ('25.10.2013 06:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T060000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T070000')),
+
+    # timezone given
+    ('25.10.2013 06:00 America/New_York Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=America/New_York;VALUE=DATE-TIME:20131025T060000',
+         'DTEND;TZID=America/New_York;VALUE=DATE-TIME:20131025T070000'))
+)
+
+
+ at freeze_time('20140216T120000')
+def test_construct_event_format_de():
+    for data_list, vevent_expected in test_set_format_de:
+        vevent = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
+        assert _replace_uid(vevent).to_ical() == vevent_expected
+
+
+test_set_format_us = _create_testcases(
+    ('1999/12/31-06:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=America/New_York;VALUE=DATE-TIME:19991231T060000',
+         'DTEND;TZID=America/New_York;VALUE=DATE-TIME:19991231T070000')),
+
+    ('2014/12/18 2014/12/20 Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20141218',
+                    'DTEND;VALUE=DATE:20141221')),
+)
+
+
+ at freeze_time('2014-02-16 12:00:00')
+def test__construct_event_format_us():
+    for data_list, vevent in test_set_format_us:
+        event = _construct_event(data_list.split(), locale=LOCALE_NEW_YORK)
+        assert _replace_uid(event).to_ical() == vevent
+
+
+test_set_format_de_complexer = _create_testcases(
+    # now events where the start date has to be inferred, too
+    # today
+    ('8:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000')),
+
+    # today until tomorrow
+    ('22:00  1:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T220000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140217T010000')),
+
+    # other timezone
+    ('22:00 1:00 Europe/London Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/London;VALUE=DATE-TIME:20140216T220000',
+         'DTEND;TZID=Europe/London;VALUE=DATE-TIME:20140217T010000')),
+
+    ('15.06. Äwesöme Event',
+     _create_vevent('DTSTART;VALUE=DATE:20140615',
+                    'DTEND;VALUE=DATE:20140616')),
+)
+
+
+ at freeze_time('2014-02-16 12:00:00')
+def test__construct_event_format_de_complexer():
+    for data_list, vevent in test_set_format_de_complexer:
+        event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
+        assert _replace_uid(event).to_ical() == vevent
+
+
+test_set_leap_year = _create_testcases(
+    ('29.02. Äwesöme Event',
+     _create_vevent(
+         'DTSTART;VALUE=DATE:20160229',
+         'DTEND;VALUE=DATE:20160301',
+         'DTSTAMP;VALUE=DATE-TIME:20160101T202122Z')),
+)
+
+
+def test_leap_year():
+    for data_list, vevent in test_set_leap_year:
+        with freeze_time('1999-1-1'):
+            with pytest.raises(DateTimeParseError):
+                event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
+        with freeze_time('2016-1-1 20:21:22'):
+            event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
+            assert _replace_uid(event).to_ical() == vevent
+
+
+test_set_description = _create_testcases(
+    # now events where the start date has to be inferred, too
+    # today
+    ('8:00 Äwesöme Event :: this is going to be awesome',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
+         'DESCRIPTION:this is going to be awesome')),
+
+    # today until tomorrow
+    ('22:00  1:00 Äwesöme Event :: Will be even better',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T220000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140217T010000',
+         'DESCRIPTION:Will be even better')),
+
+    ('15.06. Äwesöme Event :: and again',
+     _create_vevent('DTSTART;VALUE=DATE:20140615',
+                    'DTEND;VALUE=DATE:20140616',
+                    'DESCRIPTION:and again')),
+)
+
+
+def test_description():
+    for data_list, vevent in test_set_description:
+        with freeze_time('2014-02-16 12:00:00'):
+            event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
+            assert _replace_uid(event).to_ical() == vevent
+
+
+test_set_repeat = _create_testcases(
+    # now events where the start date has to be inferred, too
+    # today
+    ('8:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
+         'DESCRIPTION:please describe the event',
+         'RRULE:FREQ=DAILY;UNTIL=20150605T000000')))
+
+
+def test_repeat():
+    for data_list, vevent in test_set_repeat:
+        with freeze_time('2014-02-16 12:00:00'):
+            event = _construct_event(data_list.split(),
+                                     description='please describe the event',
+                                     repeat='daily',
+                                     until='05.06.2015',
+                                     locale=LOCALE_BERLIN)
+            assert normalize_component(_replace_uid(event).to_ical()) == \
+                normalize_component(vevent)
+
+
+test_set_alarm = _create_testcases(
+    ('8:00 Äwesöme Event',
+     ['BEGIN:VEVENT',
+      'SUMMARY:Äwesöme Event',
+      'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
+      'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
+      'DTSTAMP;VALUE=DATE-TIME:20140216T120000Z',
+      'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA',
+      'DESCRIPTION:please describe the event',
+      'BEGIN:VALARM',
+      'ACTION:DISPLAY',
+      'DESCRIPTION:please describe the event',
+      'TRIGGER:-PT23M',
+      'END:VALARM',
+      'END:VEVENT']))
+
+
+ at freeze_time('2014-02-16 12:00:00')
+def test_alarm():
+    for data_list, vevent in test_set_alarm:
+        event = _construct_event(data_list.split(),
+                                 description='please describe the event',
+                                 alarm='23m',
+                                 locale=LOCALE_BERLIN)
+        assert _replace_uid(event).to_ical() == vevent
+
+
+test_set_description_and_location_and_categories = _create_testcases(
+    # now events where the start date has to be inferred, too
+    # today
+    ('8:00 Äwesöme Event',
+     _create_vevent(
+         'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
+         'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
+         'CATEGORIES:boring meeting',
+         'DESCRIPTION:please describe the event',
+         'LOCATION:in the office')))
+
+
+ at freeze_time('2014-02-16 12:00:00')
+def test_description_and_location_and_categories():
+    for data_list, vevent in test_set_description_and_location_and_categories:
+        event = _construct_event(data_list.split(),
+                                 description='please describe the event',
+                                 location='in the office',
+                                 categories=['boring meeting'],
+                                 locale=LOCALE_BERLIN)
+        assert _replace_uid(event).to_ical() == vevent
diff -Nru khal-0.9.10/tests/settings_test.py khal-0.10.2/tests/settings_test.py
--- khal-0.9.10/tests/settings_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/settings_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,18 +1,18 @@
-import os.path
 import datetime as dt
-from validate import VdtValueError
+import os.path
 
 import pytest
+from khal.settings import get_config
+from khal.settings.exceptions import (CannotParseConfigFileError,
+                                      InvalidSettingsError)
+from khal.settings.utils import (config_checks, get_all_vdirs,
+                                 get_color_from_vdir, get_unique_name,
+                                 is_color)
 from tzlocal import get_localzone
+from validate import VdtValueError
 
 from .utils import LOCALE_BERLIN
 
-from khal.settings import get_config
-from khal.settings.exceptions import InvalidSettingsError, \
-    CannotParseConfigFileError
-from khal.settings.utils import get_all_vdirs, get_unique_name, config_checks, \
-    get_color_from_vdir, is_color
-
 PATH = __file__.rsplit('/', 1)[0] + '/configs/'
 
 
@@ -26,19 +26,20 @@
         comp_config = {
             'calendars': {
                 'home': {'path': os.path.expanduser('~/.calendars/home/'),
-                         'readonly': False, 'color': None, 'type': 'calendar'},
+                         'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar'},
                 'work': {'path': os.path.expanduser('~/.calendars/work/'),
-                         'readonly': False, 'color': None, 'type': 'calendar'},
+                         'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar'},
             },
             'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')},
             'locale': LOCALE_BERLIN,
             'default': {
-                'default_command': 'calendar',
                 'default_calendar': None,
                 'print_new': 'False',
                 'highlight_event_days': False,
                 'timedelta': dt.timedelta(days=2),
-                'show_all_days': False
+                'default_event_duration': dt.timedelta(days=1),
+                'default_dayevent_duration': dt.timedelta(hours=1),
+                'show_all_days': False,
             }
         }
         for key in comp_config:
@@ -48,6 +49,10 @@
         with pytest.raises(InvalidSettingsError):
             get_config(PATH + 'nocalendars.conf')
 
+    def test_one_level_calendar(self):
+        with pytest.raises(InvalidSettingsError):
+            get_config(PATH + 'one_level_calendars.conf')
+
     def test_small(self):
         config = get_config(
             PATH + 'small.conf',
@@ -57,30 +62,32 @@
         comp_config = {
             'calendars': {
                 'home': {'path': os.path.expanduser('~/.calendars/home/'),
-                         'color': 'dark green', 'readonly': False,
+                         'color': 'dark green', 'readonly': False, 'priority': 20,
                          'type': 'calendar'},
                 'work': {'path': os.path.expanduser('~/.calendars/work/'),
-                         'readonly': True, 'color': None,
+                         'readonly': True, 'color': None, 'priority': 10,
                          'type': 'calendar'}},
             'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')},
             'locale': {
                 'local_timezone': get_localzone(),
                 'default_timezone': get_localzone(),
-                'timeformat': '%H:%M',
-                'dateformat': '%d.%m.',
-                'longdateformat': '%d.%m.%Y',
-                'datetimeformat': '%d.%m. %H:%M',
-                'longdatetimeformat': '%d.%m.%Y %H:%M',
+                'timeformat': '%X',
+                'dateformat': '%x',
+                'longdateformat': '%x',
+                'datetimeformat': '%c',
+                'longdatetimeformat': '%c',
                 'firstweekday': 0,
                 'unicode_symbols': True,
                 'weeknumbers': False,
             },
             'default': {
                 'default_calendar': None,
-                'default_command': 'calendar',
                 'print_new': 'False',
                 'highlight_event_days': False,
                 'timedelta': dt.timedelta(days=2),
+                'default_event_duration': dt.timedelta(days=1),
+                'default_dayevent_duration': dt.timedelta(hours=1),
+
                 'show_all_days': False
             }
         }
@@ -99,7 +106,6 @@
 dateformat: %d.%m.
 longdateformat: %d.%m.%Y
 [default]
-default_command: calendar
 """
         conf_path = str(tmpdir.join('old.conf'))
         with open(conf_path, 'w+') as conf:
@@ -224,60 +230,70 @@
                 'path': '/cal3/home',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'my calendar': {
                 'color': 'dark blue',
                 'path': '/cal1/public',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'my private calendar': {
                 'color': '#FF00FF',
                 'path': '/cal1/private',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'public': {
                 'color': None,
                 'path': '/cal2/public',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'public1': {
                 'color': None,
                 'path': '/cal3/public',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'work': {
                 'color': None,
                 'path': '/cal3/work',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'cfgcolor': {
                 'color': 'dark blue',
                 'path': '/cal4/cfgcolor',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'dircolor': {
                 'color': 'dark blue',
                 'path': '/cal4/dircolor',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'cfgcolor_again': {
                 'color': 'dark blue',
                 'path': '/cal4/cfgcolor_again',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
             'cfgcolor_once_more': {
                 'color': 'dark blue',
                 'path': '/cal4/cfgcolor_once_more',
                 'readonly': False,
                 'type': 'calendar',
+                'priority': 10,
             },
 
         },
diff -Nru khal-0.9.10/tests/terminal_test.py khal-0.10.2/tests/terminal_test.py
--- khal-0.9.10/tests/terminal_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/terminal_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,4 +1,4 @@
-from khal.terminal import merge_columns, colored
+from khal.terminal import colored, merge_columns
 
 
 def test_colored():
diff -Nru khal-0.9.10/tests/ui/canvas_render.py khal-0.10.2/tests/ui/canvas_render.py
--- khal-0.9.10/tests/ui/canvas_render.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/ui/canvas_render.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,57 @@
+import io
+
+import click
+
+
+class CanvasTranslator:
+    """Translates a canvas object into a printable string."""
+
+    def __init__(self, canvas, palette=None):
+        """currently only support foreground colors, so palette is
+        a dictionary of attributes and foreground colors"""
+        self._canvas = canvas
+        self._palette = {}
+        if palette:
+            for key, color in palette.items():
+                self.add_color(key, color)
+
+    def add_color(self, key, color):
+        if color.startswith('#'):  # RGB colour
+            r = color[1:3]
+            g = color[3:5]
+            b = color[5:8]
+            rgb = int(r, 16), int(g, 16), int(b, 16)
+            value = True, '\33[38;2;{!s};{!s};{!s}m'.format(*rgb)
+        else:
+            color = color.split(' ')[-1]
+            if color == 'gray':
+                color = 'white'  # click will insist on US-english
+            value = False, color
+
+        self._palette[key] = value  # (is_ansi, color)
+
+    def transform(self):
+        self.output = io.StringIO()
+        for row in self._canvas.content():
+            # self.spaces = 0
+            for col in row[:-1]:
+                self._process_char(*col)
+            # the last column has all the trailing whitespace, which deforms
+            # everything if the terminal is resized:
+            col = row[-1]
+            self._process_char(col[0], col[1], col[2].rstrip())
+
+            self.output.write('\n')
+
+        return self.output.getvalue()
+
+    def _process_char(self, fmt, _, b):
+        text = b.decode()
+        if not fmt:
+            self.output.write(text)
+        else:
+            fmt = self._palette[fmt]
+            if fmt[0]:
+                self.output.write('{}{}'.format(fmt[1], click.style(text)))
+            else:
+                self.output.write(click.style(text, fg=fmt[1]))
diff -Nru khal-0.9.10/tests/ui/test_calendarwidget.py khal-0.10.2/tests/ui/test_calendarwidget.py
--- khal-0.9.10/tests/ui/test_calendarwidget.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/ui/test_calendarwidget.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,7 +1,6 @@
-from datetime import date, timedelta
+import datetime as dt
 
 from freezegun import freeze_time
-
 from khal.ui.calendarwidget import CalendarWidget
 
 on_press = {}
@@ -16,7 +15,7 @@
 
 
 def test_initial_focus_today():
-    today = date.today()
+    today = dt.date.today()
     frame = CalendarWidget(on_date_change=lambda _: None,
                            keybindings=keybindings,
                            on_press=on_press,
@@ -25,13 +24,13 @@
 
 
 def test_set_focus_date():
-    today = date.today()
+    today = dt.date.today()
     for diff in range(-10, 10, 1):
         frame = CalendarWidget(on_date_change=lambda _: None,
                                keybindings=keybindings,
                                on_press=on_press,
                                weeknumbers='right')
-        day = today + timedelta(days=diff)
+        day = today + dt.timedelta(days=diff)
         frame.set_focus_date(day)
         assert frame.focus_date == day
 
@@ -39,25 +38,25 @@
 def test_set_focus_date_weekstart_6():
 
     with freeze_time('2016-04-10'):
-        today = date.today()
+        today = dt.date.today()
         for diff in range(-21, 21, 1):
             frame = CalendarWidget(on_date_change=lambda _: None,
                                    keybindings=keybindings,
                                    on_press=on_press,
                                    firstweekday=6,
                                    weeknumbers='right')
-            day = today + timedelta(days=diff)
+            day = today + dt.timedelta(days=diff)
             frame.set_focus_date(day)
             assert frame.focus_date == day
 
     with freeze_time('2016-04-23'):
-        today = date.today()
+        today = dt.date.today()
         for diff in range(10):
             frame = CalendarWidget(on_date_change=lambda _: None,
                                    keybindings=keybindings,
                                    on_press=on_press,
                                    firstweekday=6,
                                    weeknumbers='right')
-            day = today + timedelta(days=diff)
+            day = today + dt.timedelta(days=diff)
             frame.set_focus_date(day)
             assert frame.focus_date == day
diff -Nru khal-0.9.10/tests/ui/test_editor.py khal-0.10.2/tests/ui/test_editor.py
--- khal-0.9.10/tests/ui/test_editor.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/ui/test_editor.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,15 +1,23 @@
-from datetime import datetime, date
+import datetime as dt
 
 import icalendar
+from khal.ui.editor import RecurrenceEditor, StartEndEditor
 
-from khal.ui.editor import StartEndEditor, RecurrenceEditor
+from ..utils import BERLIN, LOCALE_BERLIN
+from .canvas_render import CanvasTranslator
 
-from ..utils import LOCALE_BERLIN, BERLIN
+CONF = {'locale': LOCALE_BERLIN, 'keybindings': {}, 'view': {'monthdisplay': 'firstday'}}
 
-CONF = {'locale': LOCALE_BERLIN, 'keybindings': {}}
-
-START = BERLIN.localize(datetime(2015, 4, 26, 22, 23))
-END = BERLIN.localize(datetime(2015, 4, 27, 23, 23))
+START = BERLIN.localize(dt.datetime(2015, 4, 26, 22, 23))
+END = BERLIN.localize(dt.datetime(2015, 4, 27, 23, 23))
+
+palette = {
+    'date header focused': 'blue',
+    'date header': 'green',
+    'default': 'black',
+    'editf': 'red',
+    'edit': 'blue',
+}
 
 
 def test_popup(monkeypatch):
@@ -27,9 +35,9 @@
         'khal.ui.calendarwidget.CalendarWidget.__init__', fake.store)
     see = StartEndEditor(START, END, CONF)
     see.widgets.startdate.keypress((22, ), 'enter')
-    assert fake.kwargs['initial'] == date(2015, 4, 26)
+    assert fake.kwargs['initial'] == dt.date(2015, 4, 26)
     see.widgets.enddate.keypress((22, ), 'enter')
-    assert fake.kwargs['initial'] == date(2015, 4, 27)
+    assert fake.kwargs['initial'] == dt.date(2015, 4, 27)
 
 
 def test_check_understood_rrule():
@@ -39,7 +47,21 @@
     assert RecurrenceEditor.check_understood_rrule(
         icalendar.vRecur.from_ical('FREQ=MONTHLY;BYMONTHDAY=1')
     )
-
+    assert RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYSETPOS=1')
+    )
+    assert RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TU,TH;BYSETPOS=1')
+    )
+    assert RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=1')
+    )
+    assert RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=WE,SU,MO,TH,FR,TU,SA;BYSETPOS=1')
+    )
+    assert RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=WE,MO,TH,FR,TU,SA;BYSETPOS=1')
+    )
     assert not RecurrenceEditor.check_understood_rrule(
         icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=-1SU')
     )
@@ -49,3 +71,63 @@
     assert not RecurrenceEditor.check_understood_rrule(
         icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYMONTHDAY=-1')
     )
+    assert not RecurrenceEditor.check_understood_rrule(
+        icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYSETPOS=3')
+    )
+
+
+def test_editor():
+    """test for the issue in #666"""
+    editor = StartEndEditor(
+        BERLIN.localize(dt.datetime(2017, 10, 2, 13)),
+        BERLIN.localize(dt.datetime(2017, 10, 4, 18)),
+        conf=CONF
+    )
+    assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13))
+    assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18))
+    assert editor.changed is False
+    for _ in range(3):
+        editor.keypress((10, ), 'tab')
+    for _ in range(3):
+        editor.keypress((10, ), 'shift tab')
+    assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13))
+    assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18))
+    assert editor.changed is False
+
+
+def test_convert_to_date():
+    """test for the issue in #666"""
+    editor = StartEndEditor(
+        BERLIN.localize(dt.datetime(2017, 10, 2, 13)),
+        BERLIN.localize(dt.datetime(2017, 10, 4, 18)),
+        conf=CONF
+    )
+    canvas = editor.render((50, ), True)
+    assert CanvasTranslator(canvas, palette).transform() == (
+        '[ ] Allday\nFrom: \x1b[31m2.10.2017 \x1b[0m \x1b[34m13:00 \x1b[0m\n'
+        'To:   \x1b[34m04.10.2017\x1b[0m \x1b[34m18:00 \x1b[0m\n'
+    )
+
+    assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13))
+    assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18))
+    assert editor.changed is False
+    assert editor.allday is False
+
+    # set to all day event
+    editor.keypress((10, ), 'shift tab')
+    editor.keypress((10, ), ' ')
+    for _ in range(3):
+        editor.keypress((10, ), 'tab')
+    for _ in range(3):
+        editor.keypress((10, ), 'shift tab')
+
+    canvas = editor.render((50, ), True)
+    assert CanvasTranslator(canvas, palette).transform() == (
+        '[X] Allday\nFrom: \x1b[34m02.10.2017\x1b[0m  \n'
+        'To:   \x1b[34m04.10.2017\x1b[0m  \n'
+    )
+
+    assert editor.changed is True
+    assert editor.allday is True
+    assert editor.startdt == dt.date(2017, 10, 2)
+    assert editor.enddt == dt.date(2017, 10, 4)
diff -Nru khal-0.9.10/tests/ui/tests_walker.py khal-0.10.2/tests/ui/tests_walker.py
--- khal-0.9.10/tests/ui/tests_walker.py	1970-01-01 01:00:00.000000000 +0100
+++ khal-0.10.2/tests/ui/tests_walker.py	2020-07-29 18:17:53.000000000 +0200
@@ -0,0 +1,90 @@
+import datetime as dt
+
+from freezegun import freeze_time
+
+from khal.ui import DayWalker, DListBox, StaticDayWalker
+
+from ..utils import LOCALE_BERLIN
+from .canvas_render import CanvasTranslator
+
+CONF = {'locale': LOCALE_BERLIN, 'keybindings': {},
+        'view': {'monthdisplay': 'firstday'},
+        'default': {'timedelta': dt.timedelta(days=3)},
+        }
+
+
+palette = {
+    'date header focused': 'blue',
+    'date header': 'green',
+    'default': 'black',
+}
+
+
+ at freeze_time('2017-6-7')
+def test_daywalker(coll_vdirs):
+    collection, _ = coll_vdirs
+    this_date = dt.date.today()
+    daywalker = DayWalker(this_date, None, CONF, collection, delete_status=dict())
+    elistbox = DListBox(
+        daywalker, parent=None, conf=CONF,
+        delete_status=lambda: False,
+        toggle_delete_all=None,
+        toggle_delete_instance=None,
+        dynamic_days=True,
+    )
+    canvas = elistbox.render((50, 6), True)
+    assert CanvasTranslator(canvas, palette).transform() == \
+        """\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m
+\x1b[32mTomorrow (Thursday, 08.06.2017)\x1b[0m
+\x1b[32mFriday, 09.06.2017 (2 days from now)\x1b[0m
+\x1b[32mSaturday, 10.06.2017 (3 days from now)\x1b[0m
+\x1b[32mSunday, 11.06.2017 (4 days from now)\x1b[0m
+\x1b[32mMonday, 12.06.2017 (5 days from now)\x1b[0m
+"""
+
+
+ at freeze_time('2017-6-7')
+def test_staticdaywalker(coll_vdirs):
+    collection, _ = coll_vdirs
+    this_date = dt.date.today()
+    daywalker = StaticDayWalker(this_date, None, CONF, collection, delete_status=dict())
+    elistbox = DListBox(
+        daywalker, parent=None, conf=CONF,
+        delete_status=lambda: False,
+        toggle_delete_all=None,
+        toggle_delete_instance=None,
+        dynamic_days=False,
+    )
+    canvas = elistbox.render((50, 10), True)
+    assert CanvasTranslator(canvas, palette).transform() == \
+        """\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m
+\x1b[32mTomorrow (Thursday, 08.06.2017)\x1b[0m
+\x1b[32mFriday, 09.06.2017 (2 days from now)\x1b[0m
+
+
+
+
+
+
+
+"""
+
+
+ at freeze_time('2017-6-7')
+def test_staticdaywalker_3(coll_vdirs):
+    collection, _ = coll_vdirs
+    this_date = dt.date.today()
+    conf = dict()
+    conf.update(CONF)
+    conf['default'] = {'timedelta': dt.timedelta(days=1)}
+    daywalker = StaticDayWalker(this_date, None, conf, collection, delete_status=dict())
+    elistbox = DListBox(
+        daywalker, parent=None, conf=conf,
+        delete_status=lambda: False,
+        toggle_delete_all=None,
+        toggle_delete_instance=None,
+        dynamic_days=False,
+    )
+    canvas = elistbox.render((50, 10), True)
+    assert CanvasTranslator(canvas, palette).transform() == \
+        '\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m\n\n\n\n\n\n\n\n\n\n'
diff -Nru khal-0.9.10/tests/utils.py khal-0.10.2/tests/utils.py
--- khal-0.9.10/tests/utils.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/utils.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,11 +1,11 @@
-import icalendar
 import os
 
+import icalendar
 import pytz
 
 cal0 = 'a_calendar'
 cal1 = 'foobar'
-cal2 = 'work'
+cal2 = "Dad's calendar"
 cal3 = 'private'
 
 example_cals = [cal0, cal1, cal2, cal3]
@@ -113,3 +113,18 @@
     for component in ical.walk():
         if component.name == 'VEVENT':
             yield component
+
+
+def _replace_uid(event):
+    """
+    Replace an event's UID with E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA.
+    """
+    event.pop('uid')
+    event.add('uid', 'E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA')
+    return event
+
+
+class DumbItem():
+    def __init__(self, raw, uid):
+        self.raw = raw
+        self.uid = uid
diff -Nru khal-0.9.10/tests/utils_test.py khal-0.10.2/tests/utils_test.py
--- khal-0.9.10/tests/utils_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/utils_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,623 +1,16 @@
 """testing functions from the khal.utils"""
-from datetime import date, datetime, time, timedelta
-from collections import OrderedDict
-import textwrap
-import random
-
-import icalendar
+import datetime as dt
 from freezegun import freeze_time
 
-from khal.utils import guessdatetimefstr, guesstimedeltafstr, new_event, eventinfofstr
-from khal.utils import timedelta2str, guessrangefstr, weekdaypstr, construct_daynames
-from khal.utils import get_weekday_occurrence
 from khal import utils
-from khal.exceptions import FatalError
-import pytest
-
-from .utils import _get_text, normalize_component, \
-    LOCALE_BERLIN, LOCALE_NEW_YORK
-
-today = date.today()
-tomorrow = today + timedelta(days=1)
-
-
-def _construct_event(info, locale,
-                     defaulttimelen=60, defaultdatelen=1, description=None,
-                     location=None, categories=None, repeat=None, until=None,
-                     alarm=None, **kwargs):
-    info = eventinfofstr(' '.join(info), locale, adjust_reasonably=True, localize=False)
-    if description is not None:
-        info["description"] = description
-    event = new_event(locale=locale, location=location,
-                      categories=categories, repeat=repeat, until=until,
-                      alarms=alarm, **info)
-    return event
-
-
-def _create_vevent(*args):
-    """
-    Adapt and return a default vevent for testing.
-
-    Accepts an arbitrary amount of strings like 'DTSTART;VALUE=DATE:2013015'.
-    Updates the default vevent if the key (the first word) is found and
-    appends the value otherwise.
-    """
-    def_vevent = OrderedDict(
-                     [('BEGIN', 'BEGIN:VEVENT'),
-                      ('SUMMARY', 'SUMMARY:Äwesöme Event'),
-                      ('DTSTART', 'DTSTART;VALUE=DATE:20131025'),
-                      ('DTEND', 'DTEND;VALUE=DATE:20131026'),
-                      ('DTSTAMP', 'DTSTAMP;VALUE=DATE-TIME:20140216T120000Z'),
-                      ('UID', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA')])
-
-    for row in args:
-        key = row.replace(':', ';').split(';')[0]
-        def_vevent[key] = row
-
-    def_vevent['END'] = 'END:VEVENT'
-    return list(def_vevent.values())
-
-
-def _create_testcases(*cases):
-    return [(userinput, ('\r\n'.join(output) + '\r\n').encode('utf-8'))
-            for userinput, output in cases]
-
-
-def _replace_uid(event):
-    """
-    Replace an event's UID with E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA.
-    """
-    event.pop('uid')
-    event.add('uid', 'E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA')
-    return event
-
-
-def _get_TZIDs(lines):
-    """from a list of strings, get all unique strings that start with TZID"""
-    return sorted((line for line in lines if line.startswith('TZID')))
-
-
-def test_normalize_component():
-    assert normalize_component(textwrap.dedent("""
-    BEGIN:VEVENT
-    DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000
-    END:VEVENT
-    """)) != normalize_component(textwrap.dedent("""
-    BEGIN:VEVENT
-    DTSTART;TZID=Oyrope/Berlin;VALUE=DATE-TIME:20140409T093000
-    END:VEVENT
-    """))
-
-
-class TestGuessDatetimefstr:
-    tomorrow16 = datetime.combine(tomorrow, time(16, 0))
-
-    def test_today(self):
-        with freeze_time('2016-9-19 8:00'):
-            today13 = datetime.combine(date.today(), time(13, 0))
-            assert (today13, False) == guessdatetimefstr(['today', '13:00'], LOCALE_BERLIN)
-            assert date.today() == guessdatetimefstr(['today'], LOCALE_BERLIN)[0].date()
-
-    def test_tomorrow(self):
-        assert (self.tomorrow16, False) == \
-            guessdatetimefstr('tomorrow 16:00 16:00'.split(), locale=LOCALE_BERLIN)
-
-    def test_time_tomorrow(self):
-        assert (self.tomorrow16, False) == \
-            guessdatetimefstr('16:00'.split(), locale=LOCALE_BERLIN, default_day=tomorrow)
-
-    def test_time_yesterday(self):
-        with freeze_time('2016-9-19'):
-            assert (datetime(2016, 9, 18, 16), False) == \
-                guessdatetimefstr(
-                    'Yesterday 16:00'.split(),
-                    locale=LOCALE_BERLIN,
-                    default_day=datetime.today())
-
-    def test_time_weekday(self):
-        with freeze_time('2016-9-19'):
-            assert (datetime(2016, 9, 23, 16), False) == \
-                guessdatetimefstr(
-                    'Friday 16:00'.split(),
-                    locale=LOCALE_BERLIN,
-                    default_day=datetime.today())
-
-    def test_time_now(self):
-        with freeze_time('2016-9-19 17:53'):
-            assert (datetime(2016, 9, 19, 17, 53), False) == \
-                guessdatetimefstr('now'.split(), locale=LOCALE_BERLIN, default_day=datetime.today())
-
-    def test_short_format_contains_year(self):
-        """if the non long versions of date(time)format contained a year, the
-        current year would be used instead of the given one, see #545"""
-        locale = {
-            'timeformat': '%H:%M',
-            'dateformat': '%Y-%m-%d',
-            'longdateformat': '%Y-%m-%d',
-            'datetimeformat': '%Y-%m-%d %H:%M',
-            'longdatetimeformat': '%Y-%m-%d %H:%M',
-        }
-        with freeze_time('2016-12-30 17:53'):
-            assert (datetime(2017, 1, 1), True) == \
-                guessdatetimefstr('2017-1-1'.split(), locale=locale, default_day=datetime.today())
-
-        with freeze_time('2016-12-30 17:53'):
-            assert (datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr(
-                '2017-1-1 16:30'.split(), locale=locale, default_day=datetime.today(),
-            )
-
-
-class TestGuessTimedeltafstr:
-
-    def test_single(self):
-        assert timedelta(minutes=10) == guesstimedeltafstr('10m')
-
-    def test_seconds(self):
-        assert timedelta(seconds=10) == guesstimedeltafstr('10s')
-
-    def test_negative(self):
-        assert timedelta(minutes=-10) == guesstimedeltafstr('-10m')
-
-    def test_multi(self):
-        assert timedelta(days=1, hours=-3, minutes=10) == \
-            guesstimedeltafstr(' 1d -3H 10min ')
-
-    def test_multi_nospace(self):
-        assert timedelta(days=1, hours=-3, minutes=10) == \
-            guesstimedeltafstr('1D-3hour10m')
-
-    def test_garbage(self):
-        with pytest.raises(ValueError):
-                guesstimedeltafstr('10mbar')
-
-    def test_moregarbage(self):
-        with pytest.raises(ValueError):
-                guesstimedeltafstr('foo10m')
-
-    def test_same(self):
-        assert timedelta(minutes=20) == \
-            guesstimedeltafstr('10min 10minutes')
-
-
-class TestGuessRangefstr:
-    td_1d = timedelta(days=1)
-    today_start = datetime.combine(date.today(), time.min)
-    tomorrow_start = today_start + td_1d
-    today13 = datetime.combine(date.today(), time(13, 0))
-    today14 = datetime.combine(date.today(), time(14, 0))
-    tomorrow16 = datetime.combine(tomorrow, time(16, 0))
-    today16 = datetime.combine(date.today(), time(16, 0))
-    today17 = datetime.combine(date.today(), time(17, 0))
-
-    def test_today(self):
-        with freeze_time('2016-9-19'):
-            assert (datetime(2016, 9, 19, 13), datetime(2016, 9, 19, 14), False) == \
-                guessrangefstr('13:00 14:00', locale=LOCALE_BERLIN)
-            assert (datetime(2016, 9, 19), datetime(2016, 9, 21), True) == \
-                guessrangefstr('today tomorrow', LOCALE_BERLIN)
-
-    def test_tomorrow(self):
-        # XXX remove me, we shouldn't support this anyway
-        with freeze_time('2016-9-19 16:34'):
-            assert (datetime(2016, 9, 19), datetime(2016, 9, 21, 16), True) == \
-                guessrangefstr('today tomorrow 16:00', locale=LOCALE_BERLIN)
-
-    def test_time_tomorrow(self):
-        with freeze_time('2016-9-19 13:34'):
-            assert (datetime(2016, 9, 19, 16), datetime(2016, 9, 19, 17), False) == \
-                guessrangefstr('16:00', locale=LOCALE_BERLIN)
-            assert (datetime(2016, 9, 19, 16), datetime(2016, 9, 19, 17), False) == \
-                guessrangefstr('16:00 17:00', locale=LOCALE_BERLIN)
-
-    def test_start_and_end_date(self):
-        assert (datetime(2016, 1, 1), datetime(2017, 1, 2), True) == \
-            guessrangefstr('1.1.2016 1.1.2017', locale=LOCALE_BERLIN)
-
-    def test_start_and_no_end_date(self):
-        assert (datetime(2016, 1, 1), datetime(2016, 1, 2), True) == \
-            guessrangefstr('1.1.2016', locale=LOCALE_BERLIN)
-
-    def test_start_and_end_date_time(self):
-        assert (datetime(2016, 1, 1, 10), datetime(2017, 1, 1, 22), False) == \
-            guessrangefstr(
-                '1.1.2016 10:00 1.1.2017 22:00', locale=LOCALE_BERLIN)
-
-    def test_start_and_eod(self):
-        assert (datetime(2016, 1, 1, 10), datetime(2016, 1, 1, 23, 59, 59, 999999), False) == \
-            guessrangefstr('1.1.2016 10:00 eod', locale=LOCALE_BERLIN)
-
-    def test_start_and_week(self):
-        assert (datetime(2015, 12, 28), datetime(2016, 1, 5), True) == \
-            guessrangefstr('1.1.2016 week', locale=LOCALE_BERLIN)
-
-    def test_start_and_delta_1d(self):
-        assert (datetime(2016, 1, 1), datetime(2016, 1, 2), True) == \
-            guessrangefstr('1.1.2016 1d', locale=LOCALE_BERLIN)
-
-    def test_start_and_delta_3d(self):
-        assert (datetime(2016, 1, 1), datetime(2016, 1, 4), True) == \
-            guessrangefstr('1.1.2016 3d', locale=LOCALE_BERLIN)
-
-    def test_start_dt_and_delta(self):
-        assert (datetime(2016, 1, 1, 10), datetime(2016, 1, 4, 10), False) == \
-            guessrangefstr('1.1.2016 10:00 3d', locale=LOCALE_BERLIN)
-
-    def test_start_allday_and_delta_datetime(self):
-        with pytest.raises(FatalError):
-            guessrangefstr('1.1.2016 3d3m', locale=LOCALE_BERLIN)
-
-    def test_start_zero_day_delta(self):
-        with pytest.raises(FatalError):
-            guessrangefstr('1.1.2016 0d', locale=LOCALE_BERLIN)
-
-    @freeze_time('20160216')
-    def test_week(self):
-        assert (datetime(2016, 2, 15), datetime(2016, 2, 23), True) == \
-            guessrangefstr('week', locale=LOCALE_BERLIN)
-
-    def test_invalid(self):
-        with pytest.raises(ValueError):
-            guessrangefstr('3d', locale=LOCALE_BERLIN)
-        with pytest.raises(ValueError):
-            guessrangefstr('35.1.2016', locale=LOCALE_BERLIN)
-        with pytest.raises(ValueError):
-            guessrangefstr('1.1.2016 2x', locale=LOCALE_BERLIN)
-        with pytest.raises(ValueError):
-            guessrangefstr('1.1.2016x', locale=LOCALE_BERLIN)
-        with pytest.raises(ValueError):
-            guessrangefstr('xxx yyy zzz', locale=LOCALE_BERLIN)
-
-    def test_short_format_contains_year(self):
-        """if the non long versions of date(time)format contained a year, the
-        current year would be used instead of the given one, see #545
-
-        same as above, but for guessrangefstr
-        """
-        locale = {
-            'timeformat': '%H:%M',
-            'dateformat': '%Y-%m-%d',
-            'longdateformat': '%Y-%m-%d',
-            'datetimeformat': '%Y-%m-%d %H:%M',
-            'longdatetimeformat': '%Y-%m-%d %H:%M',
-        }
-        with freeze_time('2016-12-30 17:53'):
-            assert (datetime(2017, 1, 1), datetime(2017, 1, 2), True) == \
-                guessrangefstr('2017-1-1 2017-1-1', locale=locale)
-
-
-class TestTimeDelta2Str:
-
-    def test_single(self):
-        assert timedelta2str(timedelta(minutes=10)) == '10m'
-
-    def test_negative(self):
-        assert timedelta2str(timedelta(minutes=-10)) == '-10m'
-
-    def test_days(self):
-        assert timedelta2str(timedelta(days=2)) == '2d'
-
-    def test_multi(self):
-        assert timedelta2str(timedelta(days=6, hours=-3, minutes=10, seconds=-3)) == '5d 21h 9m 57s'
-
-
-def test_weekdaypstr():
-    for string, weekdayno in [
-            ('monday', 0),
-            ('tue', 1),
-            ('wednesday', 2),
-            ('thursday', 3),
-            ('fri', 4),
-            ('saturday', 5),
-            ('sun', 6),
-    ]:
-        assert weekdaypstr(string) == weekdayno
-
-
-def test_weekdaypstr_invalid():
-    with pytest.raises(ValueError):
-        weekdaypstr('foobar')
-
-
-def test_construct_daynames():
-    with freeze_time('2016-9-19'):
-        assert construct_daynames(date(2016, 9, 19)) == 'Today'
-        assert construct_daynames(date(2016, 9, 20)) == 'Tomorrow'
-        assert construct_daynames(date(2016, 9, 21)) == 'Wednesday'
-
-
-test_set_format_de = _create_testcases(
-    # all-day-events
-    # one day only
-    ('25.10.2013 Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20131025',
-                    'DTEND;VALUE=DATE:20131026')),
-
-    # 2 day
-    ('15.08.2014 16.08. Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20140815',
-                    'DTEND;VALUE=DATE:20140817')),  # XXX
-
-    # end date in next year and not specified
-    ('29.12.2014 03.01. Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20141229',
-                    'DTEND;VALUE=DATE:20150104')),
-
-    # end date in next year
-    ('29.12.2014 03.01.2015 Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20141229',
-                    'DTEND;VALUE=DATE:20150104')),
-
-    # datetime events
-    # start and end date same, no explicit end date given
-    ('25.10.2013 18:00 20:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T200000')),
-
-    # start and end date same, ends 24:00 which should be 00:00 (start) of next
-    # day
-    ('25.10.2013 18:00 24:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T000000')),
-
-    # start and end date same, explicit end date (but no year) given
-    # XXX FIXME: if no explicit year is given for the end, this_year is used
-    ('25.10.2013 18:00 26.10. 20:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T200000')),
-
-    # date ends next day, but end date not given
-    ('25.10.2013 23:00 0:30 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T230000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T003000')),
-
-    # only start datetime given
-    ('25.10.2013 06:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T060000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T070000')),
-
-    # timezone given
-    ('25.10.2013 06:00 America/New_York Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=America/New_York;VALUE=DATE-TIME:20131025T060000',
-        'DTEND;TZID=America/New_York;VALUE=DATE-TIME:20131025T070000'))
-)
-
-
- at freeze_time('20140216T120000')
-def test_construct_event_format_de():
-    for data_list, vevent_expected in test_set_format_de:
-        vevent = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
-        assert _replace_uid(vevent).to_ical() == vevent_expected
-
-
-test_set_format_us = _create_testcases(
-    ('1999/12/31-06:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=America/New_York;VALUE=DATE-TIME:19991231T060000',
-        'DTEND;TZID=America/New_York;VALUE=DATE-TIME:19991231T070000')),
-
-    ('2014/12/18 2014/12/20 Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20141218',
-                    'DTEND;VALUE=DATE:20141221')),
-)
-
-
-def test__construct_event_format_us():
-    for data_list, vevent in test_set_format_us:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(), locale=LOCALE_NEW_YORK)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-test_set_format_de_complexer = _create_testcases(
-    # now events where the start date has to be inferred, too
-    # today
-    ('8:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000')),
-
-    # today until tomorrow
-    ('22:00  1:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T220000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140217T010000')),
-
-    # other timezone
-    ('22:00 1:00 Europe/London Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/London;VALUE=DATE-TIME:20140216T220000',
-        'DTEND;TZID=Europe/London;VALUE=DATE-TIME:20140217T010000')),
-
-    ('15.06. Äwesöme Event',
-     _create_vevent('DTSTART;VALUE=DATE:20140615',
-                    'DTEND;VALUE=DATE:20140616')),
-)
-
-
-def test__construct_event_format_de_complexer():
-    for data_list, vevent in test_set_format_de_complexer:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-test_set_leap_year = _create_testcases(
-    ('29.02. Äwesöme Event',
-     _create_vevent(
-      'DTSTART;VALUE=DATE:20160229',
-      'DTEND;VALUE=DATE:20160301',
-      'DTSTAMP;VALUE=DATE-TIME:20160101T202122Z')),
-)
-
-
-def test_leap_year():
-    for data_list, vevent in test_set_leap_year:
-        with freeze_time('1999-1-1'):
-            with pytest.raises(ValueError):
-                event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
-        with freeze_time('2016-1-1 20:21:22'):
-            event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-test_set_description = _create_testcases(
-    # now events where the start date has to be inferred, too
-    # today
-    ('8:00 Äwesöme Event :: this is going to be awesome',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
-        'DESCRIPTION:this is going to be awesome')),
-
-    # today until tomorrow
-    ('22:00  1:00 Äwesöme Event :: Will be even better',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T220000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140217T010000',
-        'DESCRIPTION:Will be even better')),
-
-    ('15.06. Äwesöme Event :: and again',
-     _create_vevent('DTSTART;VALUE=DATE:20140615',
-                    'DTEND;VALUE=DATE:20140616',
-                    'DESCRIPTION:and again')),
-)
-
-
-def test_description():
-    for data_list, vevent in test_set_description:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(), locale=LOCALE_BERLIN)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-test_set_repeat = _create_testcases(
-    # now events where the start date has to be inferred, too
-    # today
-    ('8:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
-        'DESCRIPTION:please describe the event',
-        'RRULE:FREQ=DAILY;UNTIL=20150605T000000')))
-
-
-def test_repeat():
-    for data_list, vevent in test_set_repeat:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(),
-                                     description='please describe the event',
-                                     repeat='daily',
-                                     until='05.06.2015',
-                                     locale=LOCALE_BERLIN)
-            assert normalize_component(_replace_uid(event).to_ical()) == \
-                normalize_component(vevent)
-
-
-test_set_alarm = _create_testcases(
-    ('8:00 Äwesöme Event',
-     ['BEGIN:VEVENT',
-      'SUMMARY:Äwesöme Event',
-      'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
-      'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
-      'DTSTAMP;VALUE=DATE-TIME:20140216T120000Z',
-      'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA',
-      'DESCRIPTION:please describe the event',
-      'BEGIN:VALARM',
-      'ACTION:DISPLAY',
-      'DESCRIPTION:please describe the event',
-      'TRIGGER:-PT23M',
-      'END:VALARM',
-      'END:VEVENT']))
-
-
-def test_alarm():
-    for data_list, vevent in test_set_alarm:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(),
-                                     description='please describe the event',
-                                     alarm='23m',
-                                     locale=LOCALE_BERLIN)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-test_set_description_and_location_and_categories = _create_testcases(
-    # now events where the start date has to be inferred, too
-    # today
-    ('8:00 Äwesöme Event',
-     _create_vevent(
-        'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T080000',
-        'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000',
-        'CATEGORIES:boring meeting',
-        'DESCRIPTION:please describe the event',
-        'LOCATION:in the office')))
-
-
-def test_description_and_location_and_categories():
-    for data_list, vevent in test_set_description_and_location_and_categories:
-        with freeze_time('2014-02-16 12:00:00'):
-            event = _construct_event(data_list.split(),
-                                     description='please describe the event',
-                                     location='in the office',
-                                     categories='boring meeting',
-                                     locale=LOCALE_BERLIN)
-            assert _replace_uid(event).to_ical() == vevent
-
-
-def test_split_ics():
-    cal = _get_text('cal_lots_of_timezones')
-    vevents = utils.split_ics(cal)
-
-    vevents0 = vevents[0].split('\r\n')
-    vevents1 = vevents[1].split('\r\n')
-
-    part0 = _get_text('part0').split('\n')
-    part1 = _get_text('part1').split('\n')
-
-    assert _get_TZIDs(vevents0) == _get_TZIDs(part0)
-    assert _get_TZIDs(vevents1) == _get_TZIDs(part1)
-
-    assert sorted(vevents0) == sorted(part0)
-    assert sorted(vevents1) == sorted(part1)
-
-
-def test_split_ics_random_uid():
-    random.seed(123)
-    cal = _get_text('cal_lots_of_timezones')
-    vevents = utils.split_ics(cal, random_uid=True)
-
-    part0 = _get_text('part0').split('\n')
-    part1 = _get_text('part1').split('\n')
-
-    for item in icalendar.Calendar.from_ical(vevents[0]).walk():
-        if item.name == 'VEVENT':
-            assert item['UID'] == 'DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1'
-    for item in icalendar.Calendar.from_ical(vevents[1]).walk():
-        if item.name == 'VEVENT':
-            assert item['UID'] == '4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB'
-
-    # after replacing the UIDs, everything should be as above
-    vevents0 = vevents[0].replace('DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1', '123').split('\r\n')
-    vevents1 = vevents[1].replace('4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB', 'abcde').split('\r\n')
-
-    assert _get_TZIDs(vevents0) == _get_TZIDs(part0)
-    assert _get_TZIDs(vevents1) == _get_TZIDs(part1)
-
-    assert sorted(vevents0) == sorted(part0)
-    assert sorted(vevents1) == sorted(part1)
 
 
 def test_relative_timedelta_str():
     with freeze_time('2016-9-19'):
-        assert utils.relative_timedelta_str(date(2016, 9, 24)) == '5 days from now'
-        assert utils.relative_timedelta_str(date(2016, 9, 29)) == '~1 week from now'
-        assert utils.relative_timedelta_str(date(2017, 9, 29)) == '~1 year from now'
-        assert utils.relative_timedelta_str(date(2016, 7, 29)) == '~7 weeks ago'
+        assert utils.relative_timedelta_str(dt.date(2016, 9, 24)) == '5 days from now'
+        assert utils.relative_timedelta_str(dt.date(2016, 9, 29)) == '~1 week from now'
+        assert utils.relative_timedelta_str(dt.date(2017, 9, 29)) == '~1 year from now'
+        assert utils.relative_timedelta_str(dt.date(2016, 7, 29)) == '~7 weeks ago'
 
 
 weekheader = """    Mo Tu We Th Fr Sa Su   """
@@ -682,21 +75,21 @@
 
 
 def test_get_weekday_occurrence():
-    assert get_weekday_occurrence(datetime(2017, 3, 1)) == (2, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 2)) == (3, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 3)) == (4, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 4)) == (5, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 5)) == (6, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 6)) == (0, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 7)) == (1, 1)
-    assert get_weekday_occurrence(datetime(2017, 3, 8)) == (2, 2)
-    assert get_weekday_occurrence(datetime(2017, 3, 9)) == (3, 2)
-    assert get_weekday_occurrence(datetime(2017, 3, 10)) == (4, 2)
-
-    assert get_weekday_occurrence(datetime(2017, 3, 31)) == (4, 5)
-
-    assert get_weekday_occurrence(date(2017, 5, 1)) == (0, 1)
-    assert get_weekday_occurrence(date(2017, 5, 7)) == (6, 1)
-    assert get_weekday_occurrence(date(2017, 5, 8)) == (0, 2)
-    assert get_weekday_occurrence(date(2017, 5, 28)) == (6, 4)
-    assert get_weekday_occurrence(date(2017, 5, 29)) == (0, 5)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 1)) == (2, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 2)) == (3, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 3)) == (4, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 4)) == (5, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 5)) == (6, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 6)) == (0, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 7)) == (1, 1)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 8)) == (2, 2)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 9)) == (3, 2)
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 10)) == (4, 2)
+
+    assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 31)) == (4, 5)
+
+    assert utils.get_weekday_occurrence(dt.date(2017, 5, 1)) == (0, 1)
+    assert utils.get_weekday_occurrence(dt.date(2017, 5, 7)) == (6, 1)
+    assert utils.get_weekday_occurrence(dt.date(2017, 5, 8)) == (0, 2)
+    assert utils.get_weekday_occurrence(dt.date(2017, 5, 28)) == (6, 4)
+    assert utils.get_weekday_occurrence(dt.date(2017, 5, 29)) == (0, 5)
diff -Nru khal-0.9.10/tests/vtimezone_test.py khal-0.10.2/tests/vtimezone_test.py
--- khal-0.9.10/tests/vtimezone_test.py	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tests/vtimezone_test.py	2020-07-29 18:17:53.000000000 +0200
@@ -1,12 +1,13 @@
-from datetime import datetime as datetime
+import datetime as dt
+
 import pytz
 from khal.khalendar.event import create_timezone
 
 berlin = pytz.timezone('Europe/Berlin')
 bogota = pytz.timezone('America/Bogota')
 
-atime = datetime(2014, 10, 28, 10, 10)
-btime = datetime(2016, 10, 28, 10, 10)
+atime = dt.datetime(2014, 10, 28, 10, 10)
+btime = dt.datetime(2016, 10, 28, 10, 10)
 
 
 def test_berlin():
diff -Nru khal-0.9.10/tox.ini khal-0.10.2/tox.ini
--- khal-0.9.10/tox.ini	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/tox.ini	2020-07-29 18:17:53.000000000 +0200
@@ -1,5 +1,5 @@
 [tox]
-envlist = {py33,py34,py35,py36}-{tests,style}-{pytz201702,pytz201610}
+envlist = {py34,py35,py36,py37}-{tests,style,mypy}-{pytz201702,pytz201610,pytz_latest}
 skip_missing_interpreters = True
 
 [testenv]
@@ -15,25 +15,24 @@
   TRAVIS_PULL_REQUEST
   TRAVIS_REPO_SLUG
 deps =
-    codecov
     pytest
-    pytest-cov
-    pytest-capturelog
     freezegun
-    vdirsyncer<0.17.0
+    vdirsyncer
+    python-dateutil
     pytz201702: pytz==2017.2
     pytz201610: pytz==2016.10
+    pytz_latest: pytz
+    py34: typing
 
 commands =
-    py.test --cov khal {posargs}
-    codecov -e TOXENV
+    py.test {posargs}
 
 [testenv:style]
 skip_install=True
 whitelist_externals = sh
 deps = flake8
 commands =
-    flake8
+    flake8 --ignore E252,W504,E121 tests khal setup.py
     sh -c '! grep -ri seperat */*'
 
 [testenv:docs]
@@ -43,6 +42,13 @@
   make -C doc html
   make -C doc man
 
+[testenv:mypy]
+skip_install=True
+whitelist_externals = sh
+deps = mypy
+commands =
+    mypy --ignore-missing-imports khal
+
 [flake8]
 max-line-length = 100
-exclude=.tox,examples,doc
+exclude = .tox,examples,doc
diff -Nru khal-0.9.10/.travis.yml khal-0.10.2/.travis.yml
--- khal-0.9.10/.travis.yml	2018-10-09 18:01:22.000000000 +0200
+++ khal-0.10.2/.travis.yml	2020-07-29 18:17:53.000000000 +0200
@@ -2,11 +2,9 @@
 language: python
 
 python:
-    - 3.3
     - 3.4
     - 3.5
     - 3.6
-    - 3.7-dev
 
 env:
     - BUILD=py
@@ -18,7 +16,16 @@
       - python: 3.6
         env: BUILD=docs
       - python: 3.6
+        env: BUILD=mypy
+      - python: 3.6
         env: BUILD=pytz201610
+      - python: 3.6
+        env: BUILD=pytz201702
+      - python: 3.6
+        env: BUILD=pytz_latest
+      - python: 3.7
+        dist: xenial
+        sudo: true
 
 addons:
     apt:



More information about the Python-apps-team mailing list