diff --git a/docs/vdir.rst b/docs/vdir.rst
index 19f5f19..4215662 100644
--- a/docs/vdir.rst
+++ b/docs/vdir.rst
@@ -43,10 +43,28 @@ the client, which are free to choose a different scheme for filenames instead.
.. _CardDAV: http://tools.ietf.org/html/rfc6352
.. _CalDAV: http://tools.ietf.org/search/rfc4791
+Metadata
+========
+
+Any of the below metadata files may be absent. None of the files listed below
+have any file extensions.
+
+- A file called ``color`` inside the vdir indicates the vdir's color, a
+ property that is only relevant in UI design.
+
+ Its content is an ASCII-encoded hex-RGB value of the form ``#RRGGBB``. For
+ example, a file content of ``#FF0000`` indicates that the vdir has a red
+ (user-visible) color. No short forms or informal values such as ``red`` (as
+ known from CSS, for example) are allowed. The prefixing ``#`` must be
+ present.
+
+- A file called ``displayname`` contains a UTF-8 encoded label that may be used
+ to represent the vdir in UIs.
+
Writing to vdirs
================
-Creating and modifying items *should* happen atomically_.
+Creating and modifying items or metadata files *should* happen atomically_.
Writing to a temporary file on the same physical device, and then moving it to
the appropriate location is usually a very effective solution. For this
diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py
index 78c9b12..141212c 100644
--- a/tests/storage/__init__.py
+++ b/tests/storage/__init__.py
@@ -21,6 +21,7 @@ def format_item(item_template, uid=None):
class StorageTests(object):
storage_class = None
supports_collections = True
+ supports_metadata = True
@pytest.fixture(params=['VEVENT', 'VTODO', 'VCARD'])
def item_type(self, request):
@@ -55,6 +56,11 @@ class StorageTests(object):
if not self.supports_collections:
pytest.skip('This storage does not support collections.')
+ @pytest.fixture
+ def requires_metadata(self):
+ if not self.supports_metadata:
+ pytest.skip('This storage does not support metadata.')
+
def test_generic(self, s, get_item):
items = [get_item() for i in range(1, 10)]
hrefs = []
@@ -227,3 +233,13 @@ class StorageTests(object):
s = self.storage_class(**get_storage_args(collection=collname))
href, etag = s.upload(get_item())
s.get(href)
+
+ def test_metadata(self, requires_metadata, s):
+ try:
+ s.set_meta('color', u'#ff0000')
+ assert s.get_meta('color') == u'#ff0000'
+ except exceptions.UnsupportedMetadataError:
+ pass
+
+ s.set_meta('displayname', u'hello world')
+ assert s.get_meta('displayname') == u'hello world'
diff --git a/tests/storage/test_http_with_singlefile.py b/tests/storage/test_http_with_singlefile.py
index 7ac5fe2..f0431bf 100644
--- a/tests/storage/test_http_with_singlefile.py
+++ b/tests/storage/test_http_with_singlefile.py
@@ -43,6 +43,7 @@ class CombinedStorage(Storage):
class TestHttpStorage(StorageTests):
storage_class = CombinedStorage
supports_collections = False
+ supports_metadata = False
@pytest.fixture(autouse=True)
def setup_tmpdir(self, tmpdir, monkeypatch):
diff --git a/tests/storage/test_singlefile.py b/tests/storage/test_singlefile.py
index ce28588..b2be4ee 100644
--- a/tests/storage/test_singlefile.py
+++ b/tests/storage/test_singlefile.py
@@ -11,6 +11,7 @@ class TestSingleFileStorage(StorageTests):
storage_class = SingleFileStorage
supports_collections = False
+ supports_metadata = False
@pytest.fixture(autouse=True)
def setup(self, tmpdir):
diff --git a/vdirsyncer/exceptions.py b/vdirsyncer/exceptions.py
index 8acf654..6effc7f 100644
--- a/vdirsyncer/exceptions.py
+++ b/vdirsyncer/exceptions.py
@@ -56,3 +56,7 @@ class ReadOnlyError(Error):
class InvalidResponse(Error, ValueError):
'''The backend returned an invalid result.'''
+
+
+class UnsupportedMetadataError(Error, NotImplementedError):
+ '''The storage doesn't support this type of metadata.'''
diff --git a/vdirsyncer/storage/base.py b/vdirsyncer/storage/base.py
index 9fb5974..d86396a 100644
--- a/vdirsyncer/storage/base.py
+++ b/vdirsyncer/storage/base.py
@@ -208,3 +208,25 @@ class Storage(with_metaclass(StorageMeta)):
when.
'''
yield
+
+ def get_meta(self, key):
+ '''Get metadata value for collection/storage.
+
+ See the vdir specification for the keys that *have* to be accepted.
+
+ :param key: The metadata key.
+ :type key: unicode
+ '''
+
+ raise NotImplementedError('This storage does not support metadata.')
+
+ def set_meta(self, key, value):
+ '''Get metadata value for collection/storage.
+
+ :param key: The metadata key.
+ :type key: unicode
+ :param value: The value.
+ :type value: unicode
+ '''
+
+ raise NotImplementedError('This storage does not support metadata.')
diff --git a/vdirsyncer/storage/dav.py b/vdirsyncer/storage/dav.py
index 3ece32f..1180d11 100644
--- a/vdirsyncer/storage/dav.py
+++ b/vdirsyncer/storage/dav.py
@@ -336,6 +336,10 @@ class DavStorage(Storage):
_session = None
_repr_attributes = ('username', 'url')
+ _property_table = {
+ 'displayname': ('displayname', 'DAV:'),
+ }
+
def __init__(self, url, username='', password='', verify=True, auth=None,
useragent=USERAGENT, unsafe_href_chars='@',
verify_fingerprint=None, auth_cert=None, **kwargs):
@@ -546,6 +550,65 @@ class DavStorage(Storage):
for href, etag, prop in rv:
yield utils.compat.urlunquote(href), etag
+ def get_meta(self, key):
+ try:
+ tagname, namespace = self._property_table[key]
+ except KeyError:
+ raise exceptions.UnsupportedMetadataError()
+
+ lxml_selector = '{%s}%s' % (namespace, tagname)
+ data = '''
+
+
+ {}
+
+
+ '''.format(
+ to_native(etree.tostring(etree.Element(lxml_selector)))
+ )
+
+ headers = self.session.get_default_headers()
+ headers['Depth'] = 0
+
+ response = self.session.request(
+ 'PROPFIND', '',
+ data=data, headers=headers
+ )
+
+ root = _parse_xml(response.content)
+
+ for prop in root.findall('.//' + lxml_selector):
+ text = getattr(prop, 'text', None)
+ if text:
+ return text
+
+ def set_meta(self, key, value):
+ try:
+ tagname, namespace = self._property_table[key]
+ except KeyError:
+ raise exceptions.UnsupportedMetadataError()
+
+ lxml_selector = '{%s}%s' % (namespace, tagname)
+ element = etree.Element(lxml_selector)
+ element.text = value
+
+ data = '''
+
+
+
+ {}
+
+
+
+ '''.format(to_native(etree.tostring(element)))
+
+ self.session.request(
+ 'PROPPATCH', '',
+ data=data, headers=self.session.get_default_headers()
+ )
+
+ # FIXME: Deal with response
+
class CaldavStorage(DavStorage):
@@ -598,6 +661,11 @@ class CaldavStorage(DavStorage):
get_multi_data_query = '{urn:ietf:params:xml:ns:caldav}calendar-data'
+ _property_table = dict(DavStorage._property_table)
+ _property_table.update({
+ 'color': ('calendar-color', 'http://apple.com/ns/ical/'),
+ })
+
def __init__(self, start_date=None, end_date=None,
item_types=(), **kwargs):
super(CaldavStorage, self).__init__(**kwargs)
diff --git a/vdirsyncer/storage/filesystem.py b/vdirsyncer/storage/filesystem.py
index 4dcb849..543d863 100644
--- a/vdirsyncer/storage/filesystem.py
+++ b/vdirsyncer/storage/filesystem.py
@@ -180,3 +180,20 @@ class FilesystemStorage(Storage):
subprocess.call([self.post_hook, fpath])
except OSError as e:
logger.warning('Error executing external hook: {}'.format(str(e)))
+
+ def get_meta(self, key):
+ fpath = os.path.join(self.path, key)
+ try:
+ with open(fpath, 'rb') as f:
+ return f.read().decode(self.encoding)
+ except IOError as e:
+ if e.errno == errno.ENOENT:
+ return None
+ else:
+ raise
+
+ def set_meta(self, key, value):
+ assert isinstance(value, text_type)
+ fpath = os.path.join(self.path, key)
+ with atomic_write(fpath, mode='wb', overwrite=True) as f:
+ f.write(value.encode(self.encoding))
diff --git a/vdirsyncer/storage/memory.py b/vdirsyncer/storage/memory.py
index 765f8cd..2775745 100644
--- a/vdirsyncer/storage/memory.py
+++ b/vdirsyncer/storage/memory.py
@@ -21,6 +21,7 @@ class MemoryStorage(Storage):
raise exceptions.UserError('MemoryStorage does not support '
'collections.')
self.items = {} # href => (etag, item)
+ self.metadata = {}
self.fileext = fileext
super(MemoryStorage, self).__init__(**kwargs)
@@ -63,3 +64,9 @@ class MemoryStorage(Storage):
if etag != self.items[href][0]:
raise exceptions.WrongEtagError(etag)
del self.items[href]
+
+ def get_meta(self, key):
+ return self.metadata[key]
+
+ def set_meta(self, key, value):
+ self.metadata[key] = value