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.
42 from . import config, paths
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(object):
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 = glob.glob(self.pattern)
304 for filename in filenames:
305 if filename == METADATA_FILENAME:
307 for file in self.files:
308 if filename == file.path:
309 newfiles.append(file)
310 if not file.is_up_to_date():
311 self.log.debug("Changed %s", filename)
312 self.scan_file(filename)
315 if os.path.isdir(filename):
316 self.log.debug("Ignoring directory %s in scan_dir", filename)
318 self.log.debug("New %s", filename)
319 newfiles.append(Pathtime(filename))
320 self.insert_file(filename)
321 for file in self.files:
322 if not file.path in filenames:
323 self.log.debug("Removed %s", file.path)
324 self.remove_file(file.path)
326 for item in self.my_items:
328 self._ctag = '%d-' % self.mtime + h.hexdigest()
329 self.files = newfiles
331 def __init__(self, path):
332 """Initialize the collection with ``cal`` and ``user`` parameters."""
334 self.log = logging.getLogger(__name__)
335 self.encoding = "utf-8"
337 self.owner = paths.url_to_owner(path)
338 self.path = paths.url_to_file(path)
339 self.pattern = os.path.join(self.path, "*")
344 self.etag = hashlib.sha1(self.path).hexdigest()
346 self.metadata_mtime = None
348 self.tag = "Collection"
351 return "Calendar-%s (at %s)" % (self.name, self.path)
354 return "<Calendar %s>" % (self.name)
359 def git_commit(self, context):
360 args = ["git", "commit", "--allow-empty"]
363 message = context.get('action', 'other action')
365 if "user" in context:
366 # use environment variables instead of --author to avoid git
367 # looking it up in previous commits if it doesn't seem well-formed
368 env['GIT_AUTHOR_NAME'] = context['user'] or "unknown"
369 env['GIT_AUTHOR_EMAIL'] = "%s@webdav"%context['user']
370 # supress a chatty message that we could configure author
371 # information explicitly in the config file. (slicing it in after
372 # the git command as position is important with git arguments)
373 args[1:1] = ["-c", "advice.implicitIdentity=false"]
374 if "user-agent" in context:
375 message += u"\n\nUser-Agent: %r"%context['user-agent']
377 args.extend(["-m", message.encode('utf8')])
379 subprocess.check_call(args, cwd=self.path, env=env)
381 def git_add(self, path, context):
383 subprocess.check_call(["git", "add", os.path.basename(path)], cwd=self.path)
384 self.git_commit(context=context)
386 def git_rm(self, path, context):
388 subprocess.check_call(["git", "rm", os.path.basename(path)], cwd=self.path)
389 self.git_commit(context=context)
391 def git_change(self, path, context):
393 subprocess.check_call(["git", "add", os.path.basename(path)], cwd=self.path)
394 self.git_commit(context=context)
395 # Touch directory so that another running instance will update
397 os.utime(self.path, None)
398 except Exception, ex:
399 self.log.exception("Failed to set directory mtime")
401 def write_file(self, item):
402 fd, path = tempfile.mkstemp(item.file_extension, item.file_prefix, dir=self.path)
403 self.log.debug('Trying to write to %s', path)
404 file = os.fdopen(fd, 'w')
405 file.write(item.text.encode('utf-8'))
407 self.log.debug('Wrote %s to %s', file, path)
410 def create_file(self, item, context):
411 # Create directory if necessary
412 self.log.debug("Add %s", item.name)
413 if not os.path.exists(os.path.dirname(self.path)):
415 os.makedirs(os.path.dirname(self.path))
417 self.log.exception("Failed to make collection directory %s: %s", self.path, ose)
420 context['action'] = u'Add %s'%item
423 path = self.write_file(item)
424 self.git_add(path, context=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)
437 context['action'] = u'Remove %s'%item
441 self.git_rm(item.path, context=context)
443 except Exception, ex:
444 self.log.exception("Failed to remove %s", item.path)
447 def rewrite_file(self, item, context):
448 self.log.debug("Change %s", item.name)
450 context['action'] = u'Modify %s'%item
453 new_path = self.write_file(item)
454 os.rename(new_path, item.path)
455 self.scan_file(item.path)
456 self.git_change(item.path, context=context)
458 except Exception, ex:
459 self.log.exception("Failed to rewrite %s", item.path)
462 def get_item(self, name):
463 """Get collection item called ``name``."""
464 for item in self.my_items:
465 if item.name == name:
469 def get_items(self, name):
470 """Get collection items called ``name``."""
472 for item in self.my_items:
473 if item.name == name:
477 def append(self, name, text, context):
478 """Append items from ``text`` to collection.
480 If ``name`` is given, give this name to new items in ``text``.
484 self.log.debug('append name %s', name)
486 new_item = Item(text, name, None)
488 self.log.exception("Cannot create new item")
490 if new_item.name in (item.name for item in self.my_items):
491 self.log.debug("Item %s already present %s" , new_item.name, self.get_item(new_item.name).path)
492 raise CalypsoError(new_item.name, "Item already present")
493 self.log.debug("New item %s", new_item.name)
494 self.create_file(new_item, context=context)
497 def remove(self, name, context):
498 """Remove object named ``name`` from collection."""
499 self.log.debug("Remove object %s", name)
500 for old_item in self.my_items:
501 if old_item.name == name:
502 self.destroy_file(old_item, context=context)
504 def replace(self, name, text, context):
505 """Replace content by ``text`` in objet named ``name`` in collection."""
508 old_item = self.get_item(name)
513 new_item = Item(text, name, path)
515 self.log.exception("Failed to replace %s", name)
520 self.log.debug('rewrite path %s', path)
521 self.rewrite_file(new_item, context=context)
523 self.log.debug('remove and append item %s', name)
525 self.append(name, text, context=context)
528 def import_item(self, new_item, path):
529 old_item = self.get_item(new_item.name)
531 new_item.path = old_item.path
532 self.rewrite_file(new_item, context={})
533 self.log.debug("Updated %s from %s", new_item.name, path)
535 self.create_file(new_item, context={})
536 self.log.debug("Added %s from %s", new_item.name, path)
538 def import_file(self, path):
539 """Merge items from ``path`` to collection.
543 new_object = vobject.readComponents(codecs.open(path,encoding='utf-8').read())
544 for new_ics in new_object:
545 if new_ics.name == 'VCALENDAR':
547 events = new_ics.vevent_list
549 # Check for events with both dtstart and duration entries and
550 # delete the duration one
551 if ve.contents.has_key('dtstart') and ve.contents.has_key('duration'):
552 del ve.contents['duration']
553 new_ics.vevent_list = [ve]
554 new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
555 self.import_item(new_item, path)
557 new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
558 self.import_item(new_item, path)
560 except Exception, ex:
561 self.log.exception("Failed to import: %s", path)
564 def write(self, headers=None, items=None):
570 """Ctag from collection."""
575 """Collection name."""
576 return self.path.split(os.path.sep)[-1]
580 """Collection as plain text."""
583 for item in self.my_items:
584 _text = _text + item.text
591 return "#%s" % self.metadata.get('collection', 'color')
592 except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
597 """Find headers items in collection."""
602 """Get list of all items in collection."""
607 def last_modified(self):
608 """Get the last time the collection has been modified.
610 The date is formatted according to rfc1123-5.2.14.
614 return time.gmtime(self.mtime)
618 return "%d" % len(self.text)