Просмотр исходного кода

Add a (not tested) CardDAV support

Guillaume Ayoub 14 лет назад
Родитель
Сommit
8a4be02075
3 измененных файлов с 259 добавлено и 193 удалено
  1. 84 74
      radicale/__init__.py
  2. 113 69
      radicale/ical.py
  3. 62 50
      radicale/xmlutils.py

+ 84 - 74
radicale/__init__.py

@@ -107,7 +107,7 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
 
 
 
 
 class Application(object):
 class Application(object):
-    """WSGI application managing calendars."""
+    """WSGI application managing collections."""
     def __init__(self):
     def __init__(self):
         """Initialize application."""
         """Initialize application."""
         super(Application, self).__init__()
         super(Application, self).__init__()
@@ -180,8 +180,8 @@ class Application(object):
         else:
         else:
             content = None
             content = None
 
 
-        # Find calendar(s)
-        items = ical.Calendar.from_path(
+        # Find collection(s)
+        items = ical.Collection.from_path(
             environ["PATH_INFO"], environ.get("HTTP_DEPTH", "0"))
             environ["PATH_INFO"], environ.get("HTTP_DEPTH", "0"))
 
 
         # Get function corresponding to method
         # Get function corresponding to method
@@ -189,7 +189,7 @@ class Application(object):
 
 
         # Check rights
         # Check rights
         if not items or not self.acl:
         if not items or not self.acl:
-            # No calendar or no acl, don't check rights
+            # No collection or no acl, don't check rights
             status, headers, answer = function(environ, items, content, None)
             status, headers, answer = function(environ, items, content, None)
         else:
         else:
             # Ask authentication backend to check rights
             # Ask authentication backend to check rights
@@ -203,37 +203,37 @@ class Application(object):
                 user = password = None
                 user = password = None
 
 
             last_allowed = None
             last_allowed = None
-            calendars = []
-            for calendar in items:
-                if not isinstance(calendar, ical.Calendar):
+            collections = []
+            for collection in items:
+                if not isinstance(collection, ical.Collection):
                     if last_allowed:
                     if last_allowed:
-                        calendars.append(calendar)
+                        collections.append(collection)
                     continue
                     continue
 
 
-                if calendar.owner in acl.PUBLIC_USERS:
-                    log.LOGGER.info("Public calendar")
-                    calendars.append(calendar)
+                if collection.owner in acl.PUBLIC_USERS:
+                    log.LOGGER.info("Public collection")
+                    collections.append(collection)
                     last_allowed = True
                     last_allowed = True
                 else:
                 else:
                     log.LOGGER.info(
                     log.LOGGER.info(
-                        "Checking rights for calendar owned by %s" % (
-                            calendar.owner or "nobody"))
-                    if self.acl.has_right(calendar.owner, user, password):
+                        "Checking rights for collection owned by %s" % (
+                            collection.owner or "nobody"))
+                    if self.acl.has_right(collection.owner, user, password):
                         log.LOGGER.info(
                         log.LOGGER.info(
                             "%s allowed" % (user or "Anonymous user"))
                             "%s allowed" % (user or "Anonymous user"))
-                        calendars.append(calendar)
+                        collections.append(collection)
                         last_allowed = True
                         last_allowed = True
                     else:
                     else:
                         log.LOGGER.info(
                         log.LOGGER.info(
                             "%s refused" % (user or "Anonymous user"))
                             "%s refused" % (user or "Anonymous user"))
                         last_allowed = False
                         last_allowed = False
 
 
-            if calendars:
-                # Calendars found
+            if collections:
+                # Collections found
                 status, headers, answer = function(
                 status, headers, answer = function(
-                    environ, calendars, content, user)
+                    environ, collections, content, user)
             elif user and last_allowed is None:
             elif user and last_allowed is None:
-                # Good user and no calendars found, redirect user to home
+                # Good user and no collections found, redirect user to home
                 location = "/%s/" % str(quote(user))
                 location = "/%s/" % str(quote(user))
                 log.LOGGER.info("redirecting to %s" % location)
                 log.LOGGER.info("redirecting to %s" % location)
                 status = client.FOUND
                 status = client.FOUND
@@ -264,21 +264,21 @@ class Application(object):
     # All these functions must have the same parameters, some are useless
     # All these functions must have the same parameters, some are useless
     # pylint: disable=W0612,W0613,R0201
     # pylint: disable=W0612,W0613,R0201
 
 
-    def delete(self, environ, calendars, content, user):
+    def delete(self, environ, collections, content, user):
         """Manage DELETE request."""
         """Manage DELETE request."""
-        calendar = calendars[0]
+        collection = collections[0]
 
 
-        if calendar.local_path == environ["PATH_INFO"].strip("/"):
-            # Path matching the calendar, the item to delete is the calendar
-            item = calendar
+        if collection.local_path == environ["PATH_INFO"].strip("/"):
+            # Path matching the collection, the collection must be deleted
+            item = collection
         else:
         else:
             # Try to get an item matching the path
             # Try to get an item matching the path
