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