[CalendarServer-changes] [11666] CalendarServer/branches/users/gaya/sharedgroupfixes2

source_changes at macosforge.org source_changes at macosforge.org
Wed Sep 11 09:56:57 PDT 2013


Revision: 11666
          http://trac.calendarserver.org//changeset/11666
Author:   gaya at apple.com
Date:     2013-09-11 09:56:57 -0700 (Wed, 11 Sep 2013)
Log Message:
-----------
Basics needed for group revisions.

Modified Paths:
--------------
    CalendarServer/branches/users/gaya/sharedgroupfixes2/twistedcaldav/storebridge.py
    CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/sql.py
    CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/test/test_sql.py
    CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/common/datastore/sql.py

Modified: CalendarServer/branches/users/gaya/sharedgroupfixes2/twistedcaldav/storebridge.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroupfixes2/twistedcaldav/storebridge.py	2013-09-10 16:49:44 UTC (rev 11665)
+++ CalendarServer/branches/users/gaya/sharedgroupfixes2/twistedcaldav/storebridge.py	2013-09-11 16:56:57 UTC (rev 11666)
@@ -2607,7 +2607,7 @@
         # Content-type check
         content_type = request.headers.getHeader("content-type")
         if content_type is not None and (content_type.mediaType, content_type.mediaSubtype) != ("text", "calendar"):
