mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
Simple metadata interface
This commit is contained in:
parent
f087ec599e
commit
a007828f87
9 changed files with 155 additions and 1 deletions
|
|
@ -43,10 +43,28 @@ the client, which are free to choose a different scheme for filenames instead.
|
||||||
.. _CardDAV: http://tools.ietf.org/html/rfc6352
|
.. _CardDAV: http://tools.ietf.org/html/rfc6352
|
||||||
.. _CalDAV: http://tools.ietf.org/search/rfc4791
|
.. _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
|
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
|
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
|
the appropriate location is usually a very effective solution. For this
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ def format_item(item_template, uid=None):
|
||||||
class StorageTests(object):
|
class StorageTests(object):
|
||||||
storage_class = None
|
storage_class = None
|
||||||
supports_collections = True
|
supports_collections = True
|
||||||
|
supports_metadata = True
|
||||||
|
|
||||||
@pytest.fixture(params=['VEVENT', 'VTODO', 'VCARD'])
|
@pytest.fixture(params=['VEVENT', 'VTODO', 'VCARD'])
|
||||||
def item_type(self, request):
|
def item_type(self, request):
|
||||||
|
|
@ -55,6 +56,11 @@ class StorageTests(object):
|
||||||
if not self.supports_collections:
|
if not self.supports_collections:
|
||||||
pytest.skip('This storage does not support 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):
|
def test_generic(self, s, get_item):
|
||||||
items = [get_item() for i in range(1, 10)]
|
items = [get_item() for i in range(1, 10)]
|
||||||
hrefs = []
|
hrefs = []
|
||||||
|
|
@ -227,3 +233,13 @@ class StorageTests(object):
|
||||||
s = self.storage_class(**get_storage_args(collection=collname))
|
s = self.storage_class(**get_storage_args(collection=collname))
|
||||||
href, etag = s.upload(get_item())
|
href, etag = s.upload(get_item())
|
||||||
s.get(href)
|
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'
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class CombinedStorage(Storage):
|
||||||
class TestHttpStorage(StorageTests):
|
class TestHttpStorage(StorageTests):
|
||||||
storage_class = CombinedStorage
|
storage_class = CombinedStorage
|
||||||
supports_collections = False
|
supports_collections = False
|
||||||
|
supports_metadata = False
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_tmpdir(self, tmpdir, monkeypatch):
|
def setup_tmpdir(self, tmpdir, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class TestSingleFileStorage(StorageTests):
|
||||||
|
|
||||||
storage_class = SingleFileStorage
|
storage_class = SingleFileStorage
|
||||||
supports_collections = False
|
supports_collections = False
|
||||||
|
supports_metadata = False
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup(self, tmpdir):
|
def setup(self, tmpdir):
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,7 @@ class ReadOnlyError(Error):
|
||||||
|
|
||||||
class InvalidResponse(Error, ValueError):
|
class InvalidResponse(Error, ValueError):
|
||||||
'''The backend returned an invalid result.'''
|
'''The backend returned an invalid result.'''
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedMetadataError(Error, NotImplementedError):
|
||||||
|
'''The storage doesn't support this type of metadata.'''
|
||||||
|
|
|
||||||
|
|
@ -208,3 +208,25 @@ class Storage(with_metaclass(StorageMeta)):
|
||||||
when.
|
when.
|
||||||
'''
|
'''
|
||||||
yield
|
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.')
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,10 @@ class DavStorage(Storage):
|
||||||
_session = None
|
_session = None
|
||||||
_repr_attributes = ('username', 'url')
|
_repr_attributes = ('username', 'url')
|
||||||
|
|
||||||
|
_property_table = {
|
||||||
|
'displayname': ('displayname', 'DAV:'),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, url, username='', password='', verify=True, auth=None,
|
def __init__(self, url, username='', password='', verify=True, auth=None,
|
||||||
useragent=USERAGENT, unsafe_href_chars='@',
|
useragent=USERAGENT, unsafe_href_chars='@',
|
||||||
verify_fingerprint=None, auth_cert=None, **kwargs):
|
verify_fingerprint=None, auth_cert=None, **kwargs):
|
||||||
|
|
@ -546,6 +550,65 @@ class DavStorage(Storage):
|
||||||
for href, etag, prop in rv:
|
for href, etag, prop in rv:
|
||||||
yield utils.compat.urlunquote(href), etag
|
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 = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
{}
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
'''.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 = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:">
|
||||||
|
<D:set>
|
||||||
|
<D:prop>
|
||||||
|
{}
|
||||||
|
</D:prop>
|
||||||
|
</D:set>
|
||||||
|
</D:propertyupdate>
|
||||||
|
'''.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):
|
class CaldavStorage(DavStorage):
|
||||||
|
|
||||||
|
|
@ -598,6 +661,11 @@ class CaldavStorage(DavStorage):
|
||||||
|
|
||||||
get_multi_data_query = '{urn:ietf:params:xml:ns:caldav}calendar-data'
|
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,
|
def __init__(self, start_date=None, end_date=None,
|
||||||
item_types=(), **kwargs):
|
item_types=(), **kwargs):
|
||||||
super(CaldavStorage, self).__init__(**kwargs)
|
super(CaldavStorage, self).__init__(**kwargs)
|
||||||
|
|
|
||||||
|
|
@ -180,3 +180,20 @@ class FilesystemStorage(Storage):
|
||||||
subprocess.call([self.post_hook, fpath])
|
subprocess.call([self.post_hook, fpath])
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning('Error executing external hook: {}'.format(str(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))
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class MemoryStorage(Storage):
|
||||||
raise exceptions.UserError('MemoryStorage does not support '
|
raise exceptions.UserError('MemoryStorage does not support '
|
||||||
'collections.')
|
'collections.')
|
||||||
self.items = {} # href => (etag, item)
|
self.items = {} # href => (etag, item)
|
||||||
|
self.metadata = {}
|
||||||
self.fileext = fileext
|
self.fileext = fileext
|
||||||
super(MemoryStorage, self).__init__(**kwargs)
|
super(MemoryStorage, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
|
@ -63,3 +64,9 @@ class MemoryStorage(Storage):
|
||||||
if etag != self.items[href][0]:
|
if etag != self.items[href][0]:
|
||||||
raise exceptions.WrongEtagError(etag)
|
raise exceptions.WrongEtagError(etag)
|
||||||
del self.items[href]
|
del self.items[href]
|
||||||
|
|
||||||
|
def get_meta(self, key):
|
||||||
|
return self.metadata[key]
|
||||||
|
|
||||||
|
def set_meta(self, key, value):
|
||||||
|
self.metadata[key] = value
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue