Repository: pimutils/khal

Issue:
Parse out other events similar to 'birthday'
I see `X-ANNIVERSARY` tags at least. There are probably others in use too.


Discussion:

Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 6a1fcd7..2e925c3 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -36,4 +36,5 @@ 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
\ No newline at end of file
+Florian Wehner - florian [at] whnr [dot] de
+Martin Stone
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 7dba27f..dcdc361 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,6 +8,13 @@ may want to subscribe to `GitHub's tag feed
 <https://github.com/geier/khal/tags.atom>`_.
 
 
+0.10.2
+======
+not released
+
+* NEW Parse `X-ANNIVERSARY`, `ANNIVERSARY` and `X-ABDATE` fields from vcards.
+
+
 0.10.1
 ======
 2019-03-30
diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py
index 4fba4a6..b544219 100644
--- a/khal/khalendar/backend.py
+++ b/khal/khalendar/backend.py
@@ -240,58 +240,83 @@ class SQLiteDb(object):
         stuple = (vevent_str, etag, href, calendar)
         self.sql_ex(sql_s, stuple)
 
-    def update_birthday(self, vevent_str: str, href: str, etag: str='', calendar: str=None) -> 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)
+        # Delete all event entries for this contact
+        self.deletelike(href + '%', calendar=calendar)
         ical = utils.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]])
-            vevent = icalendar.Event()
-            vevent.add('dtstart', bday)
-            vevent.add('dtend', bday + dt.timedelta(days=1))
-            if bday.month == 2 and bday.day == 29:  # leap year
-                vevent.add('rrule', {'freq': 'YEARLY', 'BYYEARDAY': 60})
-            else:
-                vevent.add('rrule', {'freq': 'YEARLY'})
-            if orig_bday:
-                vevent.add('x-birthday',
-                           '{:04}{:02}{:02}'.format(bday.year, bday.month, bday.day))
-                vevent.add('x-fname', name)
-            vevent.add('summary', '{0}\'s birthday'.format(name))
-            vevent.add('uid', href)
-            vevent_str = vevent.to_ical().decode('utf-8')
-            self._update_impl(vevent, href, calendar)
-            sql_s = ('INSERT INTO events (item, etag, href, calendar) VALUES (?, ?, ?, ?);')
-            stuple = (vevent_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: icalendar.cal.Event, href: str, calendar: str) -> None:
         """insert `vevent` into the database
@@ -407,6 +432,23 @@ class SQLiteDb(object):
         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`
 
@@ -611,3 +653,22 @@ def calc_shift_deltas(vevent: icalendar.Event) -> Tuple[dt.timedelta, dt.timedel
     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 --git a/khal/khalendar/event.py b/khal/khalendar/event.py
index ddd5cf6..9c0acf2 100644
--- a/khal/khalendar/event.py
+++ b/khal/khalendar/event.py
@@ -351,11 +351,23 @@ class Event(object):
 
     @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:
+            if int(date[4:6]) == 2 and int(date[6:8]) == 29:
                 leap = ' (29th of Feb.)'
             else:
                 leap = ''
@@ -367,8 +379,8 @@ class Event(object):
                 suffix = 'rd'
             else:
                 suffix = 'th'
-            return '{name}\'s {number}{suffix} birthday{leap}'.format(
-                name=name, number=number, suffix=suffix, leap=leap,
+            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', '')
diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py
index dfb2d15..b01c2d5 100644
--- a/khal/khalendar/khalendar.py
+++ b/khal/khalendar/khalendar.py
@@ -305,6 +305,7 @@ class CalendarCollection(object):
         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():
@@ -314,7 +315,14 @@ class CalendarCollection(object):
                     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
 
@@ -324,7 +332,7 @@ class CalendarCollection(object):
         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)


Test patch:
diff --git a/tests/backend_test.py b/tests/backend_test.py
index 46bcea8..5ef0ff2 100644
--- a/tests/backend_test.py
+++ b/tests/backend_test.py
@@ -655,6 +655,35 @@ BDAY:--0311
 END:VCARD
 """
 
+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)
@@ -663,7 +692,7 @@ 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 'SUMMARY:Unix\'s birthday' in events[0][0]
@@ -678,15 +707,15 @@ def test_birthdays():
 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_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_birthday(card_no_fn, 'unix.vcf', calendar=calname)
+    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
@@ -696,7 +725,7 @@ def test_birthdays_no_fn():
 def test_birthday_does_not_parse():
     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_does_not_parse, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
     assert len(events) == 0
 
@@ -704,6 +733,66 @@ def test_birthday_does_not_parse():
 def test_vcard_two_birthdays():
     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_two_birthdays, 'unix.vcf', calendar=calname)
     events = list(db.get_floating(start, end))
     assert len(events) == 0
+
+
+def test_anniversary():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_anniversary, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
+    assert len(events) == 1
+    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_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 '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_abdate_nolabel():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_abdate_nolabel, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
+    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_birthday_v3():
+    db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
+    assert list(db.get_floating(start, end)) == list()
+    db.update_vcf_dates(card_v3, 'unix.vcf', calendar=calname)
+    events = list(db.get_floating(start, end))
+    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]