-            log.error("MIME type %s not allowed in calendar collection" % (content_type,))
+            log.error("MIME type {content_type} not allowed in calendar collection", content_type=content_type)
             raise HTTPError(ErrorResponse(
                 responsecode.FORBIDDEN,
                 (caldav_namespace, "supported-calendar-data"),
@@ -3245,7 +3245,7 @@
         # Content-type check
         content_type = request.headers.getHeader("content-type")
         if content_type is not None and (content_type.mediaType, content_type.mediaSubtype) != ("text", "vcard"):
-            log.error("MIME type %s not allowed in vcard collection" % (content_type,))
+            log.error("MIME type {content_type} not allowed in vcard collection", content_type=content_type)
             raise HTTPError(ErrorResponse(
                 responsecode.FORBIDDEN,
                 (carddav_namespace, "supported-address-data"),

Modified: CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/sql.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/sql.py	2013-09-10 16:49:44 UTC (rev 11665)
+++ CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/sql.py	2013-09-11 16:56:57 UTC (rev 11666)
@@ -398,7 +398,10 @@
 
     def __init__(self, home, name, resourceID, mode, status, revision=0, message=None, ownerHome=None, bindName=None):
         ownerName = ownerHome.addressbook().name() if ownerHome else None
-        super(AddressBook, self).__init__(home, name, resourceID, mode, status, revision=revision, message=message, ownerHome=ownerHome, ownerName=ownerName)
+        super(AddressBook, self).__init__(
+            home, name, resourceID, mode, status, revision=revision,
+            message=message, ownerHome=ownerHome, ownerName=ownerName
+        )
         self._index = PostgresLegacyABIndexEmulator(self)
         self._bindName = bindName
 
@@ -451,6 +454,201 @@
 
 
     @inlineCallbacks
+    def resourceNamesSinceRevision(self, revision):
+        """
+        Return the changed and deleted resources since a particular revision. This implementation takes
+        into account sharing by making use of the bindRevision attribute to determine if the requested
+        revision is earlier than the share acceptance. If so, then we need to return all resources in
+        the results since the collection is in effect "new".
+
+        @param revision: the revision to determine changes since
+        @type revision: C{int}
+        """
+
+        print("AddressBook: resourceNamesSinceRevision:%s revision:%s" % (self, revision,))
+        if True: # self.owned() or self.fullyShared():
+            returnValue((yield super(AddressBook, self).resourceNamesSinceRevision(revision)))
+
+        # call sharedChildResourceNamesSinceRevision() and filter results
+        sharedChildChanged, sharedChildDeleted = yield self.sharedChildResourceNamesSinceRevision(revision, "infinity")
+
+        path = self.name() + '/'
+        lenpath = len(path)
+        changed = [item[lenpath:] for item in sharedChildChanged if item.startswith(path) and item != path]
+        deleted = [item[lenpath:] for item in sharedChildDeleted if item.startswith(path) and item != path]
+        returnValue((changed, deleted,))
+
+
+    @inlineCallbacks
+    def sharedChildResourceNamesSinceRevision(self, revision, depth):
+        """
+        Determine the list of child resources that have changed since the specified sync revision.
+        We do the same SQL query for both depth "1" and "infinity", but filter the results for
+        "1" to only account for a collection change.
+
+        We need to handle shared collection a little differently from owned ones. When a shared collection
+        is bound into a home we record a revision for it using the sharee home id and sharee collection name.
+        That revision is the "starting point" for changes: so if sync occurs with a revision earlier than
+        that, we return the list of all resources in the shared collection since they are all "new" as far
+        as the client is concerned since the shared collection has just appeared. For a later revision, we
+        just report the changes since that one. When a shared collection is removed from a home, we again
+        record a revision for the sharee home and sharee collection name with the "deleted" flag set. That way
+        the shared collection can be reported as removed.
+
+        @param revision: the sync revision to compare to
+        @type revision: C{str}
+        @param depth: depth for determine what changed
+        @type depth: C{str}
+        """
+
+        print("sharedChildResourceNamesSinceRevision:%s revision:%s, depth:%s" % (self, revision, depth))
+        assert not self.owned()
+
+        if True:#self.fullyShared():
+            returnValue((yield super(AddressBook, self).sharedChildResourceNamesSinceRevision(revision, depth)))
+
+        changed = set()
+        deleted = set()
+
+        groupBindRows = yield AddressBookObject._acceptedBindForHomeIDAndAddressBookID.on(
+                self._txn, homeID=self._home._resourceID, addressbookID=self._resourceID
+        )
+        acceptedGroupIDs = [groupBindRow[2] for groupBindRow in groupBindRows]
+        minRevision = min([groupBindRow[5] for groupBindRow in groupBindRows])
+        sharerevision = 0 if revision < minRevision else revision
+        print("sharedChildResourceNamesSinceRevision:%s minRevision:%s, sharerevision:%s" % (self, minRevision, sharerevision))
+
+        # get revision table changes
+        rev = self._revisionsSchema
+        results = [(
+                name,
+                wasdeleted,
+            ) for name, wasdeleted in (
+                yield Select([rev.RESOURCE_NAME, rev.DELETED],
+                             From=rev,
+                            Where=(rev.REVISION > sharerevision).And(
+                            rev.RESOURCE_ID == self._resourceID)).on(self._txn)
+            ) if name
+        ]
+        print("sharedChildResourceNamesSinceRevision:%s results:%s" % (self, results))
+
+        # get members table changes
+        aboMembers = schema.ABO_MEMBERS
+        memberRows = yield Select(
+            [aboMembers.GROUP_ID, aboMembers.MEMBER_ID, aboMembers.REMOVED],
+            From=aboMembers,
+            Where=(aboMembers.REVISION > sharerevision).And(
+                aboMembers.ADDRESSBOOK_ID == self._resourceID)
+        ).on(self._txn)
+        groupIDToChangedMemberIDMap = {}
+        for groupID, memberID, removed in memberRows:
+            if groupID not in groupIDToChangedMemberIDMap:
+                groupIDToChangedMemberIDMap[groupID] = set()
+            groupIDToChangedMemberIDMap[groupID].add(memberID)
+        print("sharedChildResourceNamesSinceRevision:%s memberRows:%s groupIDToChangedMemberIDMap=%s" % (self, memberRows, groupIDToChangedMemberIDMap,))
+
+        allowedObjectIDs = None
+        @inlineCallbacks
+        def allowedObjectIDs(acceptedGroupIDs):
+            if not hasattr(self, "__allowedObjectIDs"):
+                self.__allowedObjectIDs = yield self.expandGroupIDs(self._txn, acceptedGroupIDs)
+            returnValue(self.__allowedObjectIDs)
+
+        @inlineCallbacks
+        def getMissingNames(ids, idToNameMap):
+            idsForMissingNames = list(set(ids) - set(idToNameMap.keys()))
+            if idsForMissingNames:
+                abo = schema.ADDRESSBOOK_OBJECT
+                memberIDNameRows = (
+                    yield AddressBookObject._columnsWithResourceIDsQuery(
+                        [abo.RESOURCE_ID, abo.RESOURCE_NAME],
+                        idsForMissingNames
+                    ).on(self._txn, resourceIDs=idsForMissingNames)
+                )
+                idToNameMap = dict(dict(idToNameMap), **dict(memberIDNameRows))
+
+            returnValue(idToNameMap)
+
+        allIDs = set()
+        for groupID, memberID, removed in memberRows:
+            allIDs.add(groupID)
+            allIDs.add(memberID)
+
+        idToNameMap = yield getMissingNames(allIDs, {})
+        print("sharedChildResourceNamesSinceRevision:%s idToNameMap=%s" % (self, idToNameMap,))
+
+        path = self.name()
+        removedMemberIDs = set()
+        changedMemberIDs = set()
+
+        # TODO: use for query
+        allowedObjectIDs = yield self.expandGroupIDs(self._txn, acceptedGroupIDs)
+        print("sharedChildResourceNamesSinceRevision:%s acceptedGroupIDs:%s allowedObjectIDs=%s," % (self, acceptedGroupIDs, allowedObjectIDs,))
+
+        for groupID, memberID, removed in memberRows:
+
+            if memberID not in allowedObjectIDs or groupID not in allowedObjectIDs:
+                print("sharedChildResourceNamesSinceRevision:%s SKIP groupID:%s memberID=%s, wasdeleted=%s" % (self, groupID, memberID, wasdeleted,))
+                continue
+            print("sharedChildResourceNamesSinceRevision:%s groupID:%s memberID=%s, wasdeleted=%s" % (self, groupID, memberID, wasdeleted,))
+
+            if removed:
+                if sharerevision:
+                    if depth == "1":
+                        changed.add("%s/" % (path,))
+                    else:
+                        removedMemberIDs.add(memberID)
+
+            # Always report collection as changed
+            changed.add("%s/" % (path,))
+
+            # Resource changed - for depth "infinity" report resource as changed
+            if depth != "1":
+                changedMemberIDs.add(memberID)
+                changedMemberIDs.add(groupID)
+
+            print("sharedChildResourceNamesSinceRevision:%s changed:%s deleted=%s, removedMemberIDs=%s, changedMemberIDs=%s" % (self, changed, deleted, removedMemberIDs, changedMemberIDs))
+
+        '''
+        nameToIDMap = dict([(v, k) for k, v in idToNameMap.iteritems()])
+
+        for name, wasdeleted in results:
+
+            id = nameToIDMap[name]
+            if id not in allowedObjectIDs:
+                print("sharedChildResourceNamesSinceRevision:%s SKIP name:%s id=%s, wasdeleted=%s" % (self, name, id, wasdeleted,))
+                continue
+
+            print("sharedChildResourceNamesSinceRevision:%s name:%s id=%s, wasdeleted=%s" % (self, name, id, wasdeleted,))
+            if wasdeleted:
+                if sharerevision:
+                    if depth == "1":
+                        changed.add("%s/" % (path,))
+                    else:
+                        deleted.add("%s/%s" % (path, name,))
+
+            # Always report collection as changed
+            changed.add("%s/" % (path,))
+
+            # Resource changed - for depth "infinity" report resource as changed
+            if depth != "1":
+                changed.add("%s/%s" % (path, name,))
+
+            print("sharedChildResourceNamesSinceRevision:%s changed:%s deleted=%s, removedMemberIDs=%s, changedMemberIDs=%s" % (self, changed, deleted, removedMemberIDs, changedMemberIDs))
+        '''
+
+        # for changes, get object names all at once here
+        idToNameMap = yield getMissingNames(removedMemberIDs | changedMemberIDs, idToNameMap)
+        for removedMemberID in removedMemberIDs:
+            deleted.add("%s/%s" % (path, idToNameMap[removedMemberID],))
+
+        for changedMemberID in changedMemberIDs:
+            changed.add("%s/%s" % (path, idToNameMap[changedMemberID],))
+
+        returnValue((changed, deleted))
+
+
+    @inlineCallbacks
     def _loadPropertyStore(self, props=None):
         if props is None:
             props = yield PropertyStore.load(
@@ -491,11 +689,29 @@
             # raise HomeChildNameNotAllowedError
             raise HTTPError(FORBIDDEN)
 
+    '''
+    @inlineCallbacks
+    def removedObjectResource(self, child):
+        """
+            just like CommonHomeChild.removedObjectResource() but does not call self._deleteRevision(child.name())
+        """
+        self._objects.pop(child.name(), None)
+        self._objects.pop(child.uid(), None)
+        if self._objectNames and child.name() in self._objectNames:
+            self._objectNames.remove(child.name())
+        #yield self._deleteRevision(child.name())
+        yield self.notifyChanged()
+    '''
 
+
     @inlineCallbacks
     def remove(self):
 
         if self._resourceID == self._home._resourceID:
+
+            # Note that revision table is NOT queried for removes
+            yield self._updateRevision(self.name())
+
             # Allow remove, as a way to reset the address book to an empty state
             for abo in (yield self.objectResources()):
                 yield abo.remove()
@@ -503,9 +719,6 @@
 
             yield self.unshare()  # storebridge should already have done this
 
-            # Note that revision table is NOT queried for removes
-            yield self._updateRevision(self.name())
-
             yield self.properties()._removeResource()
             yield self._loadPropertyStore()
 
@@ -691,9 +904,7 @@
             ownerHome = yield home.ownerHomeWithChildID(ownerAddressBookID)
             if ownerHome not in ownerHomeToDataRowMap:
                 groupBindRow[0] = _BIND_MODE_WRITE
-                groupBindRow[3] = None  # bindName
-                groupBindRow[4] = None  # bindStatus
-                groupBindRow[6] = None  # bindMessage
+                groupBindRow[3:7] = 4 * [None]  # bindName, bindStatus, bindRevision, bindMessage
                 ownerHomeToDataRowMap[ownerHome] = groupBindRow
 
         if ownerHomeToDataRowMap:
@@ -765,7 +976,7 @@
             # Retrieve data from cache
             cacheKey = queryCacher.keyForObjectWithName(home._resourceID, name)
             cachedRows = yield queryCacher.get(cacheKey)
-            if cachedRows and (cachedRows[0][4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
+            if cachedRows and (cachedRows[0][8] == _BIND_STATUS_ACCEPTED) == bool(accepted): #cachedRows[0][8] == cachedBindStatus
                 rows = cachedRows
 
         if not rows:
@@ -785,17 +996,23 @@
                     groupBindRows = yield AddressBookObject._bindForHomeIDAndAddressBookID.on(
                             home._txn, homeID=home._resourceID, addressbookID=ownerAddressBook._resourceID
                     )
-                    for groupBindRow in groupBindRows:
-                        if (groupBindRow[4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
-                            groupBindRow.insert(AddressBookObject.bindColumnCount, ownerAddressBook._resourceID)
-                            groupBindRow.insert(AddressBookObject.bindColumnCount + 1, groupBindRow[4])
-                            groupBindRow[0] = _BIND_MODE_WRITE
-                            groupBindRow[3] = None  # bindName
-                            groupBindRow[4] = None  # bindStatus
-                            groupBindRow[6] = None  # bindMessage
-                            rows = [groupBindRow]
-                            break
+                    # prefer a bindStatus match, otherwise get row for cache
+                    matchingGroupBindRows = [groupBindRow for groupBindRow in groupBindRows if (groupBindRow[4] == _BIND_STATUS_ACCEPTED) == bool(accepted)]
+                    if matchingGroupBindRows:
+                        groupBindRows = matchingGroupBindRows
+                    elif bindRows:
+                        bindRows[0].insert(cls.bindColumnCount, ownerAddressBook._resourceID)
+                        bindRows[0].insert(cls.bindColumnCount + 1, bindRows[0][4])  # cachedStatus = bindStatus
+                        rows = bindRows
 
+                    if not rows and groupBindRows:
+                        groupBindRow = groupBindRows[0]
+                        groupBindRow.insert(AddressBookObject.bindColumnCount, ownerAddressBook._resourceID)
+                        groupBindRow.insert(AddressBookObject.bindColumnCount + 1, groupBindRow[4])
+                        groupBindRow[0] = _BIND_MODE_WRITE
+                        groupBindRow[3:7] = 4 * [None]  # bindName, bindStatus, bindRevision, bindMessage
+                        rows = [groupBindRow]
+
             if rows and queryCacher:
                 # Cache the result
                 queryCacher.setAfterCommit(home._txn, cacheKey, rows)
@@ -839,8 +1056,7 @@
         """
         bindRows = yield cls._bindForNameAndHomeID.on(home._txn, name=name, homeID=home._resourceID)
         if bindRows and (bindRows[0][4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
-            bindRow = bindRows[0]
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = bindRow[:cls.bindColumnCount] #@UnusedVariable
+            resourceID = bindRows[0][2]
 
             # alt:
             # returnValue((yield cls.objectWithID(home, resourceID)))
@@ -854,8 +1070,7 @@
             home._txn, name=name, homeID=home._resourceID
         )
         if groupBindRows and (groupBindRows[0][4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
-            groupBindRow = groupBindRows[0]
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = groupBindRow[:AddressBookObject.bindColumnCount] #@UnusedVariable
+            resourceID = groupBindRows[0][2]
 
             ownerAddressBookID = yield AddressBookObject.ownerAddressBookIDFromGroupID(home._txn, resourceID)
             # alt:
@@ -893,8 +1108,7 @@
             home._txn, resourceID=resourceID, homeID=home._resourceID
         )
         if bindRows and (bindRows[0][4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
-            bindRow = bindRows[0]
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = bindRow[:cls.bindColumnCount] #@UnusedVariable
+            resourceID = bindRows[0][2]
 
             ownerHome = yield home.ownerHomeWithChildID(resourceID)
             if accepted:
@@ -906,8 +1120,7 @@
                     home._txn, homeID=home._resourceID, addressbookID=resourceID
         )
         if groupBindRows and (groupBindRows[0][4] == _BIND_STATUS_ACCEPTED) == bool(accepted):
-            groupBindRow = groupBindRows[0]
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = groupBindRow[:AddressBookObject.bindColumnCount] #@UnusedVariable
+            resourceID = groupBindRows[0][2]
 
             ownerAddressBookID = yield AddressBookObject.ownerAddressBookIDFromGroupID(home._txn, resourceID)
             ownerHome = yield home.ownerHomeWithChildID(ownerAddressBookID)
@@ -944,18 +1157,18 @@
             home._txn, homeID=home._resourceID
         )
         for row in rows:
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = row[:cls.bindColumnCount] #@UnusedVariable
+            resourceID = row[2]
             ownerHome = yield home._txn.homeWithResourceID(home._homeType, resourceID, create=True)
-            names |= set([ownerHome.shareeAddressBookName()])
+            names.add(ownerHome.shareeAddressBookName())
 
         groupRows = yield AddressBookObject._acceptedBindForHomeID.on(
             home._txn, homeID=home._resourceID
         )
         for groupRow in groupRows:
-            bindMode, homeID, resourceID, bindName, bindStatus, bindRevision, bindMessage = groupRow[:AddressBookObject.bindColumnCount] #@UnusedVariable
+            resourceID = groupRow[2]
             ownerAddressBookID = yield AddressBookObject.ownerAddressBookIDFromGroupID(home._txn, resourceID)
             ownerHome = yield home._txn.homeWithResourceID(home._homeType, ownerAddressBookID, create=True)
-            names |= set([ownerHome.shareeAddressBookName()])
+            names.add(ownerHome.shareeAddressBookName())
         returnValue(tuple(names))
 
 
@@ -1060,7 +1273,7 @@
             rows = yield self._allColumnsWithParent(self)
             ids = set([row[1] for row in rows])
             if self.fullyShared():
-                ids |= set([self._resourceID, ])
+                ids.add(self._resourceID)
             if self.owned() or self._bindMode == _BIND_MODE_WRITE:
                 returnValue(tuple(readOnlyIDs), tuple(readWriteIDs))
             readOnlyIDs = set(ids)
@@ -1134,15 +1347,18 @@
 
         # remove None parameters, and substitute None for empty string
         bind = self._bindSchema
-        columnMap = dict([(k, v if v != "" else None)
-                          for k, v in {bind.BIND_MODE:mode,
-                            bind.BIND_STATUS:status,
-                            bind.MESSAGE:message}.iteritems() if v is not None])
+        columnMap = {}
+        if mode != None and mode != shareeView._bindMode:
+            columnMap[bind.BIND_MODE] = mode
+        if status != None and status != shareeView._bindStatus:
+            columnMap[bind.BIND_STATUS] = status
+        if message != None and message != shareeView._bindMessage:
+            columnMap[bind.MESSAGE] = message
 
-        if len(columnMap):
+        if columnMap:
 
             # count accepted
-            if status is not None:
+            if bind.BIND_STATUS in columnMap:
                 previouslyAcceptedBindCount = 1 if shareeView.fullyShared() else 0
                 previouslyAcceptedBindCount += len((yield AddressBookObject._acceptedBindForHomeIDAndAddressBookID.on(
                         self._txn, homeID=shareeView.viewerHome()._resourceID, addressbookID=shareeView._resourceID
@@ -1150,28 +1366,29 @@
 
             bindNameRows = yield self._updateBindColumnsQuery(columnMap).on(
                 self._txn,
-                resourceID=self._resourceID, homeID=shareeView.viewerHome()._resourceID
+                resourceID=self._resourceID,
+                homeID=shareeView.viewerHome()._resourceID
             )
 
             # update affected attributes
-            if mode is not None:
+            if bind.BIND_MODE in columnMap:
                 shareeView._bindMode = columnMap[bind.BIND_MODE]
 
-            if status is not None:
+            if bind.BIND_STATUS in columnMap:
                 shareeView._bindStatus = columnMap[bind.BIND_STATUS]
                 if shareeView._bindStatus == _BIND_STATUS_ACCEPTED:
                     if 0 == previouslyAcceptedBindCount:
                         yield shareeView._initSyncToken()
-                        yield shareeView._initBindRevision()
                         shareeView.viewerHome()._children[self.shareeName()] = shareeView
                         shareeView.viewerHome()._children[shareeView._resourceID] = shareeView
+                    yield shareeView._initBindRevision()
                 elif shareeView._bindStatus == _BIND_STATUS_DECLINED:
                     if 1 == previouslyAcceptedBindCount:
                         yield shareeView._deletedSyncToken(sharedRemoval=True)
                         shareeView.viewerHome()._children.pop(self.shareeName(), None)
                         shareeView.viewerHome()._children.pop(shareeView._resourceID, None)
 
-            if message is not None:
+            if bind.MESSAGE in columnMap:
                 shareeView._bindMessage = columnMap[bind.MESSAGE]
 
             queryCacher = self._txn._queryCacher
@@ -1234,7 +1451,7 @@
             # Must send notification to ensure cache invalidation occurs
             yield self.notifyPropertyChanged()
 
-        # delete binds including invites
+        # delete bind table rows for this share
         deletedBindNameRows = yield self._deleteBindForResourceIDAndHomeID.on(self._txn, resourceID=self._resourceID,
              homeID=shareeHome._resourceID
         )
@@ -1277,6 +1494,7 @@
         self._bindStatus = None
         self._bindMessage = None
         self._bindName = None
+        self._bindRevision = None
         super(AddressBookObject, self).__init__(addressbook, name, uid, resourceID, options)
         self._options = {} if options is None else options
 
@@ -1322,12 +1540,12 @@
                 raise HTTPError(FORBIDDEN)
 
         if not self.owned() and not self.addressbook().fullyShared():
-            readWriteObjectIDs = []
             readWriteGroupIDs = yield self.addressbook().readWriteGroupIDs()
-            if readWriteGroupIDs:
-                readWriteObjectIDs = yield self.addressbook().expandGroupIDs(self._txn, readWriteGroupIDs)
+            readWriteObjectIDs = (
+                yield self.addressbook().expandGroupIDs(self._txn, readWriteGroupIDs)
+            ) if readWriteGroupIDs else []
 
-            # can't delete item in shared group, even if user has addressbook unbind
+            # can't delete item in read-only shared group, even if user has addressbook unbind
             if self._resourceID not in readWriteObjectIDs:
                 raise HTTPError(FORBIDDEN)
 
@@ -1467,10 +1685,8 @@
                         resourceIDs=allowedObjectIDs,
                     ))
             elif self._resourceID:
-                if self._resourceID not in allowedObjectIDs:
-                    # allow invited groups
-                    allowedObjectIDs = yield self.addressbook().unacceptedGroupIDs()
-                if self._resourceID in allowedObjectIDs:
+                if (self._resourceID in allowedObjectIDs or
+                        self._resourceID in (yield self.addressbook().unacceptedGroupIDs())): # allow invited groups
                     rows = (yield self._allColumnsWithResourceID.on(
                         self._txn, resourceID=self._resourceID,
                     ))
@@ -1491,6 +1707,7 @@
                     self._bindStatus = bindStatus
                     self._bindMessage = bindMessage
                     self._bindName = bindName
+                    self._bindRevision = bindRevision
 
                 yield self._initIsShared()
 
@@ -1588,16 +1805,6 @@
         returnValue(rows)
 
 
-    @inlineCallbacks
-    def _changeAddressBookRevision(self, addressbook, inserting=False):
-        if inserting:
-            yield addressbook._insertRevision(self._name)
-        else:
-            yield addressbook._updateRevision(self._name)
-
-        yield addressbook.notifyChanged()
-
-
     # Stuff from put_addressbook_common
     def fullValidation(self, component, inserting):
         """
@@ -1608,7 +1815,14 @@
 
         # Valid data sizes
         if config.MaxResourceSize:
-            vcardsize = len(str(component))
+            if self._componentResourceKindToKind(component) == _ABO_KIND_GROUP:
+                thinGroup = deepcopy(component)
+                thinGroup.removeProperties("X-ADDRESSBOOKSERVER-MEMBER")
+                thinGroup.removeProperties("X-ADDRESSBOOKSERVER-KIND")
+                thinGroup.removeProperties("UID")
+                vcardsize = len(str(thinGroup))
+            else:
+                vcardsize = len(str(component))
             if vcardsize > config.MaxResourceSize:
                 raise ObjectResourceTooBigError()
 
@@ -1687,30 +1901,22 @@
 
         self._componentChanged = False
 
-        # Handle all validation operations here.
-        self.fullValidation(component, inserting)
+        if "coaddedUIDs" not in self._options:
+            # Handle all validation operations here.
+            self.fullValidation(component, inserting)
 
-        # UID lock - this will remain active until the end of the current txn
-        if not inserting or self._options.get("coaddedUIDs") is None:
+            # UID lock - this will remain active until the end of the current txn
             yield self._lockUID(component, inserting)
 
+            if inserting:
+                yield self.addressbook()._insertRevision(self._name)
+            else:
+                yield self.addressbook()._updateRevision(self._name)
+
+            yield self.addressbook().notifyChanged()
+
         yield self.updateDatabase(component, inserting=inserting)
-        yield self._changeAddressBookRevision(self._addressbook, inserting)
 
-        if self.owned():
-            # update revision table of the sharee group address book
-            if self._kind == _ABO_KIND_GROUP:  # optimization
-                invites = yield self.sharingInvites()
-                for invite in invites:
-                    shareeHome = (yield self._txn.homeWithResourceID(self.addressbook()._home._homeType, invite.shareeHomeID()))
-                    yield self._changeAddressBookRevision(shareeHome.addressbook(), inserting)
-                    # one is enough because all have the same resourceID
-                    break
-        else:
-            if self.addressbook()._resourceID != self._ownerAddressBookResourceID:
-                # update revisions table of shared group's containing address book
-                yield self._changeAddressBookRevision(self.ownerHome().addressbook(), inserting)
-
         returnValue(self._componentChanged)
 
 
@@ -1744,7 +1950,7 @@
 
 
     @classproperty
-    def _insertABObject(cls): #@NoSelf
+    def _insertABObjectQuery(cls): #@NoSelf
         """
         DAL statement to create an addressbook object with all default values.
         """
@@ -1809,7 +2015,7 @@
             if not self.owned() and not self.addressbook().fullyShared():
                 # in partially shared addressbook, all members UIDs must be inside the shared groups
                 # except during bulk operations, when other UIDs added are OK
-                coaddedUIDs = set() if self._options.get("coaddedUIDs") is None else self._options["coaddedUIDs"]
+                coaddedUIDs = self._options.get("coaddedUIDs", set())
                 if missingUIDs - coaddedUIDs:
                     raise GroupWithUnsharedAddressNotAllowedError(missingUIDs)
 
@@ -1824,16 +2030,16 @@
 
             # sort unique members
             component.removeProperties("X-ADDRESSBOOKSERVER-MEMBER")
-            for memberAddress in sorted(list(memberAddresses)): # sort unique
+            for memberAddress in sorted(memberAddresses):
                 component.addProperty(Property("X-ADDRESSBOOKSERVER-MEMBER", memberAddress))
             componentText = str(component)
 
             # remove unneeded fields to get stored _objectText
-            thinComponent = deepcopy(component)
-            thinComponent.removeProperties("X-ADDRESSBOOKSERVER-MEMBER")
-            thinComponent.removeProperties("X-ADDRESSBOOKSERVER-KIND")
-            thinComponent.removeProperties("UID")
-            self._objectText = str(thinComponent)
+            thinGroup = deepcopy(component)
+            thinGroup.removeProperties("X-ADDRESSBOOKSERVER-MEMBER")
+            thinGroup.removeProperties("X-ADDRESSBOOKSERVER-KIND")
+            thinGroup.removeProperties("UID")
+            self._objectText = str(thinGroup)
         else:
             componentText = str(component)
             self._objectText = componentText
@@ -1853,7 +2059,7 @@
 
         if inserting:
             self._resourceID, self._created, self._modified = (
-                yield self._insertABObject.on(
+                yield self._insertABObjectQuery.on(
                     self._txn,
                     addressbookResourceID=self._ownerAddressBookResourceID,
                     name=self._name,
@@ -1864,10 +2070,9 @@
                 )
             )[0]
 
-            # delete foreign members table row for this object
+            # delete foreign members table rows for this object, keeping revision
             groupIDRows = yield Delete(
                 aboForeignMembers,
-                # should this be scoped to the owner address book?
                 Where=aboForeignMembers.MEMBER_ADDRESS == "urn:uuid:" + self._uid,
                 Return=aboForeignMembers.GROUP_ID
             ).on(self._txn)
@@ -2288,21 +2493,16 @@
         returnValue(bindName)
 
 
+    @property
+    def _syncTokenRevision(self):
+        return self.addressbook()._syncTokenRevision
+
+
     @inlineCallbacks
-    def _initBindRevision(self):
-        yield self.addressbook()._initBindRevision()
+    def syncToken(self):
+        returnValue((yield self.addressbook().syncToken()))
 
-        bind = self._bindSchema
-        yield self._updateBindColumnsQuery(
-            {bind.BIND_REVISION : Parameter("revision"), }).on(
-            self._txn,
-            revision=self.addressbook()._bindRevision,
-            resourceID=self._resourceID,
-            homeID=self.viewerHome()._resourceID,
-        )
-        yield self.invalidateQueryCache()
 
-
     @inlineCallbacks
     def updateShare(self, shareeView, mode=None, status=None, message=None):
         """
@@ -2333,15 +2533,18 @@
 
         # remove None parameters, and substitute None for empty string
         bind = self._bindSchema
-        columnMap = dict([(k, v if v != "" else None)
-                          for k, v in {bind.BIND_MODE:mode,
-                            bind.BIND_STATUS:status,
-                            bind.MESSAGE:message}.iteritems() if v is not None])
+        columnMap = {}
+        if mode != None and mode != shareeView._bindMode:
+            columnMap[bind.BIND_MODE] = mode
+        if status != None and status != shareeView._bindStatus:
+            columnMap[bind.BIND_STATUS] = status
+        if message != None and message != shareeView._bindMessage:
+            columnMap[bind.MESSAGE] = message
 
-        if len(columnMap):
+        if columnMap:
 
             # count accepted
-            if status is not None:
+            if bind.BIND_STATUS in columnMap:
                 previouslyAcceptedBindCount = 1 if self.addressbook().fullyShared() else 0
                 previouslyAcceptedBindCount += len((
                     yield AddressBookObject._acceptedBindForHomeIDAndAddressBookID.on(
@@ -2351,28 +2554,29 @@
 
             bindNameRows = yield self._updateBindColumnsQuery(columnMap).on(
                 self._txn,
-                resourceID=self._resourceID, homeID=shareeView.viewerHome()._resourceID
+                resourceID=self._resourceID,
+                homeID=shareeView.viewerHome()._resourceID
             )
 
             # update affected attributes
-            if mode is not None:
+            if bind.BIND_MODE in columnMap:
                 shareeView._bindMode = columnMap[bind.BIND_MODE]
 
-            if status is not None:
+            if bind.BIND_STATUS in columnMap:
                 shareeView._bindStatus = columnMap[bind.BIND_STATUS]
                 if shareeView._bindStatus == _BIND_STATUS_ACCEPTED:
                     if 0 == previouslyAcceptedBindCount:
                         yield shareeView.addressbook()._initSyncToken()
-                        yield shareeView._initBindRevision()
                         shareeView.viewerHome()._children[self.addressbook().shareeName()] = shareeView.addressbook()
                         shareeView.viewerHome()._children[shareeView._resourceID] = shareeView.addressbook()
+                    yield shareeView._initBindRevision()
                 elif shareeView._bindStatus != _BIND_STATUS_INVITED:
                     if 1 == previouslyAcceptedBindCount:
                         yield shareeView.addressbook()._deletedSyncToken(sharedRemoval=True)
                         shareeView.viewerHome()._children.pop(self.addressbook().shareeName(), None)
                         shareeView.viewerHome()._children.pop(shareeView._resourceID, None)
 
-            if message is not None:
+            if bind.MESSAGE in columnMap:
                 shareeView._bindMessage = columnMap[bind.MESSAGE]
 
             # safer to just invalidate in all cases rather than calculate when to invalidate

Modified: CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/test/test_sql.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/test/test_sql.py	2013-09-10 16:49:44 UTC (rev 11665)
+++ CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/carddav/datastore/test/test_sql.py	2013-09-11 16:56:57 UTC (rev 11666)
@@ -574,11 +574,11 @@
             )
         groupObject = yield adbk.createAddressBookObjectWithName("g.vcf", group)
 
-        aboForeignMembers = schema.ABO_FOREIGN_MEMBERS
         aboMembers = schema.ABO_MEMBERS
-        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers,).on(txn)
+        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers).on(txn)
         self.assertEqual(memberRows, [])
 
+        aboForeignMembers = schema.ABO_FOREIGN_MEMBERS
         foreignMemberRows = yield Select([aboForeignMembers.GROUP_ID, aboForeignMembers.MEMBER_ADDRESS], From=aboForeignMembers).on(txn)
         self.assertEqual(foreignMemberRows, [[groupObject._resourceID, "urn:uuid:uid3"]])
 
@@ -597,7 +597,7 @@
             )
         subgroupObject = yield adbk.createAddressBookObjectWithName("sg.vcf", subgroup)
 
-        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers,).on(txn)
+        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers).on(txn)
         self.assertEqual(sorted(memberRows), sorted([
                                                      [groupObject._resourceID, subgroupObject._resourceID],
                                                      [subgroupObject._resourceID, personObject._resourceID],
@@ -607,7 +607,7 @@
         self.assertEqual(foreignMemberRows, [])
 
         yield subgroupObject.remove()
-        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers,).on(txn)
+        memberRows = yield Select([aboMembers.GROUP_ID, aboMembers.MEMBER_ID], From=aboMembers).on(txn)
         self.assertEqual(memberRows, [])
 
         foreignMemberRows = yield Select([aboForeignMembers.GROUP_ID, aboForeignMembers.MEMBER_ADDRESS], From=aboForeignMembers,
@@ -901,8 +901,9 @@
         self.assertEqual(normalAB._bindRevision, 0)
         otherHome = yield self.homeUnderTest(name="home2")
         otherGroup = yield otherHome.objectWithShareUID(newGroupShareUID)
+        self.assertNotEqual(otherGroup._bindRevision, 0)
         otherAB = otherGroup.addressbook()
-        self.assertNotEqual(otherAB._bindRevision, 0)
+        self.assertEqual(otherAB._bindRevision, None)
 
 
     @inlineCallbacks
@@ -952,8 +953,9 @@
         self.assertEqual(normalAB._bindRevision, 0)
         otherHome = yield self.homeUnderTest(name="home2")
         otherGroup = yield otherHome.invitedObjectWithShareUID(newGroupShareUID)
+        self.assertEqual(otherGroup._bindRevision, 0)
         otherAB = otherGroup.addressbook()
-        self.assertEqual(otherAB._bindRevision, 0)
+        self.assertEqual(otherAB._bindRevision, None)
         yield self.commit()
 
         normalAB = yield self.addressbookUnderTest(home="home3")
@@ -967,8 +969,9 @@
         self.assertEqual(normalAB._bindRevision, 0)
         otherHome = yield self.homeUnderTest(name="home2")
         otherGroup = yield otherHome.objectWithShareUID(newGroupShareUID)
+        self.assertNotEqual(otherGroup._bindRevision, 0)
         otherAB = otherGroup.addressbook()
-        self.assertNotEqual(otherAB._bindRevision, 0)
+        self.assertEqual(otherAB._bindRevision, None)
 
 
     @inlineCallbacks
@@ -1022,23 +1025,28 @@
         self.assertEqual(normalAB._bindRevision, 0)
         otherHome = yield self.homeUnderTest(name="home2")
         otherGroup = yield otherHome.objectWithShareUID(newGroupShareUID)
+        self.assertNotEqual(otherGroup._bindRevision, 0)
         otherAB = otherGroup.addressbook()
-        self.assertNotEqual(otherAB._bindRevision, 0)
+        self.assertEqual(otherAB._bindRevision, None)
 
-        changed, deleted = yield otherAB.resourceNamesSinceRevision(otherAB._bindRevision - 1)
-        self.assertNotEqual(len(changed), 0)
+        changed, deleted = yield otherAB.resourceNamesSinceRevision(otherGroup._bindRevision - 1)
+        print("revision=%s, changed=%s, deleted=%s" % (otherGroup._bindRevision - 1, changed, deleted,))
+        self.assertEqual(set(changed), set(['1.vcf', '4.vcf', '2.vcf', ]))
         self.assertEqual(len(deleted), 0)
 
-        changed, deleted = yield otherAB.resourceNamesSinceRevision(otherAB._bindRevision)
+        changed, deleted = yield otherAB.resourceNamesSinceRevision(otherGroup._bindRevision)
+        print("revision=%s, changed=%s, deleted=%s" % (otherGroup._bindRevision, changed, deleted,))
         self.assertEqual(len(changed), 0)
         self.assertEqual(len(deleted), 0)
 
-        for depth in ("1", "infinity",):
-            changed, deleted = yield otherHome.resourceNamesSinceRevision(otherAB._bindRevision - 1, depth)
-            self.assertNotEqual(len(changed), 0)
+        for depth, result in (("1", ['home3/']), ("infinity", ['home3/1.vcf', 'home3/4.vcf', 'home3/2.vcf', 'home3/'])):
+            changed, deleted = yield otherHome.resourceNamesSinceRevision(otherGroup._bindRevision - 1, depth)
+            print("revision=%s, depth=%s, changed=%s, deleted=%s" % (otherGroup._bindRevision - 1, depth, changed, deleted,))
+            self.assertEqual(set(changed), set(result))
             self.assertEqual(len(deleted), 0)
 
-            changed, deleted = yield otherHome.resourceNamesSinceRevision(otherAB._bindRevision, depth)
+            changed, deleted = yield otherHome.resourceNamesSinceRevision(otherGroup._bindRevision, depth)
+            print("revision=%s, depth=%s, changed=%s, deleted=%s" % (otherGroup._bindRevision, depth, changed, deleted,))
             self.assertEqual(len(changed), 0)
             self.assertEqual(len(deleted), 0)
 

Modified: CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/common/datastore/sql.py
===================================================================
--- CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/common/datastore/sql.py	2013-09-10 16:49:44 UTC (rev 11665)
+++ CalendarServer/branches/users/gaya/sharedgroupfixes2/txdav/common/datastore/sql.py	2013-09-11 16:56:57 UTC (rev 11666)
@@ -1918,7 +1918,6 @@
         changed = set()
         deleted = set()
         deleted_collections = set()
-        changed_collections = set()
         for path, name, wasdeleted in results:
             if wasdeleted:
                 if revision:
@@ -1934,7 +1933,6 @@
                         deleted.add("%s/" % (path,))
                         deleted_collections.add(path)
 
-        for path, name, wasdeleted in results:
             if path not in deleted_collections:
                 # Always report collection as changed
                 changed.add("%s/" % (path,))
@@ -1942,47 +1940,16 @@
                     # Resource changed - for depth "infinity" report resource as changed
                     if depth != "1":
                         changed.add("%s/%s" % (path, name,))
-                else:
-                    # Collection was changed
-                    changed_collections.add(path)
 
         # Now deal with shared collections
         # TODO: think about whether this can be done in one query rather than looping over each share
-        rev = self._revisionsSchema
         shares = yield self.children()
         for share in shares:
             if not share.owned():
-                sharerevision = 0 if revision < share._bindRevision else revision
-                results = [
-                    (
-                        share.name(),
-                        name if name else "",
-                        wasdeleted
-                    )
-                    for name, wasdeleted in
-                    (yield Select([rev.RESOURCE_NAME, rev.DELETED],
-                                     From=rev,
-                                    Where=(rev.REVISION > sharerevision).And(
-                                    rev.RESOURCE_ID == share._resourceID)).on(self._txn))
-                    if name
-                ]
+                sharedChanged, sharedDeleted = yield share.sharedChildResourceNamesSinceRevision(revision, depth)
+                changed |= sharedChanged
+                deleted |= sharedDeleted
 
-                for path, name, wasdeleted in results:
-                    if wasdeleted:
-                        if sharerevision:
-                            if depth == "1":
-                                changed.add("%s/" % (path,))
-                            else:
-                                deleted.add("%s/%s" % (path, name,))
-
-                for path, name, wasdeleted in results:
-                    # Always report collection as changed
-                    changed.add("%s/" % (path,))
-                    if name:
-                        # Resource changed - for depth "infinity" report resource as changed
-                        if depth != "1":
-                            changed.add("%s/%s" % (path, name,))
-
         changed = sorted(changed)
         deleted = sorted(deleted)
         returnValue((changed, deleted))
@@ -2940,9 +2907,10 @@
         # Look up the shared child - might be accepted or not. If accepted use the resource name
         # to look it up, else use the invitation uid (bind name)
         shareeHome = yield self._txn.homeWithUID(self._home._homeType, invitation.shareeUID())
-        shareeView = yield shareeHome.childWithName(invitation.resourceName())
+        shareeView = yield shareeHome.objectWithShareUID(invitation.uid())
         if shareeView is None:
             shareeView = yield shareeHome.invitedObjectWithShareUID(invitation.uid())
+            assert shareeView
 
         result = yield self.updateShare(shareeView, mode, status, message)
         returnValue(result)
@@ -2978,12 +2946,15 @@
 
         #remove None parameters, and substitute None for empty string
         bind = self._bindSchema
-        columnMap = dict([(k, v if v != "" else None)
-                          for k, v in {bind.BIND_MODE:mode,
-                            bind.BIND_STATUS:status,
-                            bind.MESSAGE:message}.iteritems() if v is not None])
+        columnMap = {}
+        if mode != None and mode != shareeView._bindMode:
+            columnMap[bind.BIND_MODE] = mode
+        if status != None and status != shareeView._bindStatus:
+            columnMap[bind.BIND_STATUS] = status
+        if message != None and message != shareeView._bindMessage:
+            columnMap[bind.MESSAGE] = message
 
-        if len(columnMap):
+        if columnMap:
 
             sharedname = yield self._updateBindColumnsQuery(columnMap).on(
                             self._txn,
@@ -2991,10 +2962,10 @@
                         )
 
             #update affected attributes
-            if mode is not None:
+            if bind.BIND_MODE in columnMap:
                 shareeView._bindMode = columnMap[bind.BIND_MODE]
 
-            if status is not None:
+            if bind.BIND_STATUS in columnMap:
                 shareeView._bindStatus = columnMap[bind.BIND_STATUS]
                 if shareeView._bindStatus == _BIND_STATUS_ACCEPTED:
                     yield shareeView._initSyncToken()
@@ -3006,7 +2977,7 @@
                     shareeView._home._children.pop(shareeView._name, None)
                     shareeView._home._children.pop(shareeView._resourceID, None)
 
-            if message is not None:
+            if bind.MESSAGE in columnMap:
                 shareeView._bindMessage = columnMap[bind.MESSAGE]
 
             queryCacher = self._txn._queryCacher
@@ -3130,6 +3101,7 @@
 
     @inlineCallbacks
     def _initBindRevision(self):
+        yield self.syncToken()
         self._bindRevision = self._syncTokenRevision
 
         bind = self._bindSchema
@@ -4146,6 +4118,65 @@
 
 
     @inlineCallbacks
+    def sharedChildResourceNamesSinceRevision(self, revision, depth):
+        """
+        Determine the list of child resources that have changed since the specified sync revision.
+        We do the same SQL query for both depth "1" and "infinity", but filter the results for
+        "1" to only account for a collection change.
+
+        We need to handle shared collection a little differently from owned ones. When a shared collection
+        is bound into a home we record a revision for it using the sharee home id and sharee collection name.
+        That revision is the "starting point" for changes: so if sync occurs with a revision earlier than
+        that, we return the list of all resources in the shared collection since they are all "new" as far
+        as the client is concerned since the shared collection has just appeared. For a later revision, we
+        just report the changes since that one. When a shared collection is removed from a home, we again
+        record a revision for the sharee home and sharee collection name with the "deleted" flag set. That way
+        the shared collection can be reported as removed.
+
+        @param revision: the sync revision to compare to
+        @type revision: C{str}
+        @param depth: depth for determine what changed
+        @type depth: C{str}
+        """
+        assert not self.owned()
+
+        changed = set()
+        deleted = set()
+        rev = self._revisionsSchema
+        sharerevision = 0 if revision < self._bindRevision else revision
+        results = [
+            (
+                self.name(),
+                name if name else "",
+                wasdeleted
+            )
+            for name, wasdeleted in
+            (yield Select([rev.RESOURCE_NAME, rev.DELETED],
+                             From=rev,
+                            Where=(rev.REVISION > sharerevision).And(
+                            rev.RESOURCE_ID == self._resourceID)).on(self._txn))
+            if name
+        ]
+
+        for path, name, wasdeleted in results:
+            if wasdeleted:
+                if sharerevision:
+                    if depth == "1":
+                        changed.add("%s/" % (path,))
+                    else:
+                        deleted.add("%s/%s" % (path, name,))
+
+            # Always report collection as changed
+            changed.add("%s/" % (path,))
+
+            # Resource changed - for depth "infinity" report resource as changed
+            if depth != "1":
+                changed.add("%s/%s" % (path, name,))
+
+        returnValue((changed, deleted))
+
+
+    @inlineCallbacks
     def _loadPropertyStore(self, props=None):
         if props is None:
             props = yield PropertyStore.load(
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130911/fcf42b9f/attachment-0001.html>


More information about the calendarserver-changes mailing list