1 # -*- coding: utf-8 -*-
3 # This file is part of Calypso - CalDAV/CardDAV/WebDAV Server
4 # Copyright © 2008-2011 Guillaume Ayoub
5 # Copyright © 2008 Nicolas Kandel
6 # Copyright © 2008 Pascal Halter
7 # Copyright © 2011 Keith Packard
9 # This library is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
14 # This library is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
19 # You should have received a copy of the GNU General Public License
20 # along with Calypso. If not, see <http://www.gnu.org/licenses/>.
23 Calypso collection classes.
25 Define the main classes of a collection as seen from the server.
32 from dulwich import repo
42 from . import config, paths, acl
44 METADATA_FILENAME = ".calypso-collection"
47 # Recursive search for 'name' within 'vobject'
50 def find_vobject_value(vobject, name):
52 if vobject.name == name:
55 for child in vobject.getChildren():
56 value = find_vobject_value(child, name)
63 """Internal item. Wraps a vObject"""
65 def __init__(self, text, name=None, path=None):
66 """Initialize object from ``text`` and different ``kwargs``."""
68 self.log = logging.getLogger(__name__)
70 text = text.encode('utf8')
71 except UnicodeDecodeError:
72 text = text.decode('latin1').encode('utf-8')
74 # Strip out control characters
76 text = re.sub(r"[\x01-\x09\x0b-\x1F\x7F]","",text)
79 self.object = vobject.readOne(text)
81 self.log.exception("Parse error in %s %s", name, path)
85 if not self.object.contents.has_key('x-calypso-name'):
87 if self.object.name == 'VCARD' or self.object.name == 'VEVENT':
88 if not self.object.contents.has_key('uid'):
89 self.object.add('UID').value = hashlib.sha1(text).hexdigest()
90 name = self.object.uid.value
92 for child in self.object.getChildren():
93 if child.name == 'VEVENT' or child.name == 'VCARD':
94 if not child.contents.has_key('uid'):
95 child.add('UID').value = hashlib.sha1(text).hexdigest()
96 name = child.uid.value
99 name = hashlib.sha1(text).hexdigest()
101 self.object.add("X-CALYPSO-NAME").value = name
103 names = self.object.contents[u'x-calypso-name']
105 self.object.contents[u'x-calypso-name'] = [names[0]]
108 self.name = self.object.x_calypso_name.value
109 self.tag = self.object.name
110 self.etag = hashlib.sha1(text).hexdigest()
114 """Whether this item is a vcard entry"""
115 if self.object.name == 'VCARD':
117 if self.object.name == 'VEVENT':
119 if self.object.name == 'VCALENDAR':
121 for child in self.object.getChildren():
122 if child.name == 'VCARD':
124 if child.name == 'VEVENT':
130 """Whether this item is a vcal entry"""
131 if self.object.name == 'VCARD':
133 if self.object.name == 'VEVENT':
135 if self.object.name == 'VCALENDAR':
137 for child in self.object.getChildren():
138 if child.name == 'VCARD':
140 if child.name == 'VEVENT':
145 def file_prefix(self):
153 def file_extension(self):
164 Text is the serialized form of the item.
167 return self.object.serialize().decode('utf-8')
171 return "%d" % len(self.text)
174 def last_modified(self):
175 value = find_vobject_value(self.object, "LAST-MODIFIED")
177 return value.utctimetuple()
180 def __unicode__(self):
181 fn = self.object.getChildValue("fn")
185 if hasattr(self.object, "vevent"):
186 summary = self.object.vevent.getChildValue("summary")
187 dtstart = self.object.vevent.getChildValue("dtstart")
188 if summary and dtstart:
189 return "%s (%s)"%(summary, dtstart)
195 uid = self.object.vevent.getChildValue("uid")
199 uid = self.object.getChildValue("uid")
206 class Pathtime(object):
207 """Path name and timestamps"""
209 def __init__(self, path):
211 self.mtime = self.curmtime
215 return os.path.getmtime(self.path)
217 def is_up_to_date(self):
218 newmtime = self.curmtime
219 if newmtime == self.mtime:
221 self.mtime = newmtime
224 class CalypsoError(Exception):
225 def __init__(self, name, reason):
230 return "%s: %s" % (self.reason, self.file)
232 class Collection(acl.Entity):
233 """Internal collection class."""
235 def get_description(self):
237 return str(self.metadata.get('collection', 'description'))
238 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
242 f = codecs.open(os.path.join(self.path, ".git/description"), encoding='utf-8')
244 # .git/description is not present eg when the complete server is a single git repo
248 def read_file(self, path):
249 text = codecs.open(path,encoding='utf-8').read()
250 item = Item(text, None, path)
253 def insert_file(self, path):
255 item = self.read_file(path)
256 self.my_items.append(item)
257 except Exception, ex:
258 self.log.exception("Insert %s failed", path)
261 def remove_file(self, path):
263 for old_item in self.my_items:
264 if old_item.path == path:
265 old_items.append(old_item)
266 for old_item in old_items:
267 self.my_items.remove(old_item)
269 def scan_file(self, path):
270 self.remove_file(path)
271 self.insert_file(path)
273 __metadatafile = property(lambda self: os.path.join(self.path, METADATA_FILENAME))
275 def scan_metadata(self, force):
277 mtime = os.path.getmtime(self.__metadatafile)
282 if not force and mtime == self.mtime and self.metadata is not None:
285 parser = ConfigParser.RawConfigParser()
286 parser.read(self.__metadatafile)
287 self.metadata = parser
289 def scan_dir(self, force):
291 mtime = os.path.getmtime(self.path)
296 self.scan_metadata(force)
298 if not force and mtime == self.mtime:
300 self.log.debug("Scan %s", self.path)
302 filenames = os.listdir(self.path)
304 for filename in filenames:
305 if filename == METADATA_FILENAME:
307 filepath = os.path.join(self.path, filename)
308 for file in self.files:
309 if filepath == file.path:
310 newfiles.append(file)
311 if not file.is_up_to_date():
312 self.log.debug("Changed %s", filepath)
313 self.scan_file(filepath)
316 if os.path.isdir(filepath):
317 self.log.debug("Ignoring directory %s in scan_dir", filepath)
319 self.log.debug("New %s", filepath)
320 newfiles.append(Pathtime(filepath))
321 self.insert_file(filepath)
322 for file in self.files:
323 if not file.path in filenames:
324 self.log.debug("Removed %s", file.path)
325 self.remove_file(file.path)
327 for item in self.my_items:
329 self._ctag = '%d-' % self.mtime + h.hexdigest()
330 self.files = newfiles
332 def __init__(self, path):
333 """Initialize the collection with ``cal`` and ``user`` parameters."""
335 self.log = logging.getLogger(__name__)
336 self.encoding = "utf-8"
337 self.urlpath = paths.base_prefix() if path == "/" else paths.base_prefix() + path + "/" # a collections's path always end with / as recommended in rfc4918 as suggestion
338 self.owner = paths.url_to_owner(path)
339 self.path = paths.url_to_file(path)
344 self.etag = hashlib.sha1(self.path).hexdigest()
346 self.metadata_mtime = None
348 self.tag = "Collection"
350 self.repo = repo.Repo(self.path)
351 except repo.NotGitRepository:
355 return "Calendar-%s (at %s)" % (self.name, self.path)
358 return "<Calendar %s>" % (self.name)
360 def git_commit(self, action, context):
361 args = ["git", "commit", "--allow-empty"]
366 if 'user' in context:
367 author_name = context['user'] or "unknown"
368 author_email = "%s@webdav" % context['user']
370 author_name = author_email = None
372 if context["client_info"]:
374 for key, value in context["client_info"].iteritems():
375 message += u"%s: %s\n" % (key, value)
377 self.repo.commit(message=message.encode('utf-8'),
378 author=author_name, author_email=author_email)
380 def git_add(self, path, action, context):
381 if self.repo is None:
383 self.repo.stage([os.path.basename(path)])
384 self.git_commit(action, context)
386 def git_rm(self, path, action, context):
387 if self.repo is None:
389 self.repo.stage([os.path.basename(path)])
390 self.git_commit(action, context)
392 def git_change(self, path, action, context):
393 if self.repo is None:
395 self.repo.stage([os.path.basename(path)])
396 self.git_commit(action, context)
397 # Touch directory so that another running instance will update
399 os.utime(self.path, None)
400 except Exception, ex:
401 self.log.exception("Failed to set directory mtime")
403 def write_file(self, item):
404 fd, path = tempfile.mkstemp(item.file_extension, item.file_prefix, dir=self.path)
405 self.log.debug('Trying to write to %s', path)
406 file = os.fdopen(fd, 'w')
407 file.write(item.text.encode('utf-8'))
409 self.log.debug('Wrote %s to %s', file, path)
412 def create_file(self, item, context):
413 # Create directory if necessary
414 self.log.debug("Add %s", item.name)
415 if not os.path.exists(os.path.dirname(self.path)):
417 os.makedirs(os.path.dirname(self.path))
419 self.log.exception("Failed to make collection directory %s: %s", self.path, ose)
423 path = self.write_file(item)
424 self.git_add(path, 'Add %s' % item, context)
427 self.log.exception("Error writing file")
429 except Exception, ex:
430 self.log.exception("Caught Exception")
431 self.log.debug("Failed to create %s: %s", self.path, ex)
434 def destroy_file(self, item, context):
435 self.log.debug("Remove %s", item.name)
439 self.git_rm(item.path, 'Remove %s' % item, context)
441 except Exception, ex:
442 self.log.exception("Failed to remove %s", item.path)
445 def rewrite_file(self, item, context):
446 self.log.debug("Change %s", item.name)
449 new_path = self.write_file(item)
450 os.rename(new_path, item.path)
451 self.scan_file(item.path)
452 self.git_change(item.path, u'Modify %s' % item, context)
454 except Exception, ex:
455 self.log.exception("Failed to rewrite %s", item.path)
458 def get_item(self, name):
459 """Get collection item called ``name``."""
460 for item in self.my_items:
461 if item.name == name:
465 def get_items(self, name):
466 """Get collection items called ``name``."""
468 for item in self.my_items:
469 if item.name == name:
473 def append(self, name, text, context):
474 """Append items from ``text`` to collection.
476 If ``name`` is given, give this name to new items in ``text``.
480 self.log.debug('append name %s', name)
482 new_item = Item(text, name, None)
484 self.log.exception("Cannot create new item")
486 if new_item.name in (item.name for item in self.my_items):
487 self.log.debug("Item %s already present %s" , new_item.name, self.get_item(new_item.name).path)
488 raise CalypsoError(new_item.name, "Item already present")
489 self.log.debug("New item %s", new_item.name)
490 self.create_file(new_item, context=context)
493 def remove(self, name, context):
494 """Remove object named ``name`` from collection."""
495 self.log.debug("Remove object %s", name)
496 for old_item in self.my_items:
497 if old_item.name == name:
498 self.destroy_file(old_item, context=context)
500 def replace(self, name, text, context):
501 """Replace content by ``text`` in objet named ``name`` in collection."""
504 old_item = self.get_item(name)
509 new_item = Item(text, name, path)
511 self.log.exception("Failed to replace %s", name)
516 self.log.debug('rewrite path %s', path)
517 self.rewrite_file(new_item, context=context)
519 self.log.debug('remove and append item %s', name)
521 self.append(name, text, context=context)
524 def import_item(self, new_item, path):
525 old_item = self.get_item(new_item.name)
527 new_item.path = old_item.path
528 self.rewrite_file(new_item, context={})
529 self.log.debug("Updated %s from %s", new_item.name, path)
531 self.create_file(new_item, context={})
532 self.log.debug("Added %s from %s", new_item.name, path)
534 def import_file(self, path):
535 """Merge items from ``path`` to collection.
539 new_object = vobject.readComponents(codecs.open(path,encoding='utf-8').read())
540 for new_ics in new_object:
541 if new_ics.name == 'VCALENDAR':
543 events = new_ics.vevent_list
545 # Check for events with both dtstart and duration entries and
546 # delete the duration one
547 if ve.contents.has_key('dtstart') and ve.contents.has_key('duration'):
548 del ve.contents['duration']
549 new_ics.vevent_list = [ve]
550 new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
551 self.import_item(new_item, path)
553 new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
554 self.import_item(new_item, path)
556 except Exception, ex:
557 self.log.exception("Failed to import: %s", path)
560 def write(self, headers=None, items=None):
566 """Ctag from collection."""
571 """Collection name."""
572 return self.path.split(os.path.sep)[-1]
576 """Collection as plain text."""
579 for item in self.my_items:
580 _text = _text + item.text
587 return "#%s" % self.metadata.get('collection', 'color')
588 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
593 """Find headers items in collection."""
598 """Get list of all items in collection."""
603 def last_modified(self):
604 """Get the last time the collection has been modified.
606 The date is formatted according to rfc1123-5.2.14.
610 return time.gmtime(self.mtime)
614 return "%d" % len(self.text)
617 def is_addressbook(self):
619 return self.metadata.getboolean('collection', 'is-addressbook')
620 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
624 def is_calendar(self):
626 return self.metadata.getboolean('collection', 'is-calendar')
627 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
630 def is_personal(self):
632 return self.metadata.getboolean('collection', 'personal')
633 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
634 return config.get('acl', 'personal')
636 def has_right(self, user):
638 return user in self.metadata.get('collection', 'allowed-users').split()
639 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
640 return super(Collection, self).has_right(user)