# Xandikos # Copyright (C) 2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ICalendar file handling. """ import logging from icalendar.cal import Calendar, component_factory from xandikos.store import File, InvalidFileContents def calendar_component_delta(old_cal, new_cal): """Find the differences between components in two calendars. :param old_cal: Old calendar (can be None) :param new_cal: New calendar (can be None) :yield: (old_component, new_component) tuples (either can be None) """ by_uid = {} by_content = {} by_idx = {} idx = 0 for component in getattr(old_cal, "subcomponents", []): try: by_uid[component["UID"]] = component except KeyError: by_content[component.to_ical()] = True by_idx[idx] = component idx += 1 idx = 0 for component in new_cal.subcomponents: try: old_component = by_uid.pop(component["UID"]) except KeyError: if not by_content.pop(component.to_ical(), None): # Not previously present yield (by_idx.get(idx, component_factory[component.name]()), component) by_idx.pop(idx, None) else: yield (old_component, component) for old_component in by_idx.values(): yield (old_component, component_factory[old_component.name]()) def calendar_prop_delta(old_component, new_component): fields = set([field for field in old_component or []] + [field for field in new_component or []]) for field in fields: old_value = old_component.get(field) new_value = new_component.get(field) if ( getattr(old_value, 'to_ical', None) is None or getattr(new_value, 'to_ical', None) is None or old_value.to_ical() != new_value.to_ical() ): yield (field, old_value, new_value) def describe_component(component): if component.name == "VTODO": try: return "task '%s'" % component["SUMMARY"] except KeyError: return "task" else: try: return component["SUMMARY"] except KeyError: return "calendar item" DELTA_IGNORE_FIELDS = set(["LAST-MODIFIED", "SEQUENCE", "DTSTAMP", "PRODID", "CREATED", "COMPLETED", "X-MOZ-GENERATION", "X-LIC-ERROR", "UID"]) def describe_calendar_delta(old_cal, new_cal): """Describe the differences between two calendars. :param old_cal: Old calendar (can be None) :param new_cal: New calendar (can be None) :yield: Lines describing changes """ # TODO(jelmer): Extend for old_component, new_component in calendar_component_delta(old_cal, new_cal): if not new_component: yield "Deleted %s" % describe_component(old_component) continue description = describe_component(new_component) if not old_component: yield "Added %s" % describe_component(new_component) continue for field, old_value, new_value in calendar_prop_delta(old_component, new_component): if field.upper() in DELTA_IGNORE_FIELDS: continue if ( old_component.name.upper() == "VTODO" and field.upper() == "STATUS" ): yield "%s marked as %s" % (description, new_value) elif field.upper() == 'DESCRIPTION': yield "changed description of %s" % description elif field.upper() == 'SUMMARY': yield "changed summary of %s" % description elif field.upper() == 'LOCATION': yield "changed location of %s to %s" % (description, new_value) elif (old_component.name.upper() == "VTODO" and field.upper() == "PERCENT-COMPLETE"): yield "%s marked as %d%% completed." % ( description, new_value) elif field.upper() == 'DUE': yield "changed due date for %s from %s to %s" % ( description, old_value.dt if old_value else 'none', new_value.dt if new_value else 'none') else: yield "modified field %s in %s" % (field, description) logging.debug("Changed %s/%s or %s/%s from %s to %s.", old_component.name, field, new_component.name, field, old_value, new_value) class ICalendarFile(File): """Handle for ICalendar files.""" content_type = 'text/calendar' def __init__(self, content, content_type): super(ICalendarFile, self).__init__(content, content_type) self._calendar = None def validate(self): """Verify that file contents are valid.""" self.calendar @property def calendar(self): if self._calendar is None: try: self._calendar = Calendar.from_ical(b''.join(self.content)) except ValueError: raise InvalidFileContents(self.content_type, self.content) return self._calendar def describe_delta(self, name, previous): try: lines = list(describe_calendar_delta( previous.calendar if previous else None, self.calendar)) except NotImplementedError: lines = [] if not lines: lines = super(ICalendarFile, self).describe_delta(name, previous) return lines def describe(self, name): try: subcomponents = self.calendar.subcomponents except InvalidFileContents: pass else: for component in subcomponents: try: return describe_component(component) except KeyError: pass return super(ICalendarFile, self).describe(name) def get_uid(self): """Extract the UID from a VCalendar file. :param cal: Calendar, possibly serialized. :return: UID """ for component in self.calendar.subcomponents: try: return component["UID"] except KeyError: pass raise KeyError