-            item = calendar.get_item(
-                xmlutils.name_from_path(environ["PATH_INFO"], calendar))
+            item = collection.get_item(
+                xmlutils.name_from_path(environ["PATH_INFO"], collection))
 
 
         if item and environ.get("HTTP_IF_MATCH", item.etag) == item.etag:
         if item and environ.get("HTTP_IF_MATCH", item.etag) == item.etag:
             # No ETag precondition or precondition verified, delete item
             # No ETag precondition or precondition verified, delete item
-            answer = xmlutils.delete(environ["PATH_INFO"], calendar)
+            answer = xmlutils.delete(environ["PATH_INFO"], collection)
             status = client.NO_CONTENT
             status = client.NO_CONTENT
         else:
         else:
             # No item or ETag precondition not verified, do not delete item
             # No item or ETag precondition not verified, do not delete item
@@ -286,7 +286,7 @@ class Application(object):
             status = client.PRECONDITION_FAILED
             status = client.PRECONDITION_FAILED
         return status, {}, answer
         return status, {}, answer
 
 
-    def get(self, environ, calendars, content, user):
+    def get(self, environ, collections, content, user):
         """Manage GET request."""
         """Manage GET request."""
         # Display a "Radicale works!" message if the root URL is requested
         # Display a "Radicale works!" message if the root URL is requested
         if environ["PATH_INFO"] == "/":
         if environ["PATH_INFO"] == "/":
@@ -294,67 +294,77 @@ class Application(object):
             answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
             answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
             return client.OK, headers, answer
             return client.OK, headers, answer
 
 
-        calendar = calendars[0]
-        item_name = xmlutils.name_from_path(environ["PATH_INFO"], calendar)
+        collection = collections[0]
+        item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
         if item_name:
         if item_name:
-            # Get calendar item
-            item = calendar.get_item(item_name)
+            # Get collection item
+            item = collection.get_item(item_name)
             if item:
             if item:
-                items = calendar.timezones
+                items = collection.timezones
                 items.append(item)
                 items.append(item)
                 answer_text = ical.serialize(
                 answer_text = ical.serialize(
-                    headers=calendar.headers, items=items)
+                    collection.tag, collection.headers, items)
                 etag = item.etag
                 etag = item.etag
             else:
             else:
                 return client.GONE, {}, None
                 return client.GONE, {}, None
         else:
         else:
-            # Get whole calendar
-            answer_text = calendar.text
-            etag = calendar.etag
+            # Get whole collection
+            answer_text = collection.text
+            etag = collection.etag
 
 
         headers = {
         headers = {
-            "Content-Type": "text/calendar",
-            "Last-Modified": calendar.last_modified,
+            "Content-Type": collection.mimetype,
+            "Last-Modified": collection.last_modified,
             "ETag": etag}
             "ETag": etag}
         answer = answer_text.encode(self.encoding)
         answer = answer_text.encode(self.encoding)
         return client.OK, headers, answer
         return client.OK, headers, answer
 
 
-    def head(self, environ, calendars, content, user):
+    def head(self, environ, collections, content, user):
         """Manage HEAD request."""
         """Manage HEAD request."""
-        status, headers, answer = self.get(environ, calendars, content, user)
+        status, headers, answer = self.get(environ, collections, content, user)
         return status, headers, None
         return status, headers, None
 
 
-    def mkcalendar(self, environ, calendars, content, user):
+    def mkcalendar(self, environ, collections, content, user):
         """Manage MKCALENDAR request."""
         """Manage MKCALENDAR request."""
-        calendar = calendars[0]
+        collection = collections[0]
         props = xmlutils.props_from_request(content)
         props = xmlutils.props_from_request(content)
         timezone = props.get('C:calendar-timezone')
         timezone = props.get('C:calendar-timezone')
         if timezone:
         if timezone:
-            calendar.replace('', timezone)
+            collection.replace('', timezone)
             del props['C:calendar-timezone']
             del props['C:calendar-timezone']
-        with calendar.props as calendar_props:
+        with collection.props as collection_props:
             for key, value in props.items():
             for key, value in props.items():
-                calendar_props[key] = value
-        calendar.write()
+                collection_props[key] = value
+        collection.write()
         return client.CREATED, {}, None
         return client.CREATED, {}, None
 
 
-    def move(self, environ, calendars, content, user):
+    def mkcol(self, environ, collections, content, user):
+        """Manage MKCOL request."""
+        collection = collections[0]
+        props = xmlutils.props_from_request(content)
+        with collection.props as collection_props:
+            for key, value in props.items():
+                collection_props[key] = value
+        collection.write()
+        return client.CREATED, {}, None
+
+    def move(self, environ, collections, content, user):
         """Manage MOVE request."""
         """Manage MOVE request."""
-        from_calendar = calendars[0]
+        from_collection = collections[0]
         from_name = xmlutils.name_from_path(
         from_name = xmlutils.name_from_path(
-            environ["PATH_INFO"], from_calendar)
+            environ["PATH_INFO"], from_collection)
         if from_name:
         if from_name:
-            item = from_calendar.get_item(from_name)
+            item = from_collection.get_item(from_name)
             if item:
             if item:
                 # Move the item
                 # Move the item
                 to_url_parts = urlparse(environ["HTTP_DESTINATION"])
                 to_url_parts = urlparse(environ["HTTP_DESTINATION"])
                 if to_url_parts.netloc == environ["HTTP_HOST"]:
                 if to_url_parts.netloc == environ["HTTP_HOST"]:
                     to_url = to_url_parts.path
                     to_url = to_url_parts.path
                     to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
                     to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
