|
|
@@ -33,7 +33,6 @@ import sys
|
|
|
import time
|
|
|
from contextlib import contextmanager
|
|
|
from hashlib import md5
|
|
|
-from random import randint
|
|
|
from uuid import uuid4
|
|
|
|
|
|
import vobject
|
|
|
@@ -55,26 +54,14 @@ def _load():
|
|
|
FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
|
|
|
FILESYSTEM_ENCODING = sys.getfilesystemencoding()
|
|
|
STORAGE_ENCODING = config.get("encoding", "stock")
|
|
|
+MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
|
|
|
|
|
|
|
|
|
-def serialize(tag, headers=(), items=()):
|
|
|
- """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).
|
|
|
-
|
|
|
- """
|
|
|
- items = sorted(items, key=lambda x: x.name)
|
|
|
- if tag == "VADDRESSBOOK":
|
|
|
- lines = [item.text.strip() for item in items]
|
|
|
- else:
|
|
|
- lines = ["BEGIN:%s" % tag]
|
|
|
- for part in (headers, items):
|
|
|
- if part:
|
|
|
- lines.append("\r\n".join(item.text.strip() for item in part))
|
|
|
- lines.append("END:%s" % tag)
|
|
|
- lines.append("")
|
|
|
- return "\r\n".join(lines)
|
|
|
+def get_etag(text):
|
|
|
+ """Etag from collection or item."""
|
|
|
+ etag = md5()
|
|
|
+ etag.update(text.encode("utf-8"))
|
|
|
+ return '"%s"' % etag.hexdigest()
|
|
|
|
|
|
|
|
|
def sanitize_path(path):
|
|
|
@@ -105,129 +92,35 @@ def is_safe_filesystem_path_component(path):
|
|
|
not os.path.split(path)[0] and path not in (os.curdir, os.pardir))
|
|
|
|
|
|
|
|
|
-def path_to_filesystem(path):
|
|
|
+def path_to_filesystem(root, *paths):
|
|
|
"""Convert path to a local filesystem path relative to base_folder.
|
|
|
|
|
|
Conversion is done in a secure manner, or raises ``ValueError``.
|
|
|
|
|
|
"""
|
|
|
- sane_path = sanitize_path(path).strip("/")
|
|
|
- safe_path = FOLDER
|
|
|
- if not sane_path:
|
|
|
- return safe_path
|
|
|
- for part in sane_path.split("/"):
|
|
|
- if not is_safe_filesystem_path_component(part):
|
|
|
- log.LOGGER.debug(
|
|
|
- "Can't translate path safely to filesystem: %s", path)
|
|
|
- raise ValueError("Unsafe path")
|
|
|
- safe_path = os.path.join(safe_path, part)
|
|
|
+ root = sanitize_path(root)
|
|
|
+ paths = [sanitize_path(path).strip("/") for path in paths]
|
|
|
+ safe_path = root
|
|
|
+ for path in paths:
|
|
|
+ if not path:
|
|
|
+ continue
|
|
|
+ for part in path.split("/"):
|
|
|
+ if not is_safe_filesystem_path_component(part):
|
|
|
+ log.LOGGER.debug(
|
|
|
+ "Can't translate path safely to filesystem: %s", path)
|
|
|
+ raise ValueError("Unsafe path")
|
|
|
+ safe_path = os.path.join(safe_path, part)
|
|
|
return safe_path
|
|
|
|
|
|
|
|
|
-class Item(object):
|
|
|
- """Internal iCal item."""
|
|
|
- def __init__(self, text, name=None):
|
|
|
- """Initialize object from ``text`` and different ``kwargs``."""
|
|
|
- self.component = vobject.readOne(text)
|
|
|
- self._name = name
|
|
|
-
|
|
|
- if not self.component.name:
|
|
|
- # Header
|
|
|
- self._name = next(self.component.lines()).name.lower()
|
|
|
- return
|
|
|
-
|
|
|
- # We must synchronize the name in the text and in the object.
|
|
|
- # An item must have a name, determined in order by:
|
|
|
- #
|
|
|
- # - the ``name`` parameter
|
|
|
- # - the ``X-RADICALE-NAME`` iCal property (for Events, Todos, Journals)
|
|
|
- # - the ``UID`` iCal property (for Events, Todos, Journals)
|
|
|
- # - the ``TZID`` iCal property (for Timezones)
|
|
|
- if not self._name:
|
|
|
- for line in self.component.lines():
|
|
|
- if line.name in ("X-RADICALE-NAME", "UID", "TZID"):
|
|
|
- self._name = line.value
|
|
|
- if line.name == "X-RADICALE-NAME":
|
|
|
- break
|
|
|
-
|
|
|
- if self._name:
|
|
|
- # Leading and ending brackets that may have been put by Outlook.
|
|
|
- # Slashes are mostly unwanted when saving collections on disk.
|
|
|
- self._name = self._name.strip("{}").replace("/", "_")
|
|
|
- else:
|
|
|
- self._name = uuid4().hex
|
|
|
-
|
|
|
- if not hasattr(self.component, "x_radicale_name"):
|
|
|
- self.component.add("X-RADICALE-NAME")
|
|
|
- self.component.x_radicale_name.value = self._name
|
|
|
-
|
|
|
- def __hash__(self):
|
|
|
- return hash(self.text)
|
|
|
-
|
|
|
- def __eq__(self, item):
|
|
|
- return isinstance(item, Item) and self.text == item.text
|
|
|
-
|
|
|
- @property
|
|
|
- def etag(self):
|
|
|
- """Item etag.
|
|
|
-
|
|
|
- Etag is mainly used to know if an item has changed.
|
|
|
-
|
|
|
- """
|
|
|
- etag = md5()
|
|
|
- etag.update(self.text.encode("utf-8"))
|
|
|
- return '"%s"' % etag.hexdigest()
|
|
|
-
|
|
|
- @property
|
|
|
- def name(self):
|
|
|
- """Item name.
|
|
|
-
|
|
|
- Name is mainly used to give an URL to the item.
|
|
|
-
|
|
|
- """
|
|
|
- return self._name
|
|
|
-
|
|
|
- @property
|
|
|
- def text(self):
|
|
|
- """Item serialized text."""
|
|
|
- return self.component.serialize()
|
|
|
-
|
|
|
-
|
|
|
-class Header(Item):
|
|
|
- """Internal header class."""
|
|
|
-
|
|
|
-
|
|
|
-class Timezone(Item):
|
|
|
- """Internal timezone class."""
|
|
|
- tag = "VTIMEZONE"
|
|
|
-
|
|
|
-
|
|
|
-class Component(Item):
|
|
|
- """Internal main component of a collection."""
|
|
|
-
|
|
|
-
|
|
|
-class Event(Component):
|
|
|
- """Internal event class."""
|
|
|
- tag = "VEVENT"
|
|
|
- mimetype = "text/calendar"
|
|
|
+class Item:
|
|
|
+ def __init__(self, item, href, etag):
|
|
|
+ self.item = item
|
|
|
+ self.href = href
|
|
|
+ self.etag = etag
|
|
|
|
|
|
-
|
|
|
-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"
|
|
|
+ def __getattr__(self, attr):
|
|
|
+ return getattr(self.item, attr)
|
|
|
|
|
|
|
|
|
class Collection:
|
|
|
@@ -242,21 +135,18 @@ class Collection:
|
|
|
self.encoding = "utf-8"
|
|
|
# path should already be sanitized
|
|
|
self.path = sanitize_path(path).strip("/")
|
|
|
+ self._filesystem_path = path_to_filesystem(FOLDER, self.path)
|
|
|
split_path = self.path.split("/")
|
|
|
- if principal and split_path and self.is_node(self.path):
|
|
|
- # Already existing principal collection
|
|
|
- self.owner = split_path[0]
|
|
|
- elif len(split_path) > 1:
|
|
|
+ if len(split_path) > 1:
|
|
|
# URL with at least one folder
|
|
|
self.owner = split_path[0]
|
|
|
else:
|
|
|
self.owner = None
|
|
|
self.is_principal = principal
|
|
|
- self._items = None
|
|
|
|
|
|
@classmethod
|
|
|
- def from_path(cls, path, depth="1", include_container=True):
|
|
|
- """Return a list of collections and items under the given ``path``.
|
|
|
+ def discover(cls, path, depth="1"):
|
|
|
+ """Discover a list of collections under the given ``path``.
|
|
|
|
|
|
If ``depth`` is "0", only the actual object under ``path`` is
|
|
|
returned.
|
|
|
@@ -271,314 +161,223 @@ class Collection:
|
|
|
"""
|
|
|
# path == None means wrong URL
|
|
|
if path is None:
|
|
|
- return []
|
|
|
+ return
|
|
|
|
|
|
# path should already be sanitized
|
|
|
sane_path = sanitize_path(path).strip("/")
|
|
|
attributes = sane_path.split("/")
|
|
|
if not attributes:
|
|
|
- return []
|
|
|
+ return
|
|
|
|
|
|
# Try to guess if the path leads to a collection or an item
|
|
|
- if cls.is_leaf("/".join(attributes[:-1])):
|
|
|
+ if os.path.exists(path_to_filesystem(
|
|
|
+ FOLDER, *attributes[:-1]) + ".props"):
|
|
|
attributes.pop()
|
|
|
|
|
|
- result = []
|
|
|
path = "/".join(attributes)
|
|
|
|
|
|
principal = len(attributes) <= 1
|
|
|
- if cls.is_node(path):
|
|
|
- if depth == "0":
|
|
|
- result.append(cls(path, principal))
|
|
|
- else:
|
|
|
- if include_container:
|
|
|
- result.append(cls(path, principal))
|
|
|
- for child in cls.children(path):
|
|
|
- result.append(child)
|
|
|
- else:
|
|
|
- if depth == "0":
|
|
|
- result.append(cls(path))
|
|
|
+ collection = cls(path, principal)
|
|
|
+ yield collection
|
|
|
+ if depth != "0":
|
|
|
+ items = list(collection.list())
|
|
|
+ if items:
|
|
|
+ for item in items:
|
|
|
+ yield collection.get(item[0])
|
|
|
else:
|
|
|
- collection = cls(path, principal)
|
|
|
- if include_container:
|
|
|
- result.append(collection)
|
|
|
- result.extend(collection.components)
|
|
|
- return result
|
|
|
-
|
|
|
- @property
|
|
|
- def _filesystem_path(self):
|
|
|
- """Absolute path of the file at local ``path``."""
|
|
|
- return path_to_filesystem(self.path)
|
|
|
-
|
|
|
- @property
|
|
|
- def _props_path(self):
|
|
|
- """Absolute path of the file storing the collection properties."""
|
|
|
- return self._filesystem_path + ".props"
|
|
|
-
|
|
|
- def _create_dirs(self):
|
|
|
- """Create folder storing the collection if absent."""
|
|
|
- if not os.path.exists(self._filesystem_path):
|
|
|
- os.makedirs(self._filesystem_path)
|
|
|
-
|
|
|
- def set_mimetype(self, mimetype):
|
|
|
- self._create_dirs()
|
|
|
- with self.props as props:
|
|
|
- if "tag" not in props:
|
|
|
- if mimetype == "text/vcard":
|
|
|
- props["tag"] = "VADDRESSBOOK"
|
|
|
- else:
|
|
|
- props["tag"] = "VCALENDAR"
|
|
|
+ _, directories, files = next(os.walk(collection._filesystem_path))
|
|
|
+ for sub_path in directories + files:
|
|
|
+ full_path = os.path.join(collection._filesystem_path, sub_path)
|
|
|
+ if os.path.exists(path_to_filesystem(full_path)):
|
|
|
+ collection = cls(posixpath.join(path, sub_path))
|
|
|
+ yield collection
|
|
|
|
|
|
- @property
|
|
|
- def exists(self):
|
|
|
- """``True`` if the collection exists on the storage, else ``False``."""
|
|
|
- return self.is_node(self.path) or self.is_leaf(self.path)
|
|
|
-
|
|
|
- @staticmethod
|
|
|
- def _parse(text, item_types, name=None):
|
|
|
- """Find items with type in ``item_types`` in ``text``.
|
|
|
+ @classmethod
|
|
|
+ def create_collection(cls, href, collection=None, tag=None):
|
|
|
+ """Create a collection.
|
|
|
|
|
|
- If ``name`` is given, give this name to new items in ``text``.
|
|
|
+ ``collection`` is a list of vobject components.
|
|
|
|
|
|
- Return a dict of items.
|
|
|
+ ``tag`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
|
|
|
+ ``tag`` is not given, it is guessed from the collection.
|
|
|
|
|
|
"""
|
|
|
- item_tags = {item_type.tag: item_type for item_type in item_types}
|
|
|
- items = {}
|
|
|
- root = next(vobject.readComponents(text))
|
|
|
- components = (
|
|
|
- root.components() if root.name in ("VADDRESSBOOK", "VCALENDAR")
|
|
|
- else (root,))
|
|
|
- for component in components:
|
|
|
- item_name = None if component.name == "VTIMEZONE" else name
|
|
|
- item_type = item_tags[component.name]
|
|
|
- item = item_type(component.serialize(), item_name)
|
|
|
- if item.name in items:
|
|
|
- text = "\r\n".join((item.text, items[item.name].text))
|
|
|
- items[item.name] = item_type(text, item.name)
|
|
|
- else:
|
|
|
- items[item.name] = item
|
|
|
-
|
|
|
- return items
|
|
|
+ path = path_to_filesystem(FOLDER, href)
|
|
|
+ if not os.path.exists(path):
|
|
|
+ os.makedirs(path)
|
|
|
+ if not tag and collection:
|
|
|
+ tag = collection[0].name
|
|
|
+ self = cls(href)
|
|
|
+ if tag == "VCALENDAR":
|
|
|
+ self.set_meta("tag", "VCALENDAR")
|
|
|
+ if collection:
|
|
|
+ collection, = collection
|
|
|
+ for content in ("vevent", "vtodo", "vjournal"):
|
|
|
+ if content in collection.contents:
|
|
|
+ for item in getattr(collection, "%s_list" % content):
|
|
|
+ new_collection = vobject.iCalendar()
|
|
|
+ new_collection.add(item)
|
|
|
+ self.upload(uuid4().hex, new_collection)
|
|
|
+ elif tag == "VCARD":
|
|
|
+ self.set_meta("tag", "VADDRESSBOOK")
|
|
|
+ if collection:
|
|
|
+ for card in collection:
|
|
|
+ self.upload(uuid4().hex, card)
|
|
|
+ return self
|
|
|
+
|
|
|
+ def list(self):
|
|
|
+ """List collection items."""
|
|
|
+ for href in os.listdir(self._filesystem_path):
|
|
|
+ path = os.path.join(self._filesystem_path, href)
|
|
|
+ if not href.endswith(".props") and os.path.isfile(path):
|
|
|
+ with open(path, encoding=STORAGE_ENCODING) as fd:
|
|
|
+ yield href, get_etag(fd.read())
|
|
|
|
|
|
- def save(self, text):
|
|
|
- self._create_dirs()
|
|
|
- item_types = (Timezone, Event, Todo, Journal, Card)
|
|
|
- for name, component in self._parse(text, item_types).items():
|
|
|
- if not is_safe_filesystem_path_component(name):
|
|
|
- log.LOGGER.debug(
|
|
|
- "Can't tranlate name safely to filesystem, "
|
|
|
- "skipping component: %s", name)
|
|
|
- continue
|
|
|
- filename = os.path.join(self._filesystem_path, name)
|
|
|
- with open(filename, "w", encoding=STORAGE_ENCODING) as fd:
|
|
|
- fd.write(component.text)
|
|
|
+ def get(self, href):
|
|
|
+ """Fetch a single item."""
|
|
|
+ if not href:
|
|
|
+ return
|
|
|
+ href = href.strip("{}").replace("/", "_")
|
|
|
+ if is_safe_filesystem_path_component(href):
|
|
|
+ path = os.path.join(self._filesystem_path, href)
|
|
|
+ if os.path.isfile(path):
|
|
|
+ with open(path, encoding=STORAGE_ENCODING) as fd:
|
|
|
+ text = fd.read()
|
|
|
+ return Item(vobject.readOne(text), href, get_etag(text))
|
|
|
+ else:
|
|
|
+ log.LOGGER.debug(
|
|
|
+ "Can't tranlate name safely to filesystem, "
|
|
|
+ "skipping component: %s", href)
|
|
|
|
|
|
- @property
|
|
|
- def headers(self):
|
|
|
- return (
|
|
|
- Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
|
|
|
- Header("VERSION:%s" % self.version))
|
|
|
+ def get_multi(self, hrefs):
|
|
|
+ """Fetch multiple items. Duplicate hrefs must be ignored.
|
|
|
|
|
|
- def delete(self):
|
|
|
- shutil.rmtree(self._filesystem_path)
|
|
|
- os.remove(self._props_path)
|
|
|
+ Functionally similar to ``get``, but might bring performance benefits
|
|
|
+ on some storages when used cleverly.
|
|
|
|
|
|
- def remove(self, name):
|
|
|
- if not is_safe_filesystem_path_component(name):
|
|
|
+ """
|
|
|
+ for href in set(hrefs):
|
|
|
+ yield self.get(href)
|
|
|
+
|
|
|
+ def has(self, href):
|
|
|
+ """Check if an item exists by its href."""
|
|
|
+ return self.get(href) is not None
|
|
|
+
|
|
|
+ def upload(self, href, item):
|
|
|
+ """Upload a new item."""
|
|
|
+ # TODO: use returned object in code
|
|
|
+ if is_safe_filesystem_path_component(href):
|
|
|
+ path = path_to_filesystem(self._filesystem_path, href)
|
|
|
+ if not os.path.exists(path):
|
|
|
+ text = item.serialize()
|
|
|
+ with open(path, "w", encoding=STORAGE_ENCODING) as fd:
|
|
|
+ fd.write(text)
|
|
|
+ return href, get_etag(text)
|
|
|
+ else:
|
|
|
log.LOGGER.debug(
|
|
|
"Can't tranlate name safely to filesystem, "
|
|
|
- "skipping component: %s", name)
|
|
|
- return
|
|
|
- if name in self.items:
|
|
|
- del self.items[name]
|
|
|
- filesystem_path = os.path.join(self._filesystem_path, name)
|
|
|
- if os.path.exists(filesystem_path):
|
|
|
- os.remove(filesystem_path)
|
|
|
-
|
|
|
- @property
|
|
|
- def text(self):
|
|
|
- components = (Timezone, Event, Todo, Journal, Card)
|
|
|
- items = {}
|
|
|
- try:
|
|
|
- filenames = os.listdir(self._filesystem_path)
|
|
|
- except (OSError, IOError) as e:
|
|
|
- log.LOGGER.info(
|
|
|
- "Error while reading collection %r: %r" % (
|
|
|
- self._filesystem_path, e))
|
|
|
- return ""
|
|
|
-
|
|
|
- for filename in filenames:
|
|
|
- path = os.path.join(self._filesystem_path, filename)
|
|
|
- try:
|
|
|
+ "skipping component: %s", href)
|
|
|
+
|
|
|
+ def update(self, href, item, etag=None):
|
|
|
+ """Update an item."""
|
|
|
+ # TODO: use etag in code and test it here
|
|
|
+ # TODO: use returned object in code
|
|
|
+ if is_safe_filesystem_path_component(href):
|
|
|
+ path = path_to_filesystem(self._filesystem_path, href)
|
|
|
+ if os.path.exists(path):
|
|
|
with open(path, encoding=STORAGE_ENCODING) as fd:
|
|
|
- items.update(self._parse(fd.read(), components))
|
|
|
- except (OSError, IOError) as e:
|
|
|
- log.LOGGER.warning(
|
|
|
- "Error while reading item %r: %r" % (path, e))
|
|
|
+ text = fd.read()
|
|
|
+ if not etag or etag == get_etag(text):
|
|
|
+ new_text = item.serialize()
|
|
|
+ with open(path, "w", encoding=STORAGE_ENCODING) as fd:
|
|
|
+ fd.write(new_text)
|
|
|
+ return get_etag(new_text)
|
|
|
+ else:
|
|
|
+ log.LOGGER.debug(
|
|
|
+ "Can't tranlate name safely to filesystem, "
|
|
|
+ "skipping component: %s", href)
|
|
|
|
|
|
- return serialize(
|
|
|
- self.tag, self.headers, sorted(items.values(), key=lambda x: x.name))
|
|
|
+ def delete(self, href=None, etag=None):
|
|
|
+ """Delete an item.
|
|
|
|
|
|
- @classmethod
|
|
|
- def children(cls, path):
|
|
|
- filesystem_path = path_to_filesystem(path)
|
|
|
- _, directories, files = next(os.walk(filesystem_path))
|
|
|
- for path in directories + files:
|
|
|
- # Check that the local path can be translated into an internal path
|
|
|
- if not path or posixpath.split(path)[0] or path in (".", ".."):
|
|
|
- log.LOGGER.debug("Skipping unsupported filename: %s", path)
|
|
|
- continue
|
|
|
- relative_path = posixpath.join(path, path)
|
|
|
- if cls.is_node(relative_path) or cls.is_leaf(relative_path):
|
|
|
- yield cls(relative_path)
|
|
|
+ When ``href`` is ``None``, delete the collection.
|
|
|
|
|
|
- @classmethod
|
|
|
- def is_node(cls, path):
|
|
|
- filesystem_path = path_to_filesystem(path)
|
|
|
- return (
|
|
|
- os.path.isdir(filesystem_path) and
|
|
|
- not os.path.exists(filesystem_path + ".props"))
|
|
|
+ """
|
|
|
+ # TODO: use etag in code and test it here
|
|
|
+ # TODO: use returned object in code
|
|
|
+ if href is None:
|
|
|
+ # Delete the collection
|
|
|
+ if os.path.isdir(self._filesystem_path):
|
|
|
+ shutil.rmtree(self._filesystem_path)
|
|
|
+ props_path = self._filesystem_path + ".props"
|
|
|
+ if os.path.isfile(props_path):
|
|
|
+ os.remove(props_path)
|
|
|
+ return
|
|
|
+ elif is_safe_filesystem_path_component(href):
|
|
|
+ # Delete an item
|
|
|
+ path = path_to_filesystem(self._filesystem_path, href)
|
|
|
+ if os.path.isfile(path):
|
|
|
+ with open(path, encoding=STORAGE_ENCODING) as fd:
|
|
|
+ text = fd.read()
|
|
|
+ if not etag or etag == get_etag(text):
|
|
|
+ os.remove(path)
|
|
|
+ return
|
|
|
+ else:
|
|
|
+ log.LOGGER.debug(
|
|
|
+ "Can't tranlate name safely to filesystem, "
|
|
|
+ "skipping component: %s", href)
|
|
|
|
|
|
- @classmethod
|
|
|
- def is_leaf(cls, path):
|
|
|
- filesystem_path = path_to_filesystem(path)
|
|
|
- return (
|
|
|
- os.path.isdir(filesystem_path) and
|
|
|
- os.path.exists(filesystem_path + ".props"))
|
|
|
+ @contextmanager
|
|
|
+ def at_once(self):
|
|
|
+ """Set a context manager buffering the reads and writes."""
|
|
|
+ # TODO: use in code
|
|
|
+ # TODO: use a file locker
|
|
|
+ yield
|
|
|
+
|
|
|
+ def get_meta(self, key):
|
|
|
+ """Get metadata value for collection."""
|
|
|
+ props_path = self._filesystem_path + ".props"
|
|
|
+ if os.path.exists(props_path):
|
|
|
+ with open(props_path, encoding=STORAGE_ENCODING) as prop_file:
|
|
|
+ return json.load(prop_file).get(key)
|
|
|
+
|
|
|
+ def set_meta(self, key, value):
|
|
|
+ """Get metadata value for collection."""
|
|
|
+ props_path = self._filesystem_path + ".props"
|
|
|
+ properties = {}
|
|
|
+ if os.path.exists(props_path):
|
|
|
+ with open(props_path, encoding=STORAGE_ENCODING) as prop_file:
|
|
|
+ properties.update(json.load(prop_file))
|
|
|
+ properties[key] = value
|
|
|
+ with open(props_path, "w", encoding=STORAGE_ENCODING) as prop_file:
|
|
|
+ json.dump(properties, prop_file)
|
|
|
|
|
|
@property
|
|
|
def last_modified(self):
|
|
|
+ """Get the HTTP-datetime of when the collection was modified."""
|
|
|
last = max([
|
|
|
os.path.getmtime(os.path.join(self._filesystem_path, filename))
|
|
|
for filename in os.listdir(self._filesystem_path)] or [0])
|
|
|
return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(last))
|
|
|
|
|
|
- @property
|
|
|
- @contextmanager
|
|
|
- def props(self):
|
|
|
- # On enter
|
|
|
- properties = {}
|
|
|
- if os.path.exists(self._props_path):
|
|
|
- with open(self._props_path) as prop_file:
|
|
|
- properties.update(json.load(prop_file))
|
|
|
- old_properties = properties.copy()
|
|
|
- yield properties
|
|
|
- # On exit
|
|
|
- if old_properties != properties:
|
|
|
- with open(self._props_path, "w") as prop_file:
|
|
|
- json.dump(properties, prop_file)
|
|
|
-
|
|
|
- def append(self, name, text):
|
|
|
- """Append items from ``text`` to collection.
|
|
|
-
|
|
|
- If ``name`` is given, give this name to new items in ``text``.
|
|
|
-
|
|
|
- """
|
|
|
- new_items = self._parse(
|
|
|
- text, (Timezone, Event, Todo, Journal, Card), name)
|
|
|
- for new_item in new_items.values():
|
|
|
- if new_item.name not in self.items:
|
|
|
- self.items[new_item.name] = new_item
|
|
|
- self.write()
|
|
|
-
|
|
|
- def replace(self, name, text):
|
|
|
- """Replace content by ``text`` in collection objet called ``name``."""
|
|
|
- self.remove(name)
|
|
|
- self.append(name, text)
|
|
|
-
|
|
|
- def write(self):
|
|
|
- """Write collection with given parameters."""
|
|
|
- text = serialize(self.tag, self.headers, self.items.values())
|
|
|
- self.save(text)
|
|
|
-
|
|
|
- @property
|
|
|
- def tag(self):
|
|
|
- """Type of the collection."""
|
|
|
- with self.props as props:
|
|
|
- if "tag" not in props:
|
|
|
- try:
|
|
|
- tag = open(self.path).readlines()[0][6:].rstrip()
|
|
|
- except IOError:
|
|
|
- if self.path.endswith((".vcf", "/carddav")):
|
|
|
- props["tag"] = "VADDRESSBOOK"
|
|
|
- else:
|
|
|
- props["tag"] = "VCALENDAR"
|
|
|
- else:
|
|
|
- if tag in ("VADDRESSBOOK", "VCARD"):
|
|
|
- props["tag"] = "VADDRESSBOOK"
|
|
|
- else:
|
|
|
- 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"
|
|
|
+ def serialize(self):
|
|
|
+ items = []
|
|
|
+ for href in os.listdir(self._filesystem_path):
|
|
|
+ path = os.path.join(self._filesystem_path, href)
|
|
|
+ if os.path.isfile(path):
|
|
|
+ with open(path, encoding=STORAGE_ENCODING) as fd:
|
|
|
+ items.append(vobject.readOne(fd.read()))
|
|
|
+ if self.get_meta("tag") == "VCALENDAR":
|
|
|
+ collection = vobject.iCalendar()
|
|
|
+ for item in items:
|
|
|
+ for content in ("vevent", "vtodo", "vjournal"):
|
|
|
+ if content in item.contents:
|
|
|
+ collection.add(getattr(item, content))
|
|
|
+ break
|
|
|
+ return collection.serialize()
|
|
|
+ elif self.get_meta("tag") == "VADDRESSBOOK":
|
|
|
+ return "".join([item.serialize() for item in items])
|
|
|
|
|
|
@property
|
|
|
def etag(self):
|
|
|
- """Etag from collection."""
|
|
|
- etag = md5()
|
|
|
- etag.update(self.text.encode("utf-8"))
|
|
|
- return '"%s"' % etag.hexdigest()
|
|
|
-
|
|
|
- @property
|
|
|
- def name(self):
|
|
|
- """Collection name."""
|
|
|
- with self.props as props:
|
|
|
- return props.get("D:displayname", self.path.split(os.path.sep)[-1])
|
|
|
-
|
|
|
- @property
|
|
|
- def color(self):
|
|
|
- """Collection color."""
|
|
|
- with self.props as props:
|
|
|
- if "ICAL:calendar-color" not in props:
|
|
|
- props["ICAL:calendar-color"] = "#%x" % randint(0, 255 ** 3 - 1)
|
|
|
- return props["ICAL:calendar-color"]
|
|
|
-
|
|
|
- @property
|
|
|
- def items(self):
|
|
|
- """Get list of all items in collection."""
|
|
|
- if self._items is None:
|
|
|
- self._items = self._parse(
|
|
|
- self.text, (Event, Todo, Journal, Card, Timezone))
|
|
|
- return self._items
|
|
|
-
|
|
|
- @property
|
|
|
- def timezones(self):
|
|
|
- """Get list of all timezones in collection."""
|
|
|
- return [
|
|
|
- item for item in self.items.values() if item.tag == Timezone.tag]
|
|
|
-
|
|
|
- @property
|
|
|
- def components(self):
|
|
|
- """Get list of all components in collection."""
|
|
|
- tags = [item_type.tag for item_type in (Event, Todo, Journal, Card)]
|
|
|
- return [item for item in self.items.values() if item.tag in tags]
|
|
|
-
|
|
|
- @property
|
|
|
- def owner_url(self):
|
|
|
- """Get the collection URL according to its owner."""
|
|
|
- return "/%s/" % self.owner if self.owner else None
|
|
|
-
|
|
|
- @property
|
|
|
- def url(self):
|
|
|
- """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"
|
|
|
+ return get_etag(self.serialize())
|