diff --git a/bin/radicale b/bin/radicale index 1d41442..3fd6ad0 100755 --- a/bin/radicale +++ b/bin/radicale @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # diff --git a/radicale.fcgi b/radicale.fcgi index c1b6b1a..90b52ef 100755 --- a/radicale.fcgi +++ b/radicale.fcgi @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2011 Guillaume Ayoub +# Copyright © 2011-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/radicale.py b/radicale.py index 1d41442..3fd6ad0 100755 --- a/radicale.py +++ b/radicale.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # diff --git a/radicale.wsgi b/radicale.wsgi index cec1f8c..ef93053 100755 --- a/radicale.wsgi +++ b/radicale.wsgi @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2011 Guillaume Ayoub +# Copyright © 2011-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/radicale/__init__.py b/radicale/__init__.py index 7100c05..3b27ce4 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # @@ -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__() @@ -181,8 +181,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 @@ -190,7 +190,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 @@ -204,37 +204,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 @@ -265,21 +265,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.path == environ["PATH_INFO"].strip("/"): - # Path matching the calendar, the item to delete is the calendar - item = calendar + if collection.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 @@ -287,7 +287,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"] == "/": @@ -295,67 +295,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 @@ -364,60 +374,61 @@ 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] + collection.set_mimetype(environ.get("CONTENT_TYPE")) 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/__main__.py b/radicale/__main__.py index 21a8db9..539a8cc 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2011 Guillaume Ayoub +# Copyright © 2011-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/radicale/acl/LDAP.py b/radicale/acl/LDAP.py index bb2e2e5..32d0319 100644 --- a/radicale/acl/LDAP.py +++ b/radicale/acl/LDAP.py @@ -2,7 +2,7 @@ # # This file is part of Radicale Server - Calendar Server # Copyright © 2011 Corentin Le Bail -# Copyright © 2011 Guillaume Ayoub +# Copyright © 2011-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/radicale/acl/__init__.py b/radicale/acl/__init__.py index 8886ae2..3a78810 100644 --- a/radicale/acl/__init__.py +++ b/radicale/acl/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # diff --git a/radicale/acl/htpasswd.py b/radicale/acl/htpasswd.py index e3ec2c4..abc76a2 100644 --- a/radicale/acl/htpasswd.py +++ b/radicale/acl/htpasswd.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # diff --git a/radicale/config.py b/radicale/config.py index dbaa186..28bde0d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # diff --git a/radicale/ical.py b/radicale/ical.py index 7807bba..aca1bec 100644 --- a/radicale/ical.py +++ b/radicale/ical.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # @@ -19,9 +19,9 @@ # along with Radicale. If not, see . """ -Radicale calendar classes. +Radicale collection classes. -Define the main classes of a calendar as seen from the server. +Define the main classes of a collection as seen from the server. """ @@ -31,13 +31,24 @@ import uuid from contextlib import contextmanager -def serialize(headers=(), items=()): - """Return an iCal text corresponding to given ``headers`` and ``items``.""" - lines = ["BEGIN:VCALENDAR"] - for part in (headers, items): - if part: - lines.append("\n".join(item.text for item in part)) - lines.append("END:VCALENDAR\n") +def serialize(tag, headers=(), items=(), whole=False): + """Return a text corresponding to given collection ``tag``. + + 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 == "VADDRESSBOOK" and not whole: + 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) @@ -92,11 +103,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): @@ -121,41 +132,49 @@ 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. +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. This class must be overridden and replaced by a storage backend. """ - tag = "VCALENDAR" - 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. """ @@ -163,7 +182,7 @@ class Calendar(object): split_path = path.split("/") self.path = path if path != '.' else '' if principal and split_path and self.is_collection(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 @@ -174,7 +193,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 @@ -208,58 +227,54 @@ 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 def save(self, text): - """Save the text into the calendar.""" - raise NotImplemented + """Save the text into the collection.""" + raise NotImplementedError def delete(self): - """Delete the calendar.""" - raise NotImplemented + """Delete the collection.""" + raise NotImplementedError @property def text(self): - """Calendar as plain text.""" - raise NotImplemented + """Collection as plain text.""" + raise NotImplementedError @classmethod def children(cls, path): """Yield the children of the collection at local ``path``.""" - raise NotImplemented + raise NotImplementedError @classmethod def is_collection(cls, path): """Return ``True`` if relative ``path`` is a collection.""" - raise NotImplemented + raise NotImplementedError @classmethod def is_item(cls, path): """Return ``True`` if relative ``path`` is a collection item.""" - raise NotImplemented + raise NotImplementedError @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. """ - raise NotImplemented + raise NotImplementedError @property @contextmanager def props(self): - """Get the calendar properties.""" - raise NotImplemented - - def is_vcalendar(self, path): - """Return ``True`` if there is a VCALENDAR under relative ``path``.""" - return self.text.startswith('BEGIN:VCALENDAR') + """Get the collection properties.""" + raise NotImplementedError @staticmethod def _parse(text, item_types, name=None): @@ -303,13 +318,13 @@ class Calendar(object): return list(items.values()) def get_item(self, name): - """Get calendar item called ``name``.""" + """Get collection item called ``name``.""" for item in self.items: if item.name == name: return item def append(self, name, text): - """Append items from ``text`` to calendar. + """Append items from ``text`` to collection. If ``name`` is given, give this name to new items in ``text``. @@ -317,14 +332,14 @@ class Calendar(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) self.write(items=items) def remove(self, name): - """Remove object named ``name`` from calendar.""" + """Remove object named ``name`` from collection.""" components = [ component for component in self.components if component.name != name] @@ -333,56 +348,82 @@ class Calendar(object): self.write(items=items) def replace(self, name, text): - """Replace content by ``text`` in objet named ``name`` in calendar.""" + """Replace content by ``text`` in collection objet called ``name``.""" self.remove(name) self.append(name, text) def write(self, headers=None, items=None): - """Write calendar with given parameters.""" + """Write collection 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 text = serialize(headers, items) self.save(text) + @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 headers(self): - """Find headers items in calendar.""" + """Find headers items in collection.""" 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 @property def items(self): - """Get list of all items in calendar.""" - return self._parse(self.text, (Event, Todo, Journal, Timezone)) + """Get list of all items in collection.""" + return self._parse(self.text, (Event, Todo, Journal, Card, Timezone)) @property def components(self): - """Get list of all components in calendar.""" - return self._parse(self.text, (Event, Todo, Journal)) + """Get list of all components in collection.""" + return self._parse(self.text, (Event, Todo, Journal, Card)) @property def events(self): @@ -404,9 +445,14 @@ class Calendar(object): """Get list of ``Timezome`` items in calendar.""" return self._parse(self.text, (Timezone,)) + @property + def cards(self): + """Get list of ``Card`` items in address book.""" + return self._parse(self.text, (Card,)) + @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: @@ -414,5 +460,10 @@ class Calendar(object): @property def url(self): - """Get the standard calendar URL.""" + """Get the standard collection URL.""" return "/%s/" % self.path + + @property + def version(self): + """Get the version of the collection type.""" + return "3.0" if self.tag == "VADDRESSBOOK" else "2.0" diff --git a/radicale/log.py b/radicale/log.py index 8075c40..5c3fc65 100644 --- a/radicale/log.py +++ b/radicale/log.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2011 Guillaume Ayoub +# Copyright © 2011-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/radicale/storage/filesystem.py b/radicale/storage/filesystem.py index b735d22..2e262c1 100644 --- a/radicale/storage/filesystem.py +++ b/radicale/storage/filesystem.py @@ -36,12 +36,14 @@ FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder")) # This function overrides the builtin ``open`` function for this module # pylint: disable=W0622 def open(path, mode="r"): + """Open a file at ``path`` with encoding set in the configuration.""" abs_path = os.path.join(FOLDER, path.replace("/", os.sep)) return codecs.open(abs_path, mode, config.get("encoding", "stock")) # pylint: enable=W0622 -class Calendar(ical.Calendar): +class Collection(ical.Collection): + """Collection stored in a flat ical file.""" @property def _path(self): """Absolute path of the file at local ``path``.""" @@ -49,11 +51,11 @@ class Calendar(ical.Calendar): @property def _props_path(self): - """Absolute path of the file storing the calendar properties.""" + """Absolute path of the file storing the collection properties.""" return self._path + ".props" def _create_dirs(self): - """Create folder storing the calendar if absent.""" + """Create folder storing the collection if absent.""" if not os.path.exists(os.path.dirname(self._path)): os.makedirs(os.path.dirname(self._path)) @@ -74,10 +76,12 @@ class Calendar(ical.Calendar): @classmethod def children(cls, path): - abs_path = os.path.join(FOLDER, path.replace("/", os.sep)) + rel_path = path.replace("/", os.sep) + abs_path = os.path.join(FOLDER, rel_path) for filename in next(os.walk(abs_path))[2]: - if cls.is_collection(path): - yield cls(path) + rel_filename = os.path.join(rel_path, filename) + if cls.is_collection(rel_filename): + yield cls(rel_filename) @classmethod def is_collection(cls, path): @@ -91,7 +95,7 @@ class Calendar(ical.Calendar): @property def last_modified(self): - # Create calendar if needed + # Create collection if needed if not os.path.exists(self._path): self.write() @@ -113,4 +117,4 @@ class Calendar(ical.Calendar): json.dump(properties, prop_file) -ical.Calendar = Calendar +ical.Collection = Collection diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 9e64bfe..536f21c 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2011 Guillaume Ayoub +# Copyright © 2008-2012 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # @@ -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.path.split("/") + collection_parts = collection.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.path == path.strip("/"): - # Delete the whole calendar - calendar.delete() + if collection.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) @@ -234,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 @@ -263,15 +272,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 +289,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 +351,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 +372,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 +391,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,8 +414,10 @@ def report(path, xml_request, calendar): prop_element = root.find(_tag("D", "prop")) props = [prop.tag for prop in prop_element] - if calendar: - if root.tag == _tag("C", "calendar-multiget"): + if collection: + 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 @@ -417,21 +430,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")) @@ -451,10 +465,12 @@ def report(path, xml_request, calendar): element = ET.Element(tag) if tag == _tag("D", "getetag"): element.text = item.etag - elif tag == _tag("C", "calendar-data"): - if isinstance(item, (ical.Event, ical.Todo, ical.Journal)): + elif tag in ( + _tag("C", "calendar-data"), _tag("CR", "address-data")): + 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")) diff --git a/setup.py b/setup.py index 6d863da..9aeedac 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of Radicale Server - Calendar Server -# Copyright © 2009-2011 Guillaume Ayoub +# Copyright © 2009-2012 Guillaume Ayoub # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by