-                    to_calendar = ical.Calendar.from_path(
+                    to_collection = ical.Collection.from_path(
                         to_path, depth="0")[0]
                         to_path, depth="0")[0]
-                    to_calendar.append(to_name, item.text)
-                    from_calendar.remove(from_name)
+                    to_collection.append(to_name, item.text)
+                    from_collection.remove(from_name)
                     return client.CREATED, {}, None
                     return client.CREATED, {}, None
                 else:
                 else:
                     # Remote destination server, not supported
                     # Remote destination server, not supported
@@ -363,60 +373,60 @@ class Application(object):
                 # No item found
                 # No item found
                 return client.GONE, {}, None
                 return client.GONE, {}, None
         else:
         else:
-            # Moving calendars, not supported
+            # Moving collections, not supported
             return client.FORBIDDEN, {}, None
             return client.FORBIDDEN, {}, None
 
 
-    def options(self, environ, calendars, content, user):
+    def options(self, environ, collections, content, user):
         """Manage OPTIONS request."""
         """Manage OPTIONS request."""
         headers = {
         headers = {
-            "Allow": "DELETE, HEAD, GET, MKCALENDAR, MOVE, " \
+            "Allow": "DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, " \
                 "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT",
                 "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT",
-            "DAV": "1, calendar-access"}
+            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
         return client.OK, headers, None
         return client.OK, headers, None
 
 
-    def propfind(self, environ, calendars, content, user):
+    def propfind(self, environ, collections, content, user):
         """Manage PROPFIND request."""
         """Manage PROPFIND request."""
         headers = {
         headers = {
-            "DAV": "1, calendar-access",
+            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
             "Content-Type": "text/xml"}
             "Content-Type": "text/xml"}
         answer = xmlutils.propfind(
         answer = xmlutils.propfind(
-            environ["PATH_INFO"], content, calendars, user)
+            environ["PATH_INFO"], content, collections, user)
         return client.MULTI_STATUS, headers, answer
         return client.MULTI_STATUS, headers, answer
 
 
-    def proppatch(self, environ, calendars, content, user):
+    def proppatch(self, environ, collections, content, user):
         """Manage PROPPATCH request."""
         """Manage PROPPATCH request."""
-        calendar = calendars[0]
-        answer = xmlutils.proppatch(environ["PATH_INFO"], content, calendar)
+        collection = collections[0]
+        answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
         headers = {
         headers = {
-            "DAV": "1, calendar-access",
+            "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
             "Content-Type": "text/xml"}
             "Content-Type": "text/xml"}
         return client.MULTI_STATUS, headers, answer
         return client.MULTI_STATUS, headers, answer
 
 
-    def put(self, environ, calendars, content, user):
+    def put(self, environ, collections, content, user):
         """Manage PUT request."""
         """Manage PUT request."""
-        calendar = calendars[0]
+        collection = collections[0]
         headers = {}
         headers = {}
-        item_name = xmlutils.name_from_path(environ["PATH_INFO"], calendar)
-        item = calendar.get_item(item_name)
+        item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
+        item = collection.get_item(item_name)
         if (not item and not environ.get("HTTP_IF_MATCH")) or (
         if (not item and not environ.get("HTTP_IF_MATCH")) or (
             item and environ.get("HTTP_IF_MATCH", item.etag) == item.etag):
             item and environ.get("HTTP_IF_MATCH", item.etag) == item.etag):
             # PUT allowed in 3 cases
             # PUT allowed in 3 cases
             # Case 1: No item and no ETag precondition: Add new item
             # Case 1: No item and no ETag precondition: Add new item
             # Case 2: Item and ETag precondition verified: Modify item
             # Case 2: Item and ETag precondition verified: Modify item
             # Case 3: Item and no Etag precondition: Force modifying item
             # Case 3: Item and no Etag precondition: Force modifying item
-            xmlutils.put(environ["PATH_INFO"], content, calendar)
+            xmlutils.put(environ["PATH_INFO"], content, collection)
             status = client.CREATED
             status = client.CREATED
-            headers["ETag"] = calendar.get_item(item_name).etag
+            headers["ETag"] = collection.get_item(item_name).etag
         else:
         else:
             # PUT rejected in all other cases
             # PUT rejected in all other cases
             status = client.PRECONDITION_FAILED
             status = client.PRECONDITION_FAILED
         return status, headers, None
         return status, headers, None
 
 
-    def report(self, environ, calendars, content, user):
+    def report(self, environ, collections, content, user):
         """Manage REPORT request."""
         """Manage REPORT request."""
-        calendar = calendars[0]
+        collection = collections[0]
         headers = {'Content-Type': 'text/xml'}
         headers = {'Content-Type': 'text/xml'}
-        answer = xmlutils.report(environ["PATH_INFO"], content, calendar)
+        answer = xmlutils.report(environ["PATH_INFO"], content, collection)
         return client.MULTI_STATUS, headers, answer
         return client.MULTI_STATUS, headers, answer
 
 
     # pylint: enable=W0612,W0613,R0201
     # pylint: enable=W0612,W0613,R0201

+ 113 - 69
radicale/ical.py

@@ -47,13 +47,17 @@ def open(path, mode="r"):
 # pylint: enable=W0622
 # pylint: enable=W0622
 
 
 
 
-def serialize(headers=(), items=()):
-    """Return an iCal text corresponding to given ``headers`` and ``items``."""
-    lines = ["BEGIN:VCALENDAR"]
+def serialize(tag, headers=(), items=()):
+    """Return a collection text corresponding to given ``tag``.
+
+    The collection has the given ``headers`` and ``items``.
+
+    """
+    lines = ["BEGIN:%s" % tag]
     for part in (headers, items):
     for part in (headers, items):
         if part:
         if part:
             lines.append("\n".join(item.text for item in part))
             lines.append("\n".join(item.text for item in part))
-    lines.append("END:VCALENDAR\n")
+    lines.append("END:%s\n" % tag)
     return "\n".join(lines)
     return "\n".join(lines)
 
 
 
 
@@ -135,37 +139,45 @@ class Header(Item):
     """Internal header class."""
     """Internal header class."""
 
 
 
 
-class Event(Item):
+class Timezone(Item):
+    """Internal timezone class."""
+    tag = "VTIMEZONE"
+
+
+class Component(Item):
+    """Internal main component of a collection."""
+
+
+class Event(Component):
     """Internal event class."""
     """Internal event class."""
     tag = "VEVENT"
     tag = "VEVENT"
+    mimetype = "text/calendar"
 
 
 
 
-class Todo(Item):
+class Todo(Component):
     """Internal todo class."""
     """Internal todo class."""
-    # This is not a TODO!
-    # pylint: disable=W0511
-    tag = "VTODO"
-    # pylint: enable=W0511
+    tag = "VTODO"  # pylint: disable=W0511
+    mimetype = "text/calendar"
 
 
 
 
-class Journal(Item):
+class Journal(Component):
     """Internal journal class."""
     """Internal journal class."""
     tag = "VJOURNAL"
     tag = "VJOURNAL"
+    mimetype = "text/calendar"
 
 
 
 
-class Timezone(Item):
-    """Internal timezone class."""
-    tag = "VTIMEZONE"
-
+class Card(Component):
+    """Internal card class."""
+    tag = "VCARD"
+    mimetype = "text/vcard"
 
 
-class Calendar(object):
-    """Internal calendar class."""
-    tag = "VCALENDAR"
 
 
+class Collection(object):
+    """Internal collection item."""
     def __init__(self, path, principal=False):
     def __init__(self, path, principal=False):
-        """Initialize the calendar.
+        """Initialize the collection.
 
 
-        ``path`` must be the normalized relative path of the calendar, using
+        ``path`` must be the normalized relative path of the collection, using
         the slash as the folder delimiter, with no leading nor trailing slash.
         the slash as the folder delimiter, with no leading nor trailing slash.
 
 
         """
         """
@@ -174,7 +186,7 @@ class Calendar(object):
         self.path = os.path.join(FOLDER, path.replace("/", os.sep))
         self.path = os.path.join(FOLDER, path.replace("/", os.sep))
         self.props_path = self.path + '.props'
         self.props_path = self.path + '.props'
         if principal and split_path and os.path.isdir(self.path):
         if principal and split_path and os.path.isdir(self.path):
-            # Already existing principal calendar
+            # Already existing principal collection
             self.owner = split_path[0]
             self.owner = split_path[0]
         elif len(split_path) > 1:
         elif len(split_path) > 1:
             # URL with at least one folder
             # URL with at least one folder
@@ -186,7 +198,7 @@ class Calendar(object):
 
 
     @classmethod
     @classmethod
     def from_path(cls, path, depth="infinite", include_container=True):
     def from_path(cls, path, depth="infinite", include_container=True):
-        """Return a list of calendars and items under the given ``path``.
+        """Return a list of collections and items under the given ``path``.
 
 
         If ``depth`` is "0", only the actual object under ``path`` is
         If ``depth`` is "0", only the actual object under ``path`` is
         returned. Otherwise, also sub-items are appended to the result. If
         returned. Otherwise, also sub-items are appended to the result. If
@@ -218,7 +230,7 @@ class Calendar(object):
                     result.append(cls(path, principal))
                     result.append(cls(path, principal))
                 try:
                 try:
                     for filename in next(os.walk(abs_path))[2]:
                     for filename in next(os.walk(abs_path))[2]:
-                        if cls.is_vcalendar(os.path.join(abs_path, filename)):
+                        if cls.is_collection(os.path.join(abs_path, filename)):
                             result.append(cls(os.path.join(path, filename)))
                             result.append(cls(os.path.join(path, filename)))
                 except StopIteration:
                 except StopIteration:
                     # Directory does not exist yet
                     # Directory does not exist yet
@@ -227,17 +239,52 @@ class Calendar(object):
             if depth == "0":
             if depth == "0":
                 result.append(cls(path))
                 result.append(cls(path))
             else:
             else:
-                calendar = cls(path, principal)
+                collection = cls(path, principal)
                 if include_container:
                 if include_container:
-                    result.append(calendar)
-                result.extend(calendar.components)
+                    result.append(collection)
+                result.extend(collection.components)
         return result
         return result
 
 
-    @staticmethod
-    def is_vcalendar(path):
-        """Return ``True`` if there is a VCALENDAR file under ``path``."""
+    def is_collection(self, path):
+        """Return ``True`` if there is a collection file under ``path``."""
+        beginning_string = 'BEGIN:%s' % self.tag
         with open(path) as stream:
         with open(path) as stream:
-            return 'BEGIN:VCALENDAR' == stream.read(15)
+            beginning_string = stream.read(len(beginning_string))
+
+    @property
+    def items(self):
+        """Get list of all items in collection."""
+        return self._parse(self.text, (Card, Event, Todo, Journal, Timezone))
+
+    @property
+    def components(self):
+        """Get list of all components in collection."""
+        return self._parse(self.text, (Card, Event, Todo, Journal))
+
+    @property
+    def events(self):
+        """Get list of ``Event`` items in collection."""
+        return self._parse(self.text, (Event,))
+
+    @property
+    def cards(self):
+        """Get list of all cards in collection."""
+        return self._parse(self.text, (Card,))
+
+    @property
+    def todos(self):
+        """Get list of ``Todo`` items in collection."""
+        return self._parse(self.text, (Todo,))
+
+    @property
+    def journals(self):
+        """Get list of ``Journal`` items in collection."""
+        return self._parse(self.text, (Journal,))
+
+    @property
+    def timezones(self):
+        """Get list of ``Timezome`` items in collection."""
+        return self._parse(self.text, (Timezone,))
 
 
     @staticmethod
     @staticmethod
     def _parse(text, item_types, name=None):
     def _parse(text, item_types, name=None):
@@ -329,7 +376,7 @@ class Calendar(object):
 
 
         self._create_dirs(self.path)
         self._create_dirs(self.path)
 
 
-        text = serialize(headers, items)
+        text = serialize(self.tag, headers, items)
         return open(self.path, "w").write(text)
         return open(self.path, "w").write(text)
 
 
     @staticmethod
     @staticmethod
@@ -338,21 +385,48 @@ class Calendar(object):
         if not os.path.exists(os.path.dirname(path)):
         if not os.path.exists(os.path.dirname(path)):
             os.makedirs(os.path.dirname(path))
             os.makedirs(os.path.dirname(path))
 
 
+    @property
+    def tag(self):
+        """Type of the collection."""
+        with self.props as props:
+            if "tag" not in props:
+                try:
+                    props["tag"] = open(self.path).readlines()[0][6:].rstrip()
+                except IOError:
+                    props["tag"] = "VCALENDAR"
+            return props["tag"]
+
+    @property
+    def mimetype(self):
+        """Mimetype of the collection."""
+        if self.tag == "VADDRESSBOOK":
+            return "text/vcard"
+        elif self.tag == "VCALENDAR":
+            return "text/calendar"
+
+    @property
+    def resource_type(self):
+        """Resource type of the collection."""
+        if self.tag == "VADDRESSBOOK":
+            return "addressbook"
+        elif self.tag == "VCALENDAR":
+            return "calendar"
+
     @property
     @property
     def etag(self):
     def etag(self):
-        """Etag from calendar."""
+        """Etag from collection."""
         return '"%s"' % hash(self.text)
         return '"%s"' % hash(self.text)
 
 
     @property
     @property
     def name(self):
     def name(self):
-        """Calendar name."""
+        """Collection name."""
         with self.props as props:
         with self.props as props:
             return props.get('D:displayname',
             return props.get('D:displayname',
                 self.path.split(os.path.sep)[-1])
                 self.path.split(os.path.sep)[-1])
 
 
     @property
     @property
     def text(self):
     def text(self):
-        """Calendar as plain text."""
+        """Collection as plain text."""
         try:
         try:
             return open(self.path).read()
             return open(self.path).read()
         except IOError:
         except IOError:
@@ -360,7 +434,7 @@ class Calendar(object):
 
 
     @property
     @property
     def headers(self):
     def headers(self):
-        """Find headers items in calendar."""
+        """Find headers items in collection."""
         header_lines = []
         header_lines = []
 
 
         lines = unfold(self.text)
         lines = unfold(self.text)
@@ -373,39 +447,9 @@ class Calendar(object):
 
 
         return header_lines
         return header_lines
 
 
-    @property
-    def items(self):
-        """Get list of all items in calendar."""
-        return self._parse(self.text, (Event, Todo, Journal, Timezone))
-
-    @property
-    def components(self):
-        """Get list of all components in calendar."""
-        return self._parse(self.text, (Event, Todo, Journal))
-
-    @property
-    def events(self):
-        """Get list of ``Event`` items in calendar."""
-        return self._parse(self.text, (Event,))
-
-    @property
-    def todos(self):
-        """Get list of ``Todo`` items in calendar."""
-        return self._parse(self.text, (Todo,))
-
-    @property
-    def journals(self):
-        """Get list of ``Journal`` items in calendar."""
-        return self._parse(self.text, (Journal,))
-
-    @property
-    def timezones(self):
-        """Get list of ``Timezome`` items in calendar."""
-        return self._parse(self.text, (Timezone,))
-
     @property
     @property
     def last_modified(self):
     def last_modified(self):
-        """Get the last time the calendar has been modified.
+        """Get the last time the collection has been modified.
 
 
         The date is formatted according to rfc1123-5.2.14.
         The date is formatted according to rfc1123-5.2.14.
 
 
@@ -420,7 +464,7 @@ class Calendar(object):
     @property
     @property
     @contextmanager
     @contextmanager
     def props(self):
     def props(self):
-        """Get the calendar properties."""
+        """Get the collection properties."""
         # On enter
         # On enter
         properties = {}
         properties = {}
         if os.path.exists(self.props_path):
         if os.path.exists(self.props_path):
@@ -434,7 +478,7 @@ class Calendar(object):
 
 
     @property
     @property
     def owner_url(self):
     def owner_url(self):
-        """Get the calendar URL according to its owner."""
+        """Get the collection URL according to its owner."""
         if self.owner:
         if self.owner:
             return "/%s/" % self.owner
             return "/%s/" % self.owner
         else:
         else:
@@ -442,5 +486,5 @@ class Calendar(object):
 
 
     @property
     @property
     def url(self):
     def url(self):
-        """Get the standard calendar URL."""
+        """Get the standard collection URL."""
         return "/%s/" % self.local_path
         return "/%s/" % self.local_path

+ 62 - 50
radicale/xmlutils.py

@@ -31,7 +31,7 @@ try:
     from collections import OrderedDict
     from collections import OrderedDict
 except ImportError:
 except ImportError:
     # Python 2.6 has no OrderedDict, use a dict instead
     # Python 2.6 has no OrderedDict, use a dict instead
-    OrderedDict = dict # pylint: disable=C0103
+    OrderedDict = dict  # pylint: disable=C0103
 import re
 import re
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
 
 
@@ -40,6 +40,7 @@ from radicale import client, config, ical
 
 
 NAMESPACES = {
 NAMESPACES = {
     "C": "urn:ietf:params:xml:ns:caldav",
     "C": "urn:ietf:params:xml:ns:caldav",
+    "CR": "urn:ietf:params:xml:ns:carddav",
     "D": "DAV:",
     "D": "DAV:",
     "CS": "http://calendarserver.org/ns/",
     "CS": "http://calendarserver.org/ns/",
     "ICAL": "http://apple.com/ns/ical/",
     "ICAL": "http://apple.com/ns/ical/",
@@ -56,7 +57,7 @@ for short, url in NAMESPACES.items():
         ET.register_namespace("" if short == "D" else short, url)
         ET.register_namespace("" if short == "D" else short, url)
     else:
     else:
         # ... and badly with Python 2.6 and 3.1
         # ... and badly with Python 2.6 and 3.1
-        ET._namespace_map[url] = short # pylint: disable=W0212
+        ET._namespace_map[url] = short  # pylint: disable=W0212
 
 
 
 
 CLARK_TAG_REGEX = re.compile(r"""
 CLARK_TAG_REGEX = re.compile(r"""
@@ -118,11 +119,12 @@ def _response(code):
     return "HTTP/1.1 %i %s" % (code, client.responses[code])
     return "HTTP/1.1 %i %s" % (code, client.responses[code])
 
 
 
 
-def name_from_path(path, calendar):
+def name_from_path(path, collection):
     """Return Radicale item name from ``path``."""
     """Return Radicale item name from ``path``."""
-    calendar_parts = calendar.local_path.strip("/").split("/")
+    collection_parts = collection.local_path.strip("/").split("/")
     path_parts = path.strip("/").split("/")
     path_parts = path.strip("/").split("/")
-    return path_parts[-1] if (len(path_parts) - len(calendar_parts)) else None
+    if (len(path_parts) - len(collection_parts)):
+        return path_parts[-1]
 
 
 
 
 def props_from_request(root, actions=("set", "remove")):
 def props_from_request(root, actions=("set", "remove")):
@@ -142,23 +144,29 @@ def props_from_request(root, actions=("set", "remove")):
     if prop_element is not None:
     if prop_element is not None:
         for prop in prop_element:
         for prop in prop_element:
             result[_tag_from_clark(prop.tag)] = prop.text
             result[_tag_from_clark(prop.tag)] = prop.text
+            if prop.tag == "resourcetype":
+                for resource_type in prop:
+                    if resource_type.tag in ("calendar", "addressbook"):
+                        result["resourcetype"] = \
+                            "V%s" % resource_type.tag.upper()
+                        break
 
 
     return result
     return result
 
 
 
 
-def delete(path, calendar):
+def delete(path, collection):
     """Read and answer DELETE requests.
     """Read and answer DELETE requests.
 
 
     Read rfc4918-9.6 for info.
     Read rfc4918-9.6 for info.
 
 
     """
     """
     # Reading request
     # Reading request
-    if calendar.local_path == path.strip("/"):
-        # Delete the whole calendar
-        calendar.delete()
+    if collection.local_path == path.strip("/"):
+        # Delete the whole collection
+        collection.delete()
     else:
     else:
-        # Remove an item from the calendar
-        calendar.remove(name_from_path(path, calendar))
+        # Remove an item from the collection
+        collection.remove(name_from_path(path, collection))
 
 
     # Writing answer
     # Writing answer
     multistatus = ET.Element(_tag("D", "multistatus"))
     multistatus = ET.Element(_tag("D", "multistatus"))
@@ -176,7 +184,7 @@ def delete(path, calendar):
     return _pretty_xml(multistatus)
     return _pretty_xml(multistatus)
 
 
 
 
-def propfind(path, xml_request, calendars, user=None):
+def propfind(path, xml_request, collections, user=None):
     """Read and answer PROPFIND requests.
     """Read and answer PROPFIND requests.
 
 
     Read rfc4918-9.1 for info.
     Read rfc4918-9.1 for info.
@@ -191,8 +199,8 @@ def propfind(path, xml_request, calendars, user=None):
     # Writing answer
     # Writing answer
     multistatus = ET.Element(_tag("D", "multistatus"))
     multistatus = ET.Element(_tag("D", "multistatus"))
 
 
-    for calendar in calendars:
-        response = _propfind_response(path, calendar, props, user)
+    for collection in collections:
+        response = _propfind_response(path, collection, props, user)
         multistatus.append(response)
         multistatus.append(response)
 
 
     return _pretty_xml(multistatus)
     return _pretty_xml(multistatus)
@@ -200,15 +208,15 @@ def propfind(path, xml_request, calendars, user=None):
 
 
 def _propfind_response(path, item, props, user):
 def _propfind_response(path, item, props, user):
     """Build and return a PROPFIND response."""
     """Build and return a PROPFIND response."""
-    is_calendar = isinstance(item, ical.Calendar)
-    if is_calendar:
-        with item.props as cal_props:
-            calendar_props = cal_props
+    is_collection = isinstance(item, ical.Collection)
+    if is_collection:
+        with item.props as properties:
+            collection_props = properties
 
 
     response = ET.Element(_tag("D", "response"))
     response = ET.Element(_tag("D", "response"))
 
 
     href = ET.Element(_tag("D", "href"))
     href = ET.Element(_tag("D", "href"))
-    uri = item.url if is_calendar else "%s/%s" % (path, item.name)
+    uri = item.url if is_collection else "%s/%s" % (path, item.name)
     href.text = uri.replace("//", "/")
     href.text = uri.replace("//", "/")
     response.append(href)
     response.append(href)
 
 
@@ -263,15 +271,15 @@ def _propfind_response(path, item, props, user):
                 report_tag.text = report_name
                 report_tag.text = report_name
                 supported.append(report_tag)
                 supported.append(report_tag)
                 element.append(supported)
                 element.append(supported)
-        elif is_calendar:
+        elif is_collection:
             if tag == _tag("D", "getcontenttype"):
             if tag == _tag("D", "getcontenttype"):
-                element.text = "text/calendar"
+                element.text = item.mimetype
             elif tag == _tag("D", "resourcetype"):
             elif tag == _tag("D", "resourcetype"):
                 if item.is_principal:
                 if item.is_principal:
                     tag = ET.Element(_tag("D", "principal"))
                     tag = ET.Element(_tag("D", "principal"))
                     element.append(tag)
                     element.append(tag)
                 else:
                 else:
-                    tag = ET.Element(_tag("C", "calendar"))
+                    tag = ET.Element(_tag("C", item.resource_type))
                     element.append(tag)
                     element.append(tag)
                 tag = ET.Element(_tag("D", "collection"))
                 tag = ET.Element(_tag("D", "collection"))
                 element.append(tag)
                 element.append(tag)
@@ -280,16 +288,18 @@ def _propfind_response(path, item, props, user):
             elif tag == _tag("CS", "getctag"):
             elif tag == _tag("CS", "getctag"):
                 element.text = item.etag
                 element.text = item.etag
             elif tag == _tag("C", "calendar-timezone"):
             elif tag == _tag("C", "calendar-timezone"):
-                element.text = ical.serialize(item.headers, item.timezones)
+                element.text = ical.serialize(
+                    item.tag, item.headers, item.timezones)
             else:
             else:
                 human_tag = _tag_from_clark(tag)
                 human_tag = _tag_from_clark(tag)
-                if human_tag in calendar_props:
-                    element.text = calendar_props[human_tag]
+                if human_tag in collection_props:
+                    element.text = collection_props[human_tag]
                 else:
                 else:
                     is404 = True
                     is404 = True
-        # Not for calendars
+        # Not for collections
         elif tag == _tag("D", "getcontenttype"):
         elif tag == _tag("D", "getcontenttype"):
-            element.text = "text/calendar; component=%s" % item.tag.lower()
+            element.text = "%s; component=%s" % (
+                item.mimetype, item.tag.lower())
         elif tag == _tag("D", "resourcetype"):
         elif tag == _tag("D", "resourcetype"):
             # resourcetype must be returned empty for non-collection elements
             # resourcetype must be returned empty for non-collection elements
             pass
             pass
@@ -340,7 +350,7 @@ def _add_propstat_to(element, tag, status_number):
     propstat.append(status)
     propstat.append(status)
 
 
 
 
-def proppatch(path, xml_request, calendar):
+def proppatch(path, xml_request, collection):
     """Read and answer PROPPATCH requests.
     """Read and answer PROPPATCH requests.
 
 
     Read rfc4918-9.2 for info.
     Read rfc4918-9.2 for info.
@@ -361,17 +371,17 @@ def proppatch(path, xml_request, calendar):
     href.text = path
     href.text = path
     response.append(href)
     response.append(href)
 
 
-    with calendar.props as calendar_props:
+    with collection.props as collection_props:
         for short_name, value in props_to_set.items():
         for short_name, value in props_to_set.items():
             if short_name == 'C:calendar-timezone':
             if short_name == 'C:calendar-timezone':
-                calendar.replace('', value)
-                calendar.write()
+                collection.replace('', value)
+                collection.write()
             else:
             else:
-                calendar_props[short_name] = value
+                collection_props[short_name] = value
             _add_propstat_to(response, short_name, 200)
             _add_propstat_to(response, short_name, 200)
         for short_name in props_to_remove:
         for short_name in props_to_remove:
             try:
             try:
-                del calendar_props[short_name]
+                del collection_props[short_name]
             except KeyError:
             except KeyError:
                 _add_propstat_to(response, short_name, 412)
                 _add_propstat_to(response, short_name, 412)
             else:
             else:
@@ -380,18 +390,18 @@ def proppatch(path, xml_request, calendar):
     return _pretty_xml(multistatus)
     return _pretty_xml(multistatus)
 
 
 
 
-def put(path, ical_request, calendar):
+def put(path, ical_request, collection):
     """Read PUT requests."""
     """Read PUT requests."""
-    name = name_from_path(path, calendar)
-    if name in (item.name for item in calendar.items):
+    name = name_from_path(path, collection)
+    if name in (item.name for item in collection.items):
         # PUT is modifying an existing item
         # PUT is modifying an existing item
-        calendar.replace(name, ical_request)
+        collection.replace(name, ical_request)
     else:
     else:
         # PUT is adding a new item
         # PUT is adding a new item
-        calendar.append(name, ical_request)
+        collection.append(name, ical_request)
 
 
 
 
-def report(path, xml_request, calendar):
+def report(path, xml_request, collection):
     """Read and answer REPORT requests.
     """Read and answer REPORT requests.
 
 
     Read rfc3253-3.6 for info.
     Read rfc3253-3.6 for info.
@@ -403,7 +413,7 @@ def report(path, xml_request, calendar):
     prop_element = root.find(_tag("D", "prop"))
     prop_element = root.find(_tag("D", "prop"))
     props = [prop.tag for prop in prop_element]
     props = [prop.tag for prop in prop_element]
 
 
-    if calendar:
+    if collection:
         if root.tag == _tag("C", "calendar-multiget"):
         if root.tag == _tag("C", "calendar-multiget"):
             # Read rfc4791-7.9 for info
             # Read rfc4791-7.9 for info
             hreferences = set(
             hreferences = set(
@@ -417,21 +427,22 @@ def report(path, xml_request, calendar):
     # Writing answer
     # Writing answer
     multistatus = ET.Element(_tag("D", "multistatus"))
     multistatus = ET.Element(_tag("D", "multistatus"))
 
 
-    calendar_items = calendar.items
-    calendar_headers = calendar.headers
-    calendar_timezones = calendar.timezones
+    collection_tag = collection.tag
+    collection_items = collection.items
+    collection_headers = collection.headers
+    collection_timezones = collection.timezones
 
 
     for hreference in hreferences:
     for hreference in hreferences:
-        # Check if the reference is an item or a calendar
-        name = name_from_path(hreference, calendar)
+        # Check if the reference is an item or a collection
+        name = name_from_path(hreference, collection)
         if name:
         if name:
             # Reference is an item
             # Reference is an item
             path = "/".join(hreference.split("/")[:-1]) + "/"
             path = "/".join(hreference.split("/")[:-1]) + "/"
-            items = (item for item in calendar_items if item.name == name)
+            items = (item for item in collection_items if item.name == name)
         else:
         else:
-            # Reference is a calendar
+            # Reference is a collection
             path = hreference
             path = hreference
-            items = calendar.components
+            items = collection.components
 
 
         for item in items:
         for item in items:
             response = ET.Element(_tag("D", "response"))
             response = ET.Element(_tag("D", "response"))
@@ -452,9 +463,10 @@ def report(path, xml_request, calendar):
                 if tag == _tag("D", "getetag"):
                 if tag == _tag("D", "getetag"):
                     element.text = item.etag
                     element.text = item.etag
                 elif tag == _tag("C", "calendar-data"):
                 elif tag == _tag("C", "calendar-data"):
-                    if isinstance(item, (ical.Event, ical.Todo, ical.Journal)):
+                    if isinstance(item, ical.Component):
                         element.text = ical.serialize(
                         element.text = ical.serialize(
-                            calendar_headers, calendar_timezones + [item])
+                            collection_tag, collection_headers,
+                            collection_timezones + [item])
                 prop.append(element)
                 prop.append(element)
 
 
             status = ET.Element(_tag("D", "status"))
             status = ET.Element(_tag("D", "status"))