From 8a4be02075201fdc13503185eb57d3bed5c9541f Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sat, 31 Dec 2011 13:31:22 +0100 Subject: [PATCH 1/7] Add a (not tested) CardDAV support --- radicale/__init__.py | 158 ++++++++++++++++++---------------- radicale/ical.py | 196 ++++++++++++++++++++++++++----------------- radicale/xmlutils.py | 112 ++++++++++++++----------- 3 files changed, 266 insertions(+), 200 deletions(-) diff --git a/radicale/__init__.py b/radicale/__init__.py index 4267d61..1ff67f5 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -107,7 +107,7 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): class Application(object): - """WSGI application managing calendars.""" + """WSGI application managing collections.""" def __init__(self): """Initialize application.""" super(Application, self).__init__() @@ -180,8 +180,8 @@ class Application(object): else: 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")) # Get function corresponding to method @@ -189,7 +189,7 @@ class Application(object): # Check rights 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) else: # Ask authentication backend to check rights @@ -203,37 +203,37 @@ class Application(object): user = password = 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: - calendars.append(calendar) + collections.append(collection) 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 else: 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( "%s allowed" % (user or "Anonymous user")) - calendars.append(calendar) + collections.append(collection) last_allowed = True else: log.LOGGER.info( "%s refused" % (user or "Anonymous user")) last_allowed = False - if calendars: - # Calendars found + if collections: + # Collections found status, headers, answer = function( - environ, calendars, content, user) + environ, collections, content, user) 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)) log.LOGGER.info("redirecting to %s" % location) status = client.FOUND @@ -264,21 +264,21 @@ class Application(object): # All these functions must have the same parameters, some are useless # pylint: disable=W0612,W0613,R0201 - def delete(self, environ, calendars, content, user): + def delete(self, environ, collections, content, user): """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: # 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: # 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 else: # No item or ETag precondition not verified, do not delete item @@ -286,7 +286,7 @@ class Application(object): status = client.PRECONDITION_FAILED return status, {}, answer - def get(self, environ, calendars, content, user): + def get(self, environ, collections, content, user): """Manage GET request.""" # Display a "Radicale works!" message if the root URL is requested if environ["PATH_INFO"] == "/": @@ -294,67 +294,77 @@ class Application(object): answer = "\nRadicaleRadicale works!" 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: - # Get calendar item - item = calendar.get_item(item_name) + # Get collection item + item = collection.get_item(item_name) if item: - items = calendar.timezones + items = collection.timezones items.append(item) answer_text = ical.serialize( - headers=calendar.headers, items=items) + collection.tag, collection.headers, items) etag = item.etag else: return client.GONE, {}, None else: - # Get whole calendar - answer_text = calendar.text - etag = calendar.etag + # Get whole collection + answer_text = collection.text + etag = collection.etag headers = { - "Content-Type": "text/calendar", - "Last-Modified": calendar.last_modified, + "Content-Type": collection.mimetype, + "Last-Modified": collection.last_modified, "ETag": etag} answer = answer_text.encode(self.encoding) return client.OK, headers, answer - def head(self, environ, calendars, content, user): + def head(self, environ, collections, content, user): """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 - def mkcalendar(self, environ, calendars, content, user): + def mkcalendar(self, environ, collections, content, user): """Manage MKCALENDAR request.""" - calendar = calendars[0] + collection = collections[0] props = xmlutils.props_from_request(content) timezone = props.get('C:calendar-timezone') if timezone: - calendar.replace('', timezone) + collection.replace('', timezone) del props['C:calendar-timezone'] - with calendar.props as calendar_props: + with collection.props as collection_props: for key, value in props.items(): - calendar_props[key] = value - calendar.write() + collection_props[key] = value + collection.write() 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.""" - from_calendar = calendars[0] + from_collection = collections[0] from_name = xmlutils.name_from_path( - environ["PATH_INFO"], from_calendar) + environ["PATH_INFO"], from_collection) if from_name: - item = from_calendar.get_item(from_name) + item = from_collection.get_item(from_name) if item: # Move the item to_url_parts = urlparse(environ["HTTP_DESTINATION"]) if to_url_parts.netloc == environ["HTTP_HOST"]: to_url = to_url_parts.path 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_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 else: # Remote destination server, not supported @@ -363,60 +373,60 @@ class Application(object): # No item found return client.GONE, {}, None else: - # Moving calendars, not supported + # Moving collections, not supported return client.FORBIDDEN, {}, None - def options(self, environ, calendars, content, user): + def options(self, environ, collections, content, user): """Manage OPTIONS request.""" headers = { - "Allow": "DELETE, HEAD, GET, MKCALENDAR, MOVE, " \ + "Allow": "DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, " \ "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT", - "DAV": "1, calendar-access"} + "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"} return client.OK, headers, None - def propfind(self, environ, calendars, content, user): + def propfind(self, environ, collections, content, user): """Manage PROPFIND request.""" headers = { - "DAV": "1, calendar-access", + "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol", "Content-Type": "text/xml"} answer = xmlutils.propfind( - environ["PATH_INFO"], content, calendars, user) + environ["PATH_INFO"], content, collections, user) return client.MULTI_STATUS, headers, answer - def proppatch(self, environ, calendars, content, user): + def proppatch(self, environ, collections, content, user): """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 = { - "DAV": "1, calendar-access", + "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol", "Content-Type": "text/xml"} return client.MULTI_STATUS, headers, answer - def put(self, environ, calendars, content, user): + def put(self, environ, collections, content, user): """Manage PUT request.""" - calendar = calendars[0] + collection = collections[0] 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 ( item and environ.get("HTTP_IF_MATCH", item.etag) == item.etag): # PUT allowed in 3 cases # Case 1: No item and no ETag precondition: Add new item # Case 2: Item and ETag precondition verified: Modify 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 - headers["ETag"] = calendar.get_item(item_name).etag + headers["ETag"] = collection.get_item(item_name).etag else: # PUT rejected in all other cases status = client.PRECONDITION_FAILED return status, headers, None - def report(self, environ, calendars, content, user): + def report(self, environ, collections, content, user): """Manage REPORT request.""" - calendar = calendars[0] + collection = collections[0] 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 # pylint: enable=W0612,W0613,R0201 diff --git a/radicale/ical.py b/radicale/ical.py index 3c07d3c..4ad689c 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -47,13 +47,17 @@ def open(path, mode="r"): # 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): if 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) @@ -135,37 +139,45 @@ class Header(Item): """Internal header class.""" -class Event(Item): - """Internal event class.""" - tag = "VEVENT" - - -class Todo(Item): - """Internal todo class.""" - # This is not a TODO! - # pylint: disable=W0511 - tag = "VTODO" - # pylint: enable=W0511 - - -class Journal(Item): - """Internal journal class.""" - tag = "VJOURNAL" - - class Timezone(Item): """Internal timezone class.""" tag = "VTIMEZONE" -class Calendar(object): - """Internal calendar class.""" - tag = "VCALENDAR" +class Component(Item): + """Internal main component of a collection.""" + +class Event(Component): + """Internal event class.""" + tag = "VEVENT" + mimetype = "text/calendar" + + +class Todo(Component): + """Internal todo class.""" + tag = "VTODO" # pylint: disable=W0511 + mimetype = "text/calendar" + + +class Journal(Component): + """Internal journal class.""" + tag = "VJOURNAL" + mimetype = "text/calendar" + + +class Card(Component): + """Internal card class.""" + tag = "VCARD" + mimetype = "text/vcard" + + +class Collection(object): + """Internal collection item.""" 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. """ @@ -174,7 +186,7 @@ class Calendar(object): self.path = os.path.join(FOLDER, path.replace("/", os.sep)) self.props_path = self.path + '.props' if principal and split_path and os.path.isdir(self.path): - # Already existing principal calendar + # Already existing principal collection self.owner = split_path[0] elif len(split_path) > 1: # URL with at least one folder @@ -186,7 +198,7 @@ class Calendar(object): @classmethod 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 returned. Otherwise, also sub-items are appended to the result. If @@ -218,7 +230,7 @@ class Calendar(object): result.append(cls(path, principal)) try: 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))) except StopIteration: # Directory does not exist yet @@ -227,17 +239,52 @@ class Calendar(object): if depth == "0": result.append(cls(path)) else: - calendar = cls(path, principal) + collection = cls(path, principal) if include_container: - result.append(calendar) - result.extend(calendar.components) + result.append(collection) + result.extend(collection.components) 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: - 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 def _parse(text, item_types, name=None): @@ -329,7 +376,7 @@ class Calendar(object): self._create_dirs(self.path) - text = serialize(headers, items) + text = serialize(self.tag, headers, items) return open(self.path, "w").write(text) @staticmethod @@ -338,21 +385,48 @@ class Calendar(object): if not os.path.exists(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 def etag(self): - """Etag from calendar.""" + """Etag from collection.""" return '"%s"' % hash(self.text) @property def name(self): - """Calendar name.""" + """Collection name.""" with self.props as props: return props.get('D:displayname', self.path.split(os.path.sep)[-1]) @property def text(self): - """Calendar as plain text.""" + """Collection as plain text.""" try: return open(self.path).read() except IOError: @@ -360,7 +434,7 @@ class Calendar(object): @property def headers(self): - """Find headers items in calendar.""" + """Find headers items in collection.""" header_lines = [] lines = unfold(self.text) @@ -373,39 +447,9 @@ class Calendar(object): 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 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. @@ -420,7 +464,7 @@ class Calendar(object): @property @contextmanager def props(self): - """Get the calendar properties.""" + """Get the collection properties.""" # On enter properties = {} if os.path.exists(self.props_path): @@ -434,7 +478,7 @@ class Calendar(object): @property def owner_url(self): - """Get the calendar URL according to its owner.""" + """Get the collection URL according to its owner.""" if self.owner: return "/%s/" % self.owner else: @@ -442,5 +486,5 @@ class Calendar(object): @property def url(self): - """Get the standard calendar URL.""" + """Get the standard collection URL.""" return "/%s/" % self.local_path diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 514e822..7139e12 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -31,7 +31,7 @@ try: from collections import OrderedDict except ImportError: # Python 2.6 has no OrderedDict, use a dict instead - OrderedDict = dict # pylint: disable=C0103 + OrderedDict = dict # pylint: disable=C0103 import re import xml.etree.ElementTree as ET @@ -40,6 +40,7 @@ from radicale import client, config, ical NAMESPACES = { "C": "urn:ietf:params:xml:ns:caldav", + "CR": "urn:ietf:params:xml:ns:carddav", "D": "DAV:", "CS": "http://calendarserver.org/ns/", "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) else: # ... 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""" @@ -118,11 +119,12 @@ def _response(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``.""" - calendar_parts = calendar.local_path.strip("/").split("/") + collection_parts = collection.local_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")): @@ -142,23 +144,29 @@ def props_from_request(root, actions=("set", "remove")): if prop_element is not None: for prop in prop_element: 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 -def delete(path, calendar): +def delete(path, collection): """Read and answer DELETE requests. Read rfc4918-9.6 for info. """ # 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: - # 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 multistatus = ET.Element(_tag("D", "multistatus")) @@ -176,7 +184,7 @@ def delete(path, calendar): 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 rfc4918-9.1 for info. @@ -191,8 +199,8 @@ def propfind(path, xml_request, calendars, user=None): # Writing answer 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) return _pretty_xml(multistatus) @@ -200,15 +208,15 @@ def propfind(path, xml_request, calendars, user=None): def _propfind_response(path, item, props, user): """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")) 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("//", "/") response.append(href) @@ -263,15 +271,15 @@ def _propfind_response(path, item, props, user): report_tag.text = report_name supported.append(report_tag) element.append(supported) - elif is_calendar: + elif is_collection: if tag == _tag("D", "getcontenttype"): - element.text = "text/calendar" + element.text = item.mimetype elif tag == _tag("D", "resourcetype"): if item.is_principal: tag = ET.Element(_tag("D", "principal")) element.append(tag) else: - tag = ET.Element(_tag("C", "calendar")) + tag = ET.Element(_tag("C", item.resource_type)) element.append(tag) tag = ET.Element(_tag("D", "collection")) element.append(tag) @@ -280,16 +288,18 @@ def _propfind_response(path, item, props, user): elif tag == _tag("CS", "getctag"): element.text = item.etag 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: 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: is404 = True - # Not for calendars + # Not for collections 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"): # resourcetype must be returned empty for non-collection elements pass @@ -340,7 +350,7 @@ def _add_propstat_to(element, tag, status_number): propstat.append(status) -def proppatch(path, xml_request, calendar): +def proppatch(path, xml_request, collection): """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. @@ -361,17 +371,17 @@ def proppatch(path, xml_request, calendar): href.text = path response.append(href) - with calendar.props as calendar_props: + with collection.props as collection_props: for short_name, value in props_to_set.items(): if short_name == 'C:calendar-timezone': - calendar.replace('', value) - calendar.write() + collection.replace('', value) + collection.write() else: - calendar_props[short_name] = value + collection_props[short_name] = value _add_propstat_to(response, short_name, 200) for short_name in props_to_remove: try: - del calendar_props[short_name] + del collection_props[short_name] except KeyError: _add_propstat_to(response, short_name, 412) else: @@ -380,18 +390,18 @@ def proppatch(path, xml_request, calendar): return _pretty_xml(multistatus) -def put(path, ical_request, calendar): +def put(path, ical_request, collection): """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 - calendar.replace(name, ical_request) + collection.replace(name, ical_request) else: # 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 rfc3253-3.6 for info. @@ -403,7 +413,7 @@ def report(path, xml_request, calendar): prop_element = root.find(_tag("D", "prop")) props = [prop.tag for prop in prop_element] - if calendar: + if collection: if root.tag == _tag("C", "calendar-multiget"): # Read rfc4791-7.9 for info hreferences = set( @@ -417,21 +427,22 @@ def report(path, xml_request, calendar): # Writing answer 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: - # 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: # Reference is an item 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: - # Reference is a calendar + # Reference is a collection path = hreference - items = calendar.components + items = collection.components for item in items: response = ET.Element(_tag("D", "response")) @@ -452,9 +463,10 @@ def report(path, xml_request, calendar): if tag == _tag("D", "getetag"): element.text = item.etag elif tag == _tag("C", "calendar-data"): - if isinstance(item, (ical.Event, ical.Todo, ical.Journal)): + if isinstance(item, ical.Component): element.text = ical.serialize( - calendar_headers, calendar_timezones + [item]) + collection_tag, collection_headers, + collection_timezones + [item]) prop.append(element) status = ET.Element(_tag("D", "status")) From b56db741f4b13e9beb837e622b9a9d1b5509c5a2 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Wed, 4 Jan 2012 19:47:34 +0100 Subject: [PATCH 2/7] Add support for Evolution VCard WebDAV --- radicale/__init__.py | 1 + radicale/ical.py | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/radicale/__init__.py b/radicale/__init__.py index 1ff67f5..a472f1d 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -405,6 +405,7 @@ class Application(object): def put(self, environ, collections, content, user): """Manage PUT request.""" collection = collections[0] + collection.set_mimetype(environ.get("CONTENT_TYPE")) headers = {} item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection) item = collection.get_item(item_name) diff --git a/radicale/ical.py b/radicale/ical.py index 4ad689c..5889976 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -110,11 +110,11 @@ class Item(object): line, "X-RADICALE-NAME:%s" % self._name) else: self.text = self.text.replace( - "\nUID:", "\nX-RADICALE-NAME:%s\nUID:" % self._name) + "\nEND:", "\nX-RADICALE-NAME:%s\nEND:" % self._name) else: self._name = str(uuid.uuid4()) self.text = self.text.replace( - "\nEND:", "\nUID:%s\nEND:" % self._name) + "\nEND:", "\nX-RADICALE-NAME:%s\nEND:" % self._name) @property def etag(self): @@ -342,7 +342,7 @@ class Collection(object): items = self.items for new_item in self._parse( - text, (Timezone, Event, Todo, Journal), name): + text, (Timezone, Event, Todo, Journal, Card), name): if new_item.name not in (item.name for item in items): items.append(new_item) @@ -371,7 +371,7 @@ class Collection(object): """Write calendar with given parameters.""" headers = headers or self.headers or ( Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"), - Header("VERSION:2.0")) + Header("VERSION:%s" % self.version)) items = items if items is not None else self.items self._create_dirs(self.path) @@ -379,6 +379,15 @@ class Collection(object): text = serialize(self.tag, headers, items) return open(self.path, "w").write(text) + def set_mimetype(self, mimetype): + """Set the mimetype of the collection.""" + with self.props as props: + if "tag" not in props: + if mimetype == "text/vcard": + props["tag"] = "VADDRESSBOOK" + else: + props["tag"] = "VCALENDAR" + @staticmethod def _create_dirs(path): """Create folder if absent.""" @@ -438,12 +447,11 @@ class Collection(object): header_lines = [] lines = unfold(self.text) - for line in lines: - if line.startswith("PRODID:"): - header_lines.append(Header(line)) - for line in lines: - if line.startswith("VERSION:"): - header_lines.append(Header(line)) + for header in ("PRODID", "VERSION"): + for line in lines: + if line.startswith("%s:" % header): + header_lines.append(Header(line)) + break return header_lines @@ -488,3 +496,8 @@ class Collection(object): def url(self): """Get the standard collection URL.""" return "/%s/" % self.local_path + + @property + def version(self): + """Get the version of the collection type.""" + return "3.0" if self.tag == "VADDRESSBOOK" else "2.0" From f11e78a3f4bc814a90a98f22379ba5a8fce0a62a Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Thu, 5 Jan 2012 21:49:34 +0100 Subject: [PATCH 3/7] Answer addressbook-home-set, fix the collection children detection --- radicale/ical.py | 14 ++++++++------ radicale/xmlutils.py | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/radicale/ical.py b/radicale/ical.py index 5889976..9030b3e 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -230,8 +230,9 @@ class Collection(object): result.append(cls(path, principal)) try: for filename in next(os.walk(abs_path))[2]: - if cls.is_collection(os.path.join(abs_path, filename)): - result.append(cls(os.path.join(path, filename))) + collection = cls(os.path.join(path, filename)) + if collection.exists: + result.append(collection) except StopIteration: # Directory does not exist yet pass @@ -245,11 +246,12 @@ class Collection(object): result.extend(collection.components) return result - def is_collection(self, path): - """Return ``True`` if there is a collection file under ``path``.""" + @property + def exists(self): + """Return ``True`` if there is a collection file exists.""" beginning_string = 'BEGIN:%s' % self.tag - with open(path) as stream: - beginning_string = stream.read(len(beginning_string)) + with open(self.path) as stream: + return beginning_string == stream.read(len(beginning_string)) @property def items(self): diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 7139e12..81e1a1d 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -242,6 +242,7 @@ def _propfind_response(path, item, props, user): elif tag in ( _tag("D", "principal-collection-set"), _tag("C", "calendar-user-address-set"), + _tag("CR", "addressbook-home-set"), _tag("C", "calendar-home-set")): tag = ET.Element(_tag("D", "href")) tag.text = path From bff01db29b6c0c2010a04b832627acf08166ff55 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Thu, 5 Jan 2012 21:58:50 +0100 Subject: [PATCH 4/7] Manage addressbook-multiget and address-data --- radicale/xmlutils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 81e1a1d..1f301c8 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -415,7 +415,9 @@ def report(path, xml_request, collection): props = [prop.tag for prop in prop_element] if collection: - if root.tag == _tag("C", "calendar-multiget"): + if root.tag in ( + _tag("C", "calendar-multiget"), + _tag("CR", "addressbook-multiget")): # Read rfc4791-7.9 for info hreferences = set( href_element.text for href_element @@ -463,7 +465,8 @@ def report(path, xml_request, collection): element = ET.Element(tag) if tag == _tag("D", "getetag"): element.text = item.etag - elif tag == _tag("C", "calendar-data"): + elif tag in ( + _tag("C", "calendar-data"), _tag("CR", "address-data")): if isinstance(item, ical.Component): element.text = ical.serialize( collection_tag, collection_headers, From fd3eacfe01d29e9163b49c25144f3df7c9a0e4e8 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Thu, 5 Jan 2012 22:56:37 +0100 Subject: [PATCH 5/7] Ignore .props files for collections children --- radicale/ical.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/radicale/ical.py b/radicale/ical.py index 9030b3e..de14a49 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -230,9 +230,10 @@ class Collection(object): result.append(cls(path, principal)) try: for filename in next(os.walk(abs_path))[2]: - collection = cls(os.path.join(path, filename)) - if collection.exists: - result.append(collection) + if not filename.endswith(".props"): + collection = cls(os.path.join(path, filename)) + if collection.exists: + result.append(collection) except StopIteration: # Directory does not exist yet pass From 1dfa887384047a28823660148fa44f7326324101 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Fri, 6 Jan 2012 19:01:52 +0100 Subject: [PATCH 6/7] Fix the GET and REPORT requests for vcards --- radicale/ical.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/radicale/ical.py b/radicale/ical.py index de14a49..6072bb2 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -53,11 +53,14 @@ def serialize(tag, headers=(), items=()): The collection has the given ``headers`` and ``items``. """ - lines = ["BEGIN:%s" % tag] - for part in (headers, items): - if part: - lines.append("\n".join(item.text for item in part)) - lines.append("END:%s\n" % tag) + if tag == "VCARD" or (tag == "VADDRESSBOOK" and items and len(items) == 1): + lines = [items[0].text] + else: + lines = ["BEGIN:%s" % tag] + for part in (headers, items): + if part: + lines.append("\n".join(item.text for item in part)) + lines.append("END:%s\n" % tag) return "\n".join(lines) From a4a52c71d2c08b828e87ab0e854c4703adac2889 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Fri, 6 Jan 2012 19:42:20 +0100 Subject: [PATCH 7/7] Use a clean way to manage calendars and address books different serialization --- radicale/ical.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/radicale/ical.py b/radicale/ical.py index 6072bb2..c16c506 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -47,13 +47,17 @@ def open(path, mode="r"): # pylint: enable=W0622 -def serialize(tag, headers=(), items=()): - """Return a collection text corresponding to given ``tag``. +def serialize(tag, headers=(), items=(), whole=False): + """Return a text corresponding to given collection ``tag``. - The collection has the given ``headers`` and ``items``. + The text may have the given ``headers`` and ``items`` added around the + items if needed (ie. for calendars). + + If ``whole`` is ``True``, the collection tags and headers are added, even + for address books. """ - if tag == "VCARD" or (tag == "VADDRESSBOOK" and items and len(items) == 1): + if tag == "VADDRESSBOOK" and not whole: lines = [items[0].text] else: lines = ["BEGIN:%s" % tag] @@ -382,7 +386,7 @@ class Collection(object): self._create_dirs(self.path) - text = serialize(self.tag, headers, items) + text = serialize(self.tag, headers, items, True) return open(self.path, "w").write(text) def set_mimetype(self, mimetype):