ical.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. # -*- coding: utf-8 -*-
  2. #
  3. # This file is part of Radicale Server - Calendar Server
  4. # Copyright © 2008-2011 Guillaume Ayoub
  5. # Copyright © 2008 Nicolas Kandel
  6. # Copyright © 2008 Pascal Halter
  7. #
  8. # This library is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation, either version 3 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  20. """
  21. Radicale calendar classes.
  22. Define the main classes of a calendar as seen from the server.
  23. """
  24. import codecs
  25. from contextlib import contextmanager
  26. import json
  27. import os
  28. import posixpath
  29. import time
  30. import uuid
  31. from radicale import config
  32. FOLDER = os.path.expanduser(config.get("storage", "folder"))
  33. # This function overrides the builtin ``open`` function for this module
  34. # pylint: disable=W0622
  35. def open(path, mode="r"):
  36. """Open file at ``path`` with ``mode``, automagically managing encoding."""
  37. return codecs.open(path, mode, config.get("encoding", "stock"))
  38. # pylint: enable=W0622
  39. def serialize(tag, headers=(), items=(), whole=False):
  40. """Return a text corresponding to given collection ``tag``.
  41. The text may have the given ``headers`` and ``items`` added around the
  42. items if needed (ie. for calendars).
  43. If ``whole`` is ``True``, the collection tags and headers are added, even
  44. for address books.
  45. """
  46. if tag == "VADDRESSBOOK" and not whole:
  47. lines = [items[0].text]
  48. else:
  49. lines = ["BEGIN:%s" % tag]
  50. for part in (headers, items):
  51. if part:
  52. lines.append("\n".join(item.text for item in part))
  53. lines.append("END:%s\n" % tag)
  54. return "\n".join(lines)
  55. def unfold(text):
  56. """Unfold multi-lines attributes.
  57. Read rfc5545-3.1 for info.
  58. """
  59. lines = []
  60. for line in text.splitlines():
  61. if lines and (line.startswith(" ") or line.startswith("\t")):
  62. lines[-1] += line[1:]
  63. else:
  64. lines.append(line)
  65. return lines
  66. class Item(object):
  67. """Internal iCal item."""
  68. def __init__(self, text, name=None):
  69. """Initialize object from ``text`` and different ``kwargs``."""
  70. self.text = text
  71. self._name = name
  72. # We must synchronize the name in the text and in the object.
  73. # An item must have a name, determined in order by:
  74. #
  75. # - the ``name`` parameter
  76. # - the ``X-RADICALE-NAME`` iCal property (for Events, Todos, Journals)
  77. # - the ``UID`` iCal property (for Events, Todos, Journals)
  78. # - the ``TZID`` iCal property (for Timezones)
  79. if not self._name:
  80. for line in unfold(self.text):
  81. if line.startswith("X-RADICALE-NAME:"):
  82. self._name = line.replace("X-RADICALE-NAME:", "").strip()
  83. break
  84. elif line.startswith("TZID:"):
  85. self._name = line.replace("TZID:", "").strip()
  86. break
  87. elif line.startswith("UID:"):
  88. self._name = line.replace("UID:", "").strip()
  89. # Do not break, a ``X-RADICALE-NAME`` can appear next
  90. if self._name:
  91. if "\nX-RADICALE-NAME:" in text:
  92. for line in unfold(self.text):
  93. if line.startswith("X-RADICALE-NAME:"):
  94. self.text = self.text.replace(
  95. line, "X-RADICALE-NAME:%s" % self._name)
  96. else:
  97. self.text = self.text.replace(
  98. "\nEND:", "\nX-RADICALE-NAME:%s\nEND:" % self._name)
  99. else:
  100. self._name = str(uuid.uuid4())
  101. self.text = self.text.replace(
  102. "\nEND:", "\nX-RADICALE-NAME:%s\nEND:" % self._name)
  103. @property
  104. def etag(self):
  105. """Item etag.
  106. Etag is mainly used to know if an item has changed.
  107. """
  108. return '"%s"' % hash(self.text)
  109. @property
  110. def name(self):
  111. """Item name.
  112. Name is mainly used to give an URL to the item.
  113. """
  114. return self._name
  115. class Header(Item):
  116. """Internal header class."""
  117. class Timezone(Item):
  118. """Internal timezone class."""
  119. tag = "VTIMEZONE"
  120. class Component(Item):
  121. """Internal main component of a collection."""
  122. class Event(Component):
  123. """Internal event class."""
  124. tag = "VEVENT"
  125. mimetype = "text/calendar"
  126. class Todo(Component):
  127. """Internal todo class."""
  128. tag = "VTODO" # pylint: disable=W0511
  129. mimetype = "text/calendar"
  130. class Journal(Component):
  131. """Internal journal class."""
  132. tag = "VJOURNAL"
  133. mimetype = "text/calendar"
  134. class Card(Component):
  135. """Internal card class."""
  136. tag = "VCARD"
  137. mimetype = "text/vcard"
  138. class Collection(object):
  139. """Internal collection item."""
  140. def __init__(self, path, principal=False):
  141. """Initialize the collection.
  142. ``path`` must be the normalized relative path of the collection, using
  143. the slash as the folder delimiter, with no leading nor trailing slash.
  144. """
  145. self.encoding = "utf-8"
  146. split_path = path.split("/")
  147. self.path = os.path.join(FOLDER, path.replace("/", os.sep))
  148. self.props_path = self.path + '.props'
  149. if principal and split_path and os.path.isdir(self.path):
  150. # Already existing principal collection
  151. self.owner = split_path[0]
  152. elif len(split_path) > 1:
  153. # URL with at least one folder
  154. self.owner = split_path[0]
  155. else:
  156. self.owner = None
  157. self.local_path = path if path != '.' else ''
  158. self.is_principal = principal
  159. @classmethod
  160. def from_path(cls, path, depth="infinite", include_container=True):
  161. """Return a list of collections and items under the given ``path``.
  162. If ``depth`` is "0", only the actual object under ``path`` is
  163. returned. Otherwise, also sub-items are appended to the result. If
  164. ``include_container`` is ``True`` (the default), the containing object
  165. is included in the result.
  166. The ``path`` is relative to the storage folder.
  167. """
  168. # First do normpath and then strip, to prevent access to FOLDER/../
  169. sane_path = posixpath.normpath(path.replace(os.sep, "/")).strip("/")
  170. attributes = sane_path.split("/")
  171. if not attributes:
  172. return None
  173. if not (os.path.isfile(os.path.join(FOLDER, *attributes)) or
  174. path.endswith("/")):
  175. attributes.pop()
  176. result = []
  177. path = "/".join(attributes)
  178. abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
  179. principal = len(attributes) <= 1
  180. if os.path.isdir(abs_path):
  181. if depth == "0":
  182. result.append(cls(path, principal))
  183. else:
  184. if include_container:
  185. result.append(cls(path, principal))
  186. try:
  187. for filename in next(os.walk(abs_path))[2]:
  188. if not filename.endswith(".props"):
  189. collection = cls(os.path.join(path, filename))
  190. if collection.exists:
  191. result.append(collection)
  192. except StopIteration:
  193. # Directory does not exist yet
  194. pass
  195. else:
  196. if depth == "0":
  197. result.append(cls(path))
  198. else:
  199. collection = cls(path, principal)
  200. if include_container:
  201. result.append(collection)
  202. result.extend(collection.components)
  203. return result
  204. @property
  205. def exists(self):
  206. """Return ``True`` if there is a collection file exists."""
  207. beginning_string = 'BEGIN:%s' % self.tag
  208. with open(self.path) as stream:
  209. return beginning_string == stream.read(len(beginning_string))
  210. @property
  211. def items(self):
  212. """Get list of all items in collection."""
  213. return self._parse(self.text, (Card, Event, Todo, Journal, Timezone))
  214. @property
  215. def components(self):
  216. """Get list of all components in collection."""
  217. return self._parse(self.text, (Card, Event, Todo, Journal))
  218. @property
  219. def events(self):
  220. """Get list of ``Event`` items in collection."""
  221. return self._parse(self.text, (Event,))
  222. @property
  223. def cards(self):
  224. """Get list of all cards in collection."""
  225. return self._parse(self.text, (Card,))
  226. @property
  227. def todos(self):
  228. """Get list of ``Todo`` items in collection."""
  229. return self._parse(self.text, (Todo,))
  230. @property
  231. def journals(self):
  232. """Get list of ``Journal`` items in collection."""
  233. return self._parse(self.text, (Journal,))
  234. @property
  235. def timezones(self):
  236. """Get list of ``Timezome`` items in collection."""
  237. return self._parse(self.text, (Timezone,))
  238. @staticmethod
  239. def _parse(text, item_types, name=None):
  240. """Find items with type in ``item_types`` in ``text``.
  241. If ``name`` is given, give this name to new items in ``text``.
  242. Return a list of items.
  243. """
  244. item_tags = {}
  245. for item_type in item_types:
  246. item_tags[item_type.tag] = item_type
  247. items = {}
  248. lines = unfold(text)
  249. in_item = False
  250. for line in lines:
  251. if line.startswith("BEGIN:") and not in_item:
  252. item_tag = line.replace("BEGIN:", "").strip()
  253. if item_tag in item_tags:
  254. in_item = True
  255. item_lines = []
  256. if in_item:
  257. item_lines.append(line)
  258. if line.startswith("END:%s" % item_tag):
  259. in_item = False
  260. item_type = item_tags[item_tag]
  261. item_text = "\n".join(item_lines)
  262. item_name = None if item_tag == "VTIMEZONE" else name
  263. item = item_type(item_text, item_name)
  264. if item.name in items:
  265. text = "\n".join((item.text, items[item.name].text))
  266. items[item.name] = item_type(text, item.name)
  267. else:
  268. items[item.name] = item
  269. return list(items.values())
  270. def get_item(self, name):
  271. """Get calendar item called ``name``."""
  272. for item in self.items:
  273. if item.name == name:
  274. return item
  275. def append(self, name, text):
  276. """Append items from ``text`` to calendar.
  277. If ``name`` is given, give this name to new items in ``text``.
  278. """
  279. items = self.items
  280. for new_item in self._parse(
  281. text, (Timezone, Event, Todo, Journal, Card), name):
  282. if new_item.name not in (item.name for item in items):
  283. items.append(new_item)
  284. self.write(items=items)
  285. def delete(self):
  286. """Delete the calendar."""
  287. os.remove(self.path)
  288. os.remove(self.props_path)
  289. def remove(self, name):
  290. """Remove object named ``name`` from calendar."""
  291. components = [
  292. component for component in self.components
  293. if component.name != name]
  294. items = self.timezones + components
  295. self.write(items=items)
  296. def replace(self, name, text):
  297. """Replace content by ``text`` in objet named ``name`` in calendar."""
  298. self.remove(name)
  299. self.append(name, text)
  300. def write(self, headers=None, items=None):
  301. """Write calendar with given parameters."""
  302. headers = headers or self.headers or (
  303. Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
  304. Header("VERSION:%s" % self.version))
  305. items = items if items is not None else self.items
  306. self._create_dirs(self.path)
  307. text = serialize(self.tag, headers, items, True)
  308. return open(self.path, "w").write(text)
  309. def set_mimetype(self, mimetype):
  310. """Set the mimetype of the collection."""
  311. with self.props as props:
  312. if "tag" not in props:
  313. if mimetype == "text/vcard":
  314. props["tag"] = "VADDRESSBOOK"
  315. else:
  316. props["tag"] = "VCALENDAR"
  317. @staticmethod
  318. def _create_dirs(path):
  319. """Create folder if absent."""
  320. if not os.path.exists(os.path.dirname(path)):
  321. os.makedirs(os.path.dirname(path))
  322. @property
  323. def tag(self):
  324. """Type of the collection."""
  325. with self.props as props:
  326. if "tag" not in props:
  327. try:
  328. props["tag"] = open(self.path).readlines()[0][6:].rstrip()
  329. except IOError:
  330. props["tag"] = "VCALENDAR"
  331. return props["tag"]
  332. @property
  333. def mimetype(self):
  334. """Mimetype of the collection."""
  335. if self.tag == "VADDRESSBOOK":
  336. return "text/vcard"
  337. elif self.tag == "VCALENDAR":
  338. return "text/calendar"
  339. @property
  340. def resource_type(self):
  341. """Resource type of the collection."""
  342. if self.tag == "VADDRESSBOOK":
  343. return "addressbook"
  344. elif self.tag == "VCALENDAR":
  345. return "calendar"
  346. @property
  347. def etag(self):
  348. """Etag from collection."""
  349. return '"%s"' % hash(self.text)
  350. @property
  351. def name(self):
  352. """Collection name."""
  353. with self.props as props:
  354. return props.get('D:displayname',
  355. self.path.split(os.path.sep)[-1])
  356. @property
  357. def text(self):
  358. """Collection as plain text."""
  359. try:
  360. return open(self.path).read()
  361. except IOError:
  362. return ""
  363. @property
  364. def headers(self):
  365. """Find headers items in collection."""
  366. header_lines = []
  367. lines = unfold(self.text)
  368. for header in ("PRODID", "VERSION"):
  369. for line in lines:
  370. if line.startswith("%s:" % header):
  371. header_lines.append(Header(line))
  372. break
  373. return header_lines
  374. @property
  375. def last_modified(self):
  376. """Get the last time the collection has been modified.
  377. The date is formatted according to rfc1123-5.2.14.
  378. """
  379. # Create calendar if needed
  380. if not os.path.exists(self.path):
  381. self.write()
  382. modification_time = time.gmtime(os.path.getmtime(self.path))
  383. return time.strftime("%a, %d %b %Y %H:%M:%S +0000", modification_time)
  384. @property
  385. @contextmanager
  386. def props(self):
  387. """Get the collection properties."""
  388. # On enter
  389. properties = {}
  390. if os.path.exists(self.props_path):
  391. with open(self.props_path) as prop_file:
  392. properties.update(json.load(prop_file))
  393. yield properties
  394. # On exit
  395. self._create_dirs(self.props_path)
  396. with open(self.props_path, 'w') as prop_file:
  397. json.dump(properties, prop_file)
  398. @property
  399. def owner_url(self):
  400. """Get the collection URL according to its owner."""
  401. if self.owner:
  402. return "/%s/" % self.owner
  403. else:
  404. return None
  405. @property
  406. def url(self):
  407. """Get the standard collection URL."""
  408. return "/%s/" % self.local_path
  409. @property
  410. def version(self):
  411. """Get the version of the collection type."""
  412. return "3.0" if self.tag == "VADDRESSBOOK" else "2.0"