ical.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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(headers=(), items=()):
  40. """Return an iCal text corresponding to given ``headers`` and ``items``."""
  41. lines = ["BEGIN:VCALENDAR"]
  42. for part in (headers, items):
  43. if part:
  44. lines.append("\n".join(item.text for item in part))
  45. lines.append("END:VCALENDAR\n")
  46. return "\n".join(lines)
  47. def unfold(text):
  48. """Unfold multi-lines attributes.
  49. Read rfc5545-3.1 for info.
  50. """
  51. lines = []
  52. for line in text.splitlines():
  53. if lines and (line.startswith(" ") or line.startswith("\t")):
  54. lines[-1] += line[1:]
  55. else:
  56. lines.append(line)
  57. return lines
  58. class Item(object):
  59. """Internal iCal item."""
  60. def __init__(self, text, name=None):
  61. """Initialize object from ``text`` and different ``kwargs``."""
  62. self.text = text
  63. self._name = name
  64. # We must synchronize the name in the text and in the object.
  65. # An item must have a name, determined in order by:
  66. #
  67. # - the ``name`` parameter
  68. # - the ``X-RADICALE-NAME`` iCal property (for Events, Todos, Journals)
  69. # - the ``UID`` iCal property (for Events, Todos, Journals)
  70. # - the ``TZID`` iCal property (for Timezones)
  71. if not self._name:
  72. for line in unfold(self.text):
  73. if line.startswith("X-RADICALE-NAME:"):
  74. self._name = line.replace("X-RADICALE-NAME:", "").strip()
  75. break
  76. elif line.startswith("TZID:"):
  77. self._name = line.replace("TZID:", "").strip()
  78. break
  79. elif line.startswith("UID:"):
  80. self._name = line.replace("UID:", "").strip()
  81. # Do not break, a ``X-RADICALE-NAME`` can appear next
  82. if self._name:
  83. if "\nX-RADICALE-NAME:" in text:
  84. for line in unfold(self.text):
  85. if line.startswith("X-RADICALE-NAME:"):
  86. self.text = self.text.replace(
  87. line, "X-RADICALE-NAME:%s" % self._name)
  88. else:
  89. self.text = self.text.replace(
  90. "\nUID:", "\nX-RADICALE-NAME:%s\nUID:" % self._name)
  91. else:
  92. self._name = str(uuid.uuid4())
  93. self.text = self.text.replace(
  94. "\nEND:", "\nUID:%s\nEND:" % self._name)
  95. @property
  96. def etag(self):
  97. """Item etag.
  98. Etag is mainly used to know if an item has changed.
  99. """
  100. return '"%s"' % hash(self.text)
  101. @property
  102. def name(self):
  103. """Item name.
  104. Name is mainly used to give an URL to the item.
  105. """
  106. return self._name
  107. class Header(Item):
  108. """Internal header class."""
  109. class Event(Item):
  110. """Internal event class."""
  111. tag = "VEVENT"
  112. class Todo(Item):
  113. """Internal todo class."""
  114. # This is not a TODO!
  115. # pylint: disable=W0511
  116. tag = "VTODO"
  117. # pylint: enable=W0511
  118. class Journal(Item):
  119. """Internal journal class."""
  120. tag = "VJOURNAL"
  121. class Timezone(Item):
  122. """Internal timezone class."""
  123. tag = "VTIMEZONE"
  124. class Calendar(object):
  125. """Internal calendar class."""
  126. tag = "VCALENDAR"
  127. def __init__(self, path, principal=False):
  128. """Initialize the calendar with ``cal`` and ``user`` parameters."""
  129. self.encoding = "utf-8"
  130. split_path = path.split("/")
  131. self.owner = split_path[0] if len(split_path) > 1 else None
  132. self.path = os.path.join(FOLDER, path.replace("/", os.sep))
  133. self.local_path = path if path != '.' else ''
  134. self.is_principal = principal
  135. @classmethod
  136. def from_path(cls, path, depth="infinite", include_container=True):
  137. """Return a list of calendars and items under the given ``path``.
  138. If ``depth`` is "0", only the actual object under ``path`` is
  139. returned. Otherwise, also sub-items are appended to the result. If
  140. ``include_container`` is ``True`` (the default), the containing object
  141. is included in the result.
  142. The ``path`` is relative to the storage folder.
  143. """
  144. attributes = posixpath.normpath(path.strip("/")).split("/")
  145. if not attributes:
  146. return None
  147. if attributes[-1].endswith(".ics"):
  148. attributes.pop()
  149. result = []
  150. path = "/".join(attributes[:min(len(attributes), 2)])
  151. path = path.replace("/", os.sep)
  152. abs_path = os.path.join(FOLDER, path)
  153. if os.path.isdir(abs_path) or len(attributes) == 1:
  154. if depth == "0":
  155. result.append(cls(path, principal=True))
  156. else:
  157. if include_container:
  158. result.append(cls(path, principal=True))
  159. try:
  160. for filename in next(os.walk(abs_path))[2]:
  161. file_path = os.path.join(path, filename)
  162. if cls.is_vcalendar(os.path.join(abs_path, filename)):
  163. result.append(cls(file_path))
  164. except StopIteration:
  165. # directory does not exist yet
  166. pass
  167. else:
  168. calendar = cls(path)
  169. if depth == "0":
  170. result.append(calendar)
  171. else:
  172. if include_container:
  173. result.append(calendar)
  174. result.extend(calendar.components)
  175. return result
  176. @staticmethod
  177. def is_vcalendar(path):
  178. """Return ``True`` if there is a VCALENDAR file under ``path``."""
  179. with open(path) as stream:
  180. return 'BEGIN:VCALENDAR' == stream.read(15)
  181. @staticmethod
  182. def _parse(text, item_types, name=None):
  183. """Find items with type in ``item_types`` in ``text``.
  184. If ``name`` is given, give this name to new items in ``text``.
  185. Return a list of items.
  186. """
  187. item_tags = {}
  188. for item_type in item_types:
  189. item_tags[item_type.tag] = item_type
  190. items = []
  191. lines = unfold(text)
  192. in_item = False
  193. for line in lines:
  194. if line.startswith("BEGIN:") and not in_item:
  195. item_tag = line.replace("BEGIN:", "").strip()
  196. if item_tag in item_tags:
  197. in_item = True
  198. item_lines = []
  199. if in_item:
  200. item_lines.append(line)
  201. if line.startswith("END:%s" % item_tag):
  202. in_item = False
  203. item_type = item_tags[item_tag]
  204. item_text = "\n".join(item_lines)
  205. item_name = None if item_tag == "VTIMEZONE" else name
  206. items.append(item_type(item_text, item_name))
  207. return items
  208. def get_item(self, name):
  209. """Get calendar item called ``name``."""
  210. for item in self.items:
  211. if item.name == name:
  212. return item
  213. def append(self, name, text):
  214. """Append items from ``text`` to calendar.
  215. If ``name`` is given, give this name to new items in ``text``.
  216. """
  217. items = self.items
  218. for new_item in self._parse(
  219. text, (Timezone, Event, Todo, Journal), name):
  220. if new_item.name not in (item.name for item in items):
  221. items.append(new_item)
  222. self.write(items=items)
  223. def remove(self, name):
  224. """Remove object named ``name`` from calendar."""
  225. components = [
  226. component for component in self.components
  227. if component.name != name]
  228. items = self.timezones + components
  229. self.write(items=items)
  230. def replace(self, name, text):
  231. """Replace content by ``text`` in objet named ``name`` in calendar."""
  232. self.remove(name)
  233. self.append(name, text)
  234. def write(self, headers=None, items=None):
  235. """Write calendar with given parameters."""
  236. if self.is_principal:
  237. return
  238. headers = headers or self.headers or (
  239. Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
  240. Header("VERSION:2.0"))
  241. items = items if items is not None else self.items
  242. self._create_dirs(self.path)
  243. text = serialize(headers, items)
  244. return open(self.path, "w").write(text)
  245. @staticmethod
  246. def _create_dirs(path):
  247. """Create folder if absent."""
  248. if not os.path.exists(os.path.dirname(path)):
  249. os.makedirs(os.path.dirname(path))
  250. @property
  251. def etag(self):
  252. """Etag from calendar."""
  253. return '"%s"' % hash(self.text)
  254. @property
  255. def name(self):
  256. """Calendar name."""
  257. with self.props as props:
  258. return props.get('D:displayname',
  259. self.path.split(os.path.sep)[-1])
  260. @property
  261. def text(self):
  262. """Calendar as plain text."""
  263. try:
  264. return open(self.path).read()
  265. except IOError:
  266. return ""
  267. @property
  268. def headers(self):
  269. """Find headers items in calendar."""
  270. header_lines = []
  271. lines = unfold(self.text)
  272. for line in lines:
  273. if line.startswith("PRODID:"):
  274. header_lines.append(Header(line))
  275. for line in lines:
  276. if line.startswith("VERSION:"):
  277. header_lines.append(Header(line))
  278. return header_lines
  279. @property
  280. def items(self):
  281. """Get list of all items in calendar."""
  282. return self._parse(self.text, (Event, Todo, Journal, Timezone))
  283. @property
  284. def components(self):
  285. """Get list of all components in calendar."""
  286. return self._parse(self.text, (Event, Todo, Journal))
  287. @property
  288. def events(self):
  289. """Get list of ``Event`` items in calendar."""
  290. return self._parse(self.text, (Event,))
  291. @property
  292. def todos(self):
  293. """Get list of ``Todo`` items in calendar."""
  294. return self._parse(self.text, (Todo,))
  295. @property
  296. def journals(self):
  297. """Get list of ``Journal`` items in calendar."""
  298. return self._parse(self.text, (Journal,))
  299. @property
  300. def timezones(self):
  301. """Get list of ``Timezome`` items in calendar."""
  302. return self._parse(self.text, (Timezone,))
  303. @property
  304. def last_modified(self):
  305. """Get the last time the calendar has been modified.
  306. The date is formatted according to rfc1123-5.2.14.
  307. """
  308. # Create calendar if needed
  309. if not os.path.exists(self.path):
  310. self.write()
  311. modification_time = time.gmtime(os.path.getmtime(self.path))
  312. return time.strftime("%a, %d %b %Y %H:%M:%S +0000", modification_time)
  313. @property
  314. @contextmanager
  315. def props(self):
  316. """Get the calendar properties."""
  317. props_path = self.path + '.props'
  318. # On enter
  319. properties = {}
  320. if os.path.exists(props_path):
  321. with open(props_path) as prop_file:
  322. properties.update(json.load(prop_file))
  323. yield properties
  324. # On exit
  325. self._create_dirs(props_path)
  326. with open(props_path, 'w') as prop_file:
  327. json.dump(properties, prop_file)
  328. @property
  329. def owner_url(self):
  330. """Get the calendar URL according to its owner."""
  331. if self.owner:
  332. return ('/%s/' % self.owner).replace('//', '/')
  333. else:
  334. return None
  335. @property
  336. def url(self):
  337. """Get the standard calendar URL."""
  338. return ('/%s/' % self.local_path).replace('//', '/')