diff --git a/tests/utils/test_vobject.py b/tests/utils/test_vobject.py index 9ece4ab..7462227 100644 --- a/tests/utils/test_vobject.py +++ b/tests/utils/test_vobject.py @@ -85,3 +85,54 @@ def test_hash_item(): b = u'\n'.join(line for line in a.splitlines() if u'PRODID' not in line and u'VERSION' not in line) assert vobject.hash_item(a) == vobject.hash_item(b) + + +def test_multiline_uid(): + a = (u'BEGIN:FOO\r\n' + u'UID:123456789abcd\r\n' + u' efgh\r\n' + u'END:FOO\r\n') + assert vobject.Item(a).uid == u'123456789abcdefgh' + + +def test_multiline_uid_complex(): + a = u''' +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:Europe/Rome +X-LIC-LOCATION:Europe/Rome +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART:20140124T133000Z +DTEND:20140124T143000Z +DTSTAMP:20140612T090652Z +UID:040000008200E00074C5B7101A82E0080000000050AAABEEF50DCF0100000000000000 + 001000000062548482FA830A46B9EA62114AC9F0EF +CREATED:20140110T102231Z +DESCRIPTION:Test. +LAST-MODIFIED:20140123T095221Z +LOCATION:25.12.01.51 +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Präsentation +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR + '''.strip() + assert vobject.Item(a).uid == (u'040000008200E00074C5B7101A82E008000000005' + u'0AAABEEF50DCF0100000000000000001000000062' + u'548482FA830A46B9EA62114AC9F0EF') diff --git a/vdirsyncer/storage/base.py b/vdirsyncer/storage/base.py index e85b8f2..653a4d4 100644 --- a/vdirsyncer/storage/base.py +++ b/vdirsyncer/storage/base.py @@ -9,42 +9,7 @@ from .. import exceptions -from .. import utils -from ..utils.compat import text_type - - -class Item(object): - - '''should-be-immutable wrapper class for VCALENDAR (VEVENT, VTODO) and - VCARD''' - - uid = None - '''Global identifier of the item, across storages, doesn't change after a - modification of the item.''' - - raw = None - '''Raw content of the item, which vdirsyncer doesn't validate in any - way.''' - - hash = None - '''Hash of self.raw, used for etags.''' - - ident = None - '''Used for generating hrefs and matching up items during synchronization. - This is either the UID or the hash of the item's content.''' - - def __init__(self, raw): - assert isinstance(raw, text_type) - - for line in raw.splitlines(): - if line.startswith(u'UID:'): - uid = line[4:].strip() - if uid: - self.uid = uid - - self.raw = raw - self.hash = utils.vobject.hash_item(raw) - self.ident = self.uid or self.hash +from vdirsyncer.utils.vobject import Item # noqa class Storage(object): diff --git a/vdirsyncer/utils/__init__.py b/vdirsyncer/utils/__init__.py index b8197e5..bc5363c 100644 --- a/vdirsyncer/utils/__init__.py +++ b/vdirsyncer/utils/__init__.py @@ -15,6 +15,7 @@ from .compat import urlparse, get_raw_input logger = log.get(__name__) +_missing = object() try: @@ -288,3 +289,25 @@ def checkfile(path, create=False): 'True in your configuration to automatically ' 'create it, or create it ' 'yourself.'.format(path)) + + +class cached_property(object): + ''' + Copied from Werkzeug. + Copyright 2007-2014 Armin Ronacher + ''' + + def __init__(self, func, name=None, doc=None): + self.__name__ = name or func.__name__ + self.__module__ = func.__module__ + self.__doc__ = doc or func.__doc__ + self.func = func + + def __get__(self, obj, type=None): + if obj is None: + return self + value = obj.__dict__.get(self.__name__, _missing) + if value is _missing: + value = self.func(obj) + obj.__dict__[self.__name__] = value + return value diff --git a/vdirsyncer/utils/vobject.py b/vdirsyncer/utils/vobject.py index 8ffd4b2..f1bca9a 100644 --- a/vdirsyncer/utils/vobject.py +++ b/vdirsyncer/utils/vobject.py @@ -12,6 +12,7 @@ import icalendar.cal import icalendar.parser import icalendar.caselessdict +from . import cached_property from .compat import text_type, itervalues @@ -47,13 +48,71 @@ ICALENDAR_ORIGINAL_ORDER_SUPPORT = \ hasattr(icalendar.caselessdict.CaselessDict, '__reversed__') -def normalize_item(text, ignore_props=IGNORE_PROPS, use_icalendar=True): - try: - if not use_icalendar: - raise Exception() - lines = to_unicode_lines(icalendar.cal.Component.from_ical(text)) - except Exception: - lines = sorted(text.splitlines()) +class Item(object): + + '''should-be-immutable wrapper class for VCALENDAR (VEVENT, VTODO) and + VCARD''' + + def __init__(self, raw): + assert isinstance(raw, text_type) + + self._raw = raw + + @cached_property + def raw(self): + '''Raw content of the item, which vdirsyncer doesn't validate in any + way.''' + return self._raw + + @cached_property + def uid(self): + '''Global identifier of the item, across storages, doesn't change after + a modification of the item.''' + stack = [self.parsed] + while stack: + component = stack.pop() + if component is None: + continue + uid = component.get('UID', None) + if uid: + return uid + stack.extend(component.subcomponents) + + for line in self.raw.splitlines(): + if line.startswith(u'UID:'): + uid = line[4:].strip() + if uid: + return uid + + @cached_property + def hash(self): + '''Hash of self.raw, used for etags.''' + return hash_item(self.raw) + + @cached_property + def ident(self): + '''Used for generating hrefs and matching up items during + synchronization. This is either the UID or the hash of the item's + content.''' + return self.uid or self.hash + + @cached_property + def parsed(self): + try: + return icalendar.cal.Component.from_ical(self.raw) + except Exception: + return None + + +def normalize_item(item, ignore_props=IGNORE_PROPS, use_icalendar=True): + if not isinstance(item, Item): + item = Item(item) + if use_icalendar and item.parsed is not None: + # We have to explicitly check "is not None" here because VCALENDARS + # with only subcomponents and no own properties are also false-ish. + lines = to_unicode_lines(item.parsed) + else: + lines = sorted(item.raw.splitlines()) return u'\r\n'.join(line.strip() for line in lines