[CalendarServer-changes] [9746] CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav

source_changes at macosforge.org source_changes at macosforge.org
Sat Aug 25 07:27:26 PDT 2012


Revision: 9746
          http://trac.macosforge.org/projects/calendarserver/changeset/9746
Author:   cdaboo at apple.com
Date:     2012-08-25 07:27:26 -0700 (Sat, 25 Aug 2012)
Log Message:
-----------
Checkpoint of work so far.

Modified Paths:
--------------
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/__init__.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/customxml.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/peruserdata.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/test/test_peruserdata.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/ical.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/post.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/put_common.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/scheduling/icaldiff.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/stdconfig.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/storebridge.py
    CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/test/test_icalendar.py

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/__init__.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/__init__.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/__init__.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -41,6 +41,7 @@
 import twistedcaldav.carddavxml
 import twistedcaldav.mkcolxml
 import twistedcaldav.customxml
+import twistedcaldav.timezonexml
 
 twistedcaldav # Shhh.. pyflakes
 

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/customxml.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/customxml.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/customxml.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -69,6 +69,11 @@
 )
 
 
+calendarserver_recurrence_split_compliance = (
+    "calendarserver-recurrence-split",
+)
+
+
 @registerElement
 class TwistedCalendarSupportedComponents (WebDAVTextElement):
     """

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/peruserdata.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/peruserdata.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/peruserdata.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -140,6 +140,80 @@
         self._mergeRepresentations(icalnew, icalold)
         return icalnew
 
+    def extract(self, icalold, fromRID, newUID):
+        """
+        Return an iCalendar object with just per-user components not including ones for
+        the specified user UUID, and only including the master component and RIDs on or after
+        the specified RID. Also, adjust to a new UID if needed.
+        
+        This method is used to aid recurrence splitting where we need to preserve per-user data in the
+        original component when it is split. 
+        
+        @param icalold: existing calendar data
+        @type icalold: L{Component} or C{str}
+        @param fromRID: RID that starts per-user components
+        @type fromRID: L{PyCalendarDateTime}
+        @param newUID: new iCalendar UID to use or None to leave unchanged
+        @type newUID: C{str} or C{None}
+        """
+
+        icalold = self.validCalendar(icalold)
+        extracted = icalold.duplicate()
+        for component in tuple(extracted.subcomponents()):
+            if component.name() != PerUserDataFilter.PERUSER_COMPONENT:
+                # Remove unwanted components
+                extracted.removeComponent(component)
+            else:
+                uuid = component.propertyValue(PerUserDataFilter.PERUSER_UID)
+                if uuid == self.uid:
+                    extracted.removeComponent(component)
+                else:
+                    # Adjust to any new UID
+                    if newUID:
+                        component.getProperty("UID").setValue(newUID)
+                    
+                    # Now filter out per instance components before the RID
+                    for perinstance in tuple(component.subcomponents()):
+                        if perinstance.name() != PerUserDataFilter.PERINSTANCE_COMPONENT:
+                            raise AssertionError("Wrong sub-component '%s' in a X-CALENDARSERVER-PERUSER component" % (perinstance.name(),))
+                        rid = perinstance.getRecurrenceIDUTC()
+                        if rid and rid < fromRID:
+                            component.removeComponent(perinstance)
+        
+        return extracted
+            
+    def truncateRecurrenceDueToSplit(self, calendar, splitRID):
+        """
+        Remove overridden components, per-user data at or beyond the specified RECURRENCE-ID.
+
+        @param calendar: calendar to operate on
+        @type calendar: L{Component} or C{str}
+        @param splitRID: RID to remove from
+        @type splitRID: L{PyCalendarDateTime}
+        """
+        
+        calendar = self.validCalendar(calendar)
+
+        # First do RRULE, override truncation
+        calendar.truncateRecurrenceDueToSplit(splitRID)
+    
+        # Now do per-user data
+        for component in tuple(calendar.subcomponents()):
+            if component.name() == PerUserDataFilter.PERUSER_COMPONENT:
+                # Now filter out per instance components on or after the RID
+                for perinstance in tuple(component.subcomponents()):
+                    if perinstance.name() != PerUserDataFilter.PERINSTANCE_COMPONENT:
+                        raise AssertionError("Wrong sub-component '%s' in a X-CALENDARSERVER-PERUSER component" % (perinstance.name(),))
+                    rid = perinstance.getRecurrenceIDUTC()
+                    if rid and rid >= splitRID:
+                        component.removeComponent(perinstance)
+                
+                # If none left, remove the overall per-user data
+                if len(tuple(component.subcomponents())) == 0:
+                    calendar.removeComponent(component)
+
+        return calendar
+
     def _mergeBack(self, ical, peruser):
         """
         Merge the per-user data back into the main calendar data.

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/test/test_peruserdata.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/test/test_peruserdata.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/datafilters/test/test_peruserdata.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -17,6 +17,7 @@
 import twistedcaldav.test.util
 from twistedcaldav.ical import Component
 from twistedcaldav.datafilters.peruserdata import PerUserDataFilter
+from pycalendar.datetime import PyCalendarDateTime
 
 dataForTwoUsers = """BEGIN:VCALENDAR
 VERSION:2.0
@@ -6258,3 +6259,472 @@
         self.assertEqual(str(filtered), result)
         unfiltered = PerUserDataFilter("user01").filter(filtered)
         self.assertEqual(str(unfiltered), unfiltered_result)
+
+
+class PerUserDataExtractTest (twistedcaldav.test.util.TestCase):
+
+    def test_extract(self):
+        
+        data = (
+            # tuple (old_data, split_at_rid, new_uid, result)
+            
+            # No peruser data
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                "DEF",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+            ),
+            
+            # Only owner peruser data
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                "DEF",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+            ),
+            
+            # Other peruser data - master only
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                "DEF",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:DEF
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+            ),
+            
+            # Other peruser data - master + three instances
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-master
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120821T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-20120821T120000Z
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120822T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-20120822T120000Z
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120824T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-20120824T120000Z
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                "DEF",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:DEF
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-master
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120822T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-20120822T120000Z
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120824T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02-20120824T120000Z
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""".replace("\n", "\r\n"),
+            ),
+        )
+        
+        for old_data, split_rid, new_uid, result in data:
+            extracted = PerUserDataFilter("user01").extract(old_data, split_rid, new_uid)
+            self.assertEqual(str(extracted), result)
+
+
+class PerUserDataTruncateTest (twistedcaldav.test.util.TestCase):
+
+    def test_split(self):
+        
+        data = (
+            # tuple (input_data, split_at_rid, result)
+            
+            # No peruser data
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;UNTIL=20120822T115959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            
+            # Peruser data - master only
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;UNTIL=20120822T115959Z
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test user02
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""",
+            ),
+            
+            # Peruser data - three overrides
+            (
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;COUNT=5
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120821T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod1
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120822T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod2
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user02
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120822T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-2mod2
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120823T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-2mod2
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20120822T120000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+DTSTART:20120820T120000Z
+DURATION:PT1H
+ATTENDEE:mailto:user1 at example.com
+ATTENDEE:mailto:user2 at example.com
+DTSTAMP:20080601T120000Z
+ORGANIZER;CN=User 01:mailto:user1 at example.com
+RRULE:FREQ=DAILY;UNTIL=20120822T115959Z
+END:VEVENT
+BEGIN:X-CALENDARSERVER-PERUSER
+UID:12345-67890
+X-CALENDARSERVER-PERUSER-UID:user01
+BEGIN:X-CALENDARSERVER-PERINSTANCE
+RECURRENCE-ID:20120821T120000Z
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:Test-1mod1
+TRIGGER;RELATED=START:-PT10M
+END:VALARM
+END:X-CALENDARSERVER-PERINSTANCE
+END:X-CALENDARSERVER-PERUSER
+END:VCALENDAR
+""",
+            ),
+        )
+        
+        for old_data, split_rid, result in data:
+            split = PerUserDataFilter("").truncateRecurrenceDueToSplit(old_data.replace("\n", "\r\n"), split_rid)
+            self.assertEqual(str(split), result.replace("\n", "\r\n"))

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/ical.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/ical.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/ical.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -952,6 +952,61 @@
             self._markAsDirty()
         return changed
 
+    def truncateRecurrenceDueToSplit(self, splitRID):
+        """
+        Truncate RRULEs etc to make sure there are no more than the given number
+        of instances. This assumes that the RID has already been validated as an
+        existing, valid instance.
+ 
+        @param splitRID: the recurrence-id where the split occurs
+        @type maximumCount: L{PyCalendarDateTime}
+        """
+        
+        splitRID = splitRID.duplicateAsUTC()
+
+        master = self.masterComponent()
+        if master and master.isRecurring():
+            # Force all RRULEs to an UNTIL one second before the RID
+            rrules = master._pycalendar.getRecurrenceSet()
+            if rrules:
+                until = splitRID.duplicate()
+                until.offsetSeconds(-1)
+                for rrule in rrules.getRules():
+                    rrule.setUseCount(False)
+                    rrule.setUseUntil(True)
+                    rrule.setUntil(until)
+            
+            # Remove RDATES on or after RID
+            for rdate in tuple(master.properties("RDATE")):
+                for value in tuple(rdate.value()):
+                    if isinstance(value.getValue(), PyCalendarDateTime):
+                        if value.getValue() >= splitRID:
+                            rdate.value().remove(value)
+                    elif isinstance(value.getValue(), PyCalendarPeriod):
+                        if value.getValue().getStart() >= splitRID:
+                            rdate.value().remove(value)
+                if len(rdate.value()) == 0:
+                    master.removeProperty(rdate)
+
+            # Remove EXDATES on or after RID
+            for exdate in tuple(master.properties("EXDATE")):
+                for value in tuple(exdate.value()):
+                    if value.getValue() >= splitRID:
+                        exdate.value().remove(value)
+                if len(exdate.value()) == 0:
+                    master.removeProperty(exdate)
+    
+            # After creating/changing a component we need to do this to keep PyCalendar happy
+            master._pycalendar.finalise()
+                        
+        # Remove overridden components
+        for component in tuple(self.subcomponents()):
+            rid = component.getRecurrenceIDUTC()
+            if rid is not None and rid >= splitRID:
+                self.removeComponent(component)
+        
+        self._markAsDirty()
+
     def expand(self, start, end, timezone=None):
         """
         Expand the components into a set of new components, one for each
@@ -1145,6 +1200,22 @@
                     return True
         return False
         
+    def recurrenceSet(self):
+        """
+        @return: the RECURRENCE-SET of the subcomponents in this component.
+        """
+        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+
+        if not hasattr(self, "_recurrence_set"):
+            for subcomponent in self.subcomponents():
+                if subcomponent.name() not in ignoredComponents:
+                    self._recurrence_set = subcomponent.propertyValue("RECURRENCE-SET")
+                    break
+            else:
+                self._recurrence_set = None
+
+        return self._recurrence_set
+
     def deriveInstance(self, rid, allowCancelled=False, newcomp=None):
         """
         Derive an instance from the master component that has the provided RECURRENCE-ID, but
@@ -2823,6 +2894,26 @@
         return tuple(results)
 
 
+    def peruserComponents(self, ignoreUUID, fromRID, newUID):
+        """
+        Return an iCalendar object with just per-user components (not including ones for
+        the specified user UUID, and only including the master component and RIDs on or after
+        the specified RID.
+        
+        @param ignoreUID: UUID of user to ignore
+        @type ignoreUID: C{str}
+        @param fromRID: RID that starts per-user components
+        @type fromRID: L{PyCalendarDateTime}
+        @param newUID: new iCalendar UID to use or None to leave unchanged
+        @type newUID: C{str} or C{None}
+        """
+        
+        results = set()
+        for component in self.subcomponents():
+            if component.name() == "X-CALENDARSERVER-PERUSER":
+                results.add(component.propertyValue("X-CALENDARSERVER-PERUSER-UID"))
+        return results
+
     def hasInstancesAfter(self, limit):
         """
         Determine whether an event exists completely prior to a given moment.

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/post.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/post.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/post.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -45,24 +45,38 @@
 
     # POST can support many different APIs
     
-    # First look at query params
-    if request.params:
-        if request.params == "add-member":
-            if config.EnableAddMember:
-                result = (yield POST_handler_add_member(self, request))
-                returnValue(result)
+    # Look for action argument
+    if request.args and "action" in request.args:
+        result = (yield POST_handler_action(self, request))
+        returnValue(result)
 
-    else:
-        # Content-type handlers
-        contentType = request.headers.getHeader("content-type")
-        if contentType:
-            if hasattr(self, "POST_handler_content_type"):
-                result = (yield self.POST_handler_content_type(request, (contentType.mediaType, contentType.mediaSubtype)))
-                returnValue(result)
+    # Check for add-member query param
+    if request.params and request.params == "add-member" and config.EnableAddMember:
+        result = (yield POST_handler_add_member(self, request))
+        returnValue(result)
 
+    # Content-type handlers
+    contentType = request.headers.getHeader("content-type")
+    if contentType and hasattr(self, "POST_handler_content_type"):
+        result = (yield self.POST_handler_content_type(request, (contentType.mediaType, contentType.mediaSubtype)))
+        returnValue(result)
+
     returnValue(responsecode.FORBIDDEN)
 
 @inlineCallbacks
+def POST_handler_action(self, request):
+    
+    action = request.args["action"][0]
+    if hasattr(self, "POSTactionDispatch") and action in self.POSTactionDispatch:
+        result = (yield self.POSTactionDispatch[action](self, request))
+        returnValue(result)
+
+    raise HTTPError(StatusResponse(
+        responsecode.BAD_REQUEST,
+        "No POST action handler for '%s' on this resource" % (action,),
+    ))
+    
+ at inlineCallbacks
 def POST_handler_add_member(self, request):
 
     # Handle ;add-member

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/put_common.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/put_common.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/method/put_common.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -133,6 +133,7 @@
         internal_request=False,
         processing_organizer=None,
         returnData=False,
+        perUserMergeData=None,
     ):
         """
         Function that does common PUT/COPY/MOVE behavior.
@@ -155,6 +156,7 @@
         @param internal_request:   True if this request originates internally and needs to bypass scheduling authorization checks.
         @param processing_organizer: True if implicit processing for an organizer, False if for an attendee, None if not implicit processing.
         @param returnData:         True if the caller wants the actual data written to the store returned
+        @param perUserMergeData:   L{Component} for per user data to merge in
         """
         
         # Check that all arguments are valid
@@ -196,19 +198,27 @@
         self.internal_request = internal_request
         self.processing_organizer = processing_organizer
         self.returnData = returnData
+        self.perUserMergeData = perUserMergeData
 
         self.access = None
         self.hasPrivateComments = False
         self.isScheduleResource = False
 
+    def partialValidation(self):
+        """
+        Do a partial validation of source and destination calendar data.
+        """
+        return self.fullValidation(True)
+
     @inlineCallbacks
-    def fullValidation(self):
+    def fullValidation(self, partial=False):
         """
         Do full validation of source and destination calendar data.
         """
 
-        # Basic validation
-        self.validIfScheduleMatch()
+        # Basic validation - we skip this for the partial case
+        if not partial:
+            self.validIfScheduleMatch()
 
         if self.destinationcal:
 
@@ -984,7 +994,9 @@
         if self.calendar:
             accessUID = (yield self.destination.resourceOwnerPrincipal(self.request))
             accessUID = accessUID.principalUID() if accessUID else ""
-            if self.destination.exists() and self.destinationcal:
+            if self.perUserMergeData is not None:
+                oldCal = self.perUserMergeData
+            elif self.destination.exists() and self.destinationcal:
                 oldCal = yield self.destination.iCalendar()
             else:
                 oldCal = None

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/scheduling/icaldiff.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/scheduling/icaldiff.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/scheduling/icaldiff.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -595,8 +595,8 @@
         rdates = component.properties("RDATE")
         for rdate in rdates:
             for value in rdate.value():
-                if isinstance(value, PyCalendarDateTime):
-                    value = value.duplicate().adjustToUTC()
+                if isinstance(value.getValue(), PyCalendarDateTime):
+                    value = value.getValue().duplicate().adjustToUTC()
                 newrdates.add(value)
         
         # EXDATEs

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/stdconfig.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/stdconfig.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/stdconfig.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -533,6 +533,8 @@
     "MaxResourcesBatchUpload" : 100,      # Maximum number of resources in a batch POST
     "MaxBytesBatchUpload"     : 10485760, # Maximum size of a batch POST (10 MB)
     
+    "EnableRecurrenceSplitting" : True,   # POST to trigger recurrence split
+
     "Sharing": {
         "Enabled"             : False, # Overall on/off switch
         "AllowExternalUsers"  : False, # External (non-principal) sharees allowed
@@ -1457,6 +1459,8 @@
             compliance += caldavxml.caldav_query_extended_compliance
         if configDict.EnableDefaultAlarms:
             compliance += caldavxml.caldav_default_alarms_compliance
+        if configDict.EnableRecurrenceSplitting:
+            compliance += customxml.calendarserver_recurrence_split_compliance
     else:
         compliance = ()
 

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/storebridge.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/storebridge.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/storebridge.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -54,7 +54,8 @@
 from twistedcaldav.carddavxml import carddav_namespace
 from twistedcaldav.config import config
 from twistedcaldav.ical import Component as VCalendar, Property as VProperty,\
-    InvalidICalendarDataError, iCalendarProductID, allowedComponents
+    InvalidICalendarDataError, iCalendarProductID, allowedComponents, Component,\
+    Property
 from twistedcaldav.memcachelock import MemcacheLock, MemcacheLockTimeoutError
 from twistedcaldav.method.put_addressbook_common import StoreAddressObjectResource
 from twistedcaldav.method.put_common import StoreCalendarObjectResource
@@ -66,6 +67,10 @@
 from twistedcaldav.schedule import ScheduleInboxResource
 from twistedcaldav.scheduling.implicit import ImplicitScheduler
 from twistedcaldav.vcard import Component as VCard, InvalidVCardDataError
+from twistedcaldav.customxml import calendarserver_namespace
+import uuid
+from twistedcaldav.datafilters.peruserdata import PerUserDataFilter
+from pycalendar.datetime import PyCalendarDateTime
 
 """
 Wrappers to translate between the APIs in L{txdav.caldav.icalendarstore} and
@@ -115,9 +120,9 @@
                 FORBIDDEN,
                 "Property cannot be changed: %s" % (property.sname(),)
             ))
-            
 
 
+
     def delete(self, qname, uid=None):
         try:
             del self._newPropertyStore[self._convertKey(qname)]
@@ -232,7 +237,7 @@
     def liveProperties(self):
 
         props = super(_CommonHomeChildCollectionMixin, self).liveProperties()
-        
+
         if config.MaxResourcesPerCollection:
             props += (customxml.MaxResources.qname(),)
 
@@ -297,14 +302,14 @@
 
         if self._newStoreObject:
             newStoreObject = yield self._newStoreObject.objectResourceWithName(name)
-    
+
             similar = self._childClass(
                 newStoreObject,
                 self._newStoreObject,
                 name,
                 principalCollections=self._principalCollections
             )
-    
+
             self.propagateTransaction(similar)
             returnValue(similar)
         else:
@@ -360,26 +365,26 @@
         """
         Override to pre-load children in certain collection types for better performance.
         """
-        
+
         if depth == "1":
             if names:
                 yield self._newStoreObject.objectResourcesWithNames(names)
             else:
                 yield self._newStoreObject.objectResources()
-        
+
         result = (yield super(_CommonHomeChildCollectionMixin, self).findChildrenFaster(
             depth, request, okcallback, badcallback, missingcallback, names, privileges, inherited_aces
         ))
-        
+
         returnValue(result)
-    
+
     @inlineCallbacks
     def createCollection(self):
         """
         Override C{createCollection} to actually do the work.
         """
         self._newStoreObject = (yield self._newStoreParentHome.createChildWithName(self._name))
-        
+
         # Re-initialize to get stuff setup again now we have a "real" object
         self._initializeWithHomeChild(self._newStoreObject, self._parentResource)
 
@@ -389,7 +394,7 @@
     @inlineCallbacks
     def http_DELETE(self, request):
         """
-        Override http_DELETE to validate 'depth' header. 
+        Override http_DELETE to validate 'depth' header.
         """
 
         if not self.exists():
@@ -473,7 +478,7 @@
 
         # Actually delete it.
         yield self._newStoreObject.remove()
-        
+
         # Re-initialize to get stuff setup again now we have no object
         self._initializeWithHomeChild(None, self._parentResource)
 
@@ -515,7 +520,7 @@
         destination = yield request.locateResource(destinationURI)
         if destination.exists():
             returnValue(FORBIDDEN)
-            
+
         # Forget the destination now as after the move we will need to re-init it with its
         # new store object
         request._forgetResource(destination, destinationURI)
@@ -566,31 +571,31 @@
     @requiresPermissions(davxml.Bind())
     @inlineCallbacks
     def simpleBatchPOST(self, request):
-        
+
         # If CTag precondition
         yield self.checkCTagPrecondition(request)
-        
+
         # Look for return changed data option
         return_changed = self.checkReturnChanged(request)
 
         # Read in all data
         data = (yield allDataFromStream(request.stream))
-        
+
         components = self.componentsFromData(data)
         if components is None:
             raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, "Could not parse valid data from request body"))
-        
+
         # Build response
         xmlresponses = []
         for ctr, component in enumerate(components):
-            
+
             code = None
             error = None
             dataChanged = None
             try:
                 # Create a new name if one was not provided
                 name =  md5(str(ctr) + component.resourceUID() + str(time.time()) + request.path).hexdigest() + self.resourceSuffix()
-            
+
                 # Get a resource for the new item
                 newchildURL = joinURL(request.path, name)
                 newchild = (yield request.locateResource(newchildURL))
@@ -604,9 +609,9 @@
                     error = (error.namespace, error.name,)
             except Exception:
                 code = responsecode.BAD_REQUEST
-            
+
             if code is None:
-                
+
                 etag = (yield newchild.etag())
                 if not return_changed or dataChanged is None:
                     xmlresponses.append(
@@ -634,7 +639,7 @@
                             )
                         )
                     )
-                
+
             else:
                 xmlresponses.append(
                     davxml.StatusResponse(
@@ -646,9 +651,9 @@
                     ) if error else None,
                     )
                 )
-        
+
         result = MultiStatusResponse(xmlresponses)
-        
+
         newctag = (yield self.getInternalSyncToken())
         result.headers.setRawHeaders("CTag", (newctag,))
 
@@ -659,16 +664,16 @@
         request.extendedLogItems["rcount"] = len(xmlresponses)
 
         returnValue(result)
-        
+
     @inlineCallbacks
     def crudBatchPOST(self, request, xmlroot):
-        
+
         # Need to force some kind of overall authentication on the request
         yield self.authorize(request, (davxml.Read(), davxml.Write(),))
 
         # If CTag precondition
         yield self.checkCTagPrecondition(request)
-        
+
         # Look for return changed data option
         return_changed = self.checkReturnChanged(request)
 
@@ -680,7 +685,7 @@
         updateCount = 0
         deleteCount = 0
         for xmlchild in xmlroot.children:
-            
+
             # Determine the multiput operation: create, update, delete
             href = xmlchild.childOfType(davxml.HRef.qname())
             set = xmlchild.childOfType(davxml.Set.qname())
@@ -688,11 +693,11 @@
             xmldata_root = prop if prop else set
             xmldata = xmldata_root.childOfType(self.xmlDataElementType().qname()) if xmldata_root is not None else None
             if href is None:
-                
+
                 if xmldata is None:
                     raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, "Could not parse valid data from request body without a DAV:Href present"))
-                
-                # Do privilege check on collection once 
+
+                # Do privilege check on collection once
                 if checkedBindPrivelege is None:
                     try:
                         yield self.authorize(request, (davxml.Bind(),))
@@ -716,7 +721,7 @@
                     yield self.crudUpdate(request, str(href), xmldata, ifmatch, return_changed, xmlresponses)
                     updateCount += 1
                 else:
-                    # Do privilege check on collection once 
+                    # Do privilege check on collection once
                     if checkedUnbindPrivelege is None:
                         try:
                             yield self.authorize(request, (davxml.Unbind(),))
@@ -726,9 +731,9 @@
 
                     yield self.crudDelete(request, str(href), ifmatch, xmlresponses, checkedUnbindPrivelege);
                     deleteCount += 1
-        
+
         result = MultiStatusResponse(xmlresponses)
-        
+
         newctag = (yield self.getInternalSyncToken())
         result.headers.setRawHeaders("CTag", (newctag,))
 
@@ -748,7 +753,7 @@
 
     @inlineCallbacks
     def crudCreate(self, request, xmldata, xmlresponses, return_changed, hasPrivilege):
-        
+
         code = None
         error = None
         try:
@@ -760,7 +765,7 @@
 
             # Create a new name if one was not provided
             name =  md5(str(componentdata) + str(time.time()) + request.path).hexdigest() + self.resourceSuffix()
-        
+
             # Get a resource for the new item
             newchildURL = joinURL(request.path, name)
             newchild = (yield request.locateResource(newchildURL))
@@ -777,7 +782,7 @@
 
         except Exception:
             code = responsecode.BAD_REQUEST
-        
+
         if code is None:
             etag = (yield newchild.etag())
             xmlresponses.append(
@@ -823,7 +828,7 @@
             etag = (yield updateResource.etag())
             if ifmatch and ifmatch != etag.generate():
                 raise HTTPError(responsecode.PRECONDITION_FAILED)
-            
+
             yield self.storeResourceData(request, updateResource, href, component, componentdata)
 
             # FIXME: figure out return_changed behavior
@@ -837,7 +842,7 @@
 
         except Exception:
             code = responsecode.BAD_REQUEST
-        
+
         if code is None:
             xmlresponses.append(
                 davxml.PropertyStatusResponse(
@@ -879,7 +884,7 @@
                 raise HTTPError(responsecode.PRECONDITION_FAILED)
 
             yield deleteResource.storeRemove(
-                request, 
+                request,
                 True,
                 href,
             )
@@ -893,7 +898,7 @@
 
         except Exception:
             code = responsecode.BAD_REQUEST
-        
+
         if code is None:
             xmlresponses.append(
                 davxml.StatusResponse(
@@ -923,7 +928,7 @@
     """
     Functions common to calendar and inbox collections
     """
-    
+
     # Support component set behaviors
     def setSupportedComponentSet(self, support_components_property):
         """
@@ -931,7 +936,7 @@
         """
         support_components = tuple([comp.attributes["name"].upper() for comp in support_components_property.children])
         return self.setSupportedComponents(support_components)
-    
+
     def getSupportedComponentSet(self):
         comps = self._newStoreObject.getSupportedComponents()
         if comps:
@@ -949,14 +954,14 @@
         @param components: list of names of components to support
         @type components: C{list}
         """
-        
+
         # Validate them first - raise on failure
         if not self.validSupportedComponents(components):
             raise HTTPError(StatusResponse(responsecode.FORBIDDEN, "Invalid CALDAV:supported-calendar-component-set"))
 
         support_components = ",".join(sorted([comp.upper() for comp in components]))
         return maybeDeferred(self._newStoreObject.setSupportedComponents, support_components)
-    
+
     def getSupportedComponents(self):
         comps = self._newStoreObject.getSupportedComponents()
         if comps:
@@ -977,13 +982,13 @@
             return components in (("VEVENT",), ("VTODO",),)
         return True
 
-    
+
 class CalendarCollectionResource(DefaultAlarmPropertyMixin, _CalendarCollectionBehaviorMixin, _CommonHomeChildCollectionMixin, CalDAVResource):
     """
     Wrapper around a L{txdav.caldav.icalendar.ICalendar}.
     """
 
- 
+
     def __init__(self, calendar, home, name=None, *args, **kw):
         """
         Create a CalendarCollectionResource from a L{txdav.caldav.icalendar.ICalendar}
@@ -1056,7 +1061,7 @@
                 assert subcalendar.name() == "VCALENDAR"
 
                 for component in subcalendar.subcomponents():
-                    
+
                     # Only insert VTIMEZONEs once
                     if component.name() == "VTIMEZONE":
                         tzid = component.propertyValue("TZID")
@@ -1081,7 +1086,7 @@
         Need to split a single VCALENDAR into separate ones based on UID with the
         appropriate VTIEMZONES included.
         """
-        
+
         results = []
 
         # Split into components by UID and TZID
@@ -1097,14 +1102,14 @@
                 by_tzid[subcomponent.propertyValue("TZID")] = subcomponent
             else:
                 by_uid.setdefault(subcomponent.propertyValue("UID"), []).append(subcomponent)
-        
+
         # Re-constitute as separate VCALENDAR objects
         for components in by_uid.values():
-            
+
             newvcal = VCalendar("VCALENDAR")
             newvcal.addProperty(VProperty("VERSION", "2.0"))
             newvcal.addProperty(VProperty("PRODID", vcal.propertyValue("PRODID")))
-            
+
             # Get the set of TZIDs and include them
             tzids = set()
             for component in components:
@@ -1117,13 +1122,13 @@
                     # We ignore the error and generate invalid ics which someone will
                     # complain about at some point
                     pass
-            
+
             # Now add each component
             for component in components:
                 newvcal.addComponent(component.duplicate())
- 
+
             results.append(newvcal)
-        
+
         return results
 
     @classmethod
@@ -1147,10 +1152,10 @@
             returnData = returnData,
         )
         yield storer.run()
-        
+
         returnValue(storer.storeddata if hasattr(storer, "storeddata") else None)
-            
 
+
     @inlineCallbacks
     def storeRemove(self, request, implicitly, where):
         """
@@ -1211,7 +1216,7 @@
         that calendar's name.
         """
         defaultCalendarType = (yield self.isDefaultCalendar(request))
-        
+
         result = (yield super(CalendarCollectionResource, self).http_MOVE(request))
         if result == NO_CONTENT:
             destinationURI = urlsplit(request.headers.getHeader("destination"))[2]
@@ -1536,7 +1541,7 @@
             # Invite shares use access mode from the invite
             if record.state != "ACCEPTED":
                 continue
-            
+
             userprivs = [
             ]
             if record.access in ("read-only", "read-write", "read-write-schedule",):
@@ -1644,7 +1649,7 @@
         except IOError, e:
             log.error("Unable to read attachment: %s, due to: %s" % (self, e,))
             raise HTTPError(responsecode.NOT_FOUND)
-            
+
         return Response(OK, {"content-type":self.contentType()}, stream)
 
 
@@ -1667,7 +1672,7 @@
 
     def http_PROPPATCH(self, request):
         """
-        No dead properties allowed on attachments. 
+        No dead properties allowed on attachments.
         """
         return FORBIDDEN
 
@@ -1737,7 +1742,7 @@
     @requiresPermissions(fromParent=[davxml.Unbind()])
     def http_DELETE(self, request):
         """
-        Override http_DELETE to validate 'depth' header. 
+        Override http_DELETE to validate 'depth' header.
         """
         if not self.exists():
             log.debug("Resource not found: %s" % (self,))
@@ -1747,7 +1752,7 @@
 
     def http_PROPPATCH(self, request):
         """
-        No dead properties allowed on object resources. 
+        No dead properties allowed on object resources.
         """
         if self._newStoreParent.objectResourcesHaveProperties():
             return super(_CommonObjectResource, self).http_PROPPATCH(request)
@@ -1851,7 +1856,7 @@
 
 class _CalendarObjectMetaDataMixin(object):
     """
-    Dynamically create the required meta-data for an object resource 
+    Dynamically create the required meta-data for an object resource
     """
 
     accessMode        = _MetadataProperty("accessMode")
@@ -2012,6 +2017,267 @@
         returnValue(NO_CONTENT)
 
 
+    @inlineCallbacks
+    def action_SplitRecurrence(self, request):
+        """
+        Split a recurring event into two at the specified RECURRENCE-ID, preserving per-attendee/per-user
+        data in the on-going event.
+        """
+
+        #
+        # Step 1: Various preconditions
+        #
+
+        # Feature must be enabled
+        if not config.EnableRecurrenceSplitting:
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "valid-recurrence-split"),
+                "Recurrence splitting is not enabled",
+            ))
+            
+        # Not inbox
+        if self._newStoreObject._calendar.name() == "inbox":
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "supported-recurrence-split"),
+                "Cannot split inbox resources",
+            ))
+
+        # Do If-Schedule-Tag-Match behavior first
+        # Important: this should only ever be done when called
+        # directly as a result of an HTTP POST to ensure the proper If-
+        # header is used in this test.
+        self.validIfScheduleMatch(request)
+
+        # Event must be recurring
+        oldcalendar = (yield self.iCalendar())
+        if not oldcalendar.isRecurring():
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "supported-recurrence-split"),
+                "Cannot split non-recurring calendar resources",
+            ))
+
+        # Specified RECURRENCE-ID must exist and be valid
+        rid = request.args.get("recurrence-id", [None])[0]
+        if rid is None:
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "valid-recurrence-split"),
+                "Missing recurrence_id parameter",
+            ))
+        rid = PyCalendarDateTime.parseText(rid)
+        if not oldcalendar.validInstance(rid):
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "valid-recurrence-split"),
+                "Invalid recurrence_id parameter - does not match a valid instance",
+            ))
+
+        # Must contain valid iCalendar data in the request body
+        content_type = request.headers.getHeader("content-type")
+        if content_type is not None and (content_type.mediaType, content_type.mediaSubtype) != ("text", "calendar"):
+            log.err("MIME type %s not allowed in calendar collection" % (content_type,))
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (caldav_namespace, "supported-calendar-data"),
+                "Invalid MIME type for calendar resource",
+            ))
+        newcalendar = (yield allDataFromStream(request.stream))
+        if not hasattr(request, "extendedLogItems"):
+            request.extendedLogItems = {}
+        request.extendedLogItems["cl"] = str(len(newcalendar)) if newcalendar else "0"
+
+        # We must have some data at this point
+        newcalendar = Component.fromString(newcalendar)
+        if newcalendar is None:
+            # Use correct DAV:error response
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (caldav_namespace, "valid-calendar-data"),
+                "Valid calendar data must be present in the request"
+            ))
+
+        #
+        # Look for other events in the set that are after the split - those will need to be removed
+        #
+
+        #
+        # Step 2: Check for implicit scheduling and initiate ImplicitUID lock
+        #
+        pass
+
+        #
+        # Step 3: Save the new event with per-user data preserved
+        #
+
+        # Add the recurrence-set relation property
+        recurrenceSet = oldcalendar.recurrenceSet()
+        if recurrenceSet is None:
+            recurrenceSet = str(uuid.uuid4())
+        if newcalendar.recurrenceSet() is not None and newcalendar.recurrenceSet() != recurrenceSet:
+            raise HTTPError(ErrorResponse(
+                responsecode.FORBIDDEN,
+                (calendarserver_namespace, "valid-recurrence-split"),
+                "Cannot specify a RECURRENCE-SET property in the new data that is different from the original data",
+            ))
+        if newcalendar.recurrenceSet() is None:
+            newcalendar.addPropertyToAllComponents(Property("RECURRENCE-SET", recurrenceSet))
+
+        # Find current user's UID and extract others' per-user data from old calendar
+        accessUUID = (yield self.resourceOwnerPrincipal(request))
+        accessUUID = accessUUID.principalUID() if accessUUID else ""
+        oldperuser = PerUserDataFilter(accessUUID).extract(oldcalendar, rid, newcalendar.resourceUID())
+
+        # Get a resource for the new item
+        parentURL = parentForURL(request.path)
+        parent = (yield request.locateResource(parentURL))
+        newname =  str(uuid.uuid4()) + ".ics"
+        newchildURL = joinURL(parentURL, newname)
+        newchild = yield request.locateResource(newchildURL)
+        newchild._url = newchildURL
+
+        # Check that the new data is valid for storing in this collection
+        yield StoreCalendarObjectResource(
+            request = request,
+            destination = newchild,
+            destination_uri = newchildURL,
+            destinationcal = True,
+            destinationparent = parent,
+            calendar = newcalendar,
+            allowImplicitSchedule = False,
+            perUserMergeData = oldperuser,
+        ).run()
+
+        #
+        # Step 4: Update existing event to truncate recurrence
+        #
+
+        # Truncate recurrence
+        oldcalendar = PerUserDataFilter("").truncateRecurrenceDueToSplit(oldcalendar, rid)
+
+        # Add the recurrence-set relation property
+        if oldcalendar.recurrenceSet() is None:
+            oldcalendar.addPropertyToAllComponents(Property("RECURRENCE-SET", recurrenceSet))
+
+        # Store the data back
+        yield self._newStoreObject.setComponent(oldcalendar)
+
+        #
+        # Step 5: Trigger implicit scheduling and release the lock when done
+        #
+        pass
+    
+        #
+        # Step 6: Create response
+        #
+        # Return something that is equivalent to a multiget on the old and new resources with the
+        # client requesting getetag and option ally calendar-data (depending on whether Prefer is used).
+        #
+
+        # Look for Prefer header
+        prefer = request.headers.getHeader("prefer", {})
+        returnRepresentation = "return-representation" in prefer
+        
+        # Build responses
+        xmlresponses = []
+
+        # Old resource
+        uri = request.path
+        etag = (yield self.etag())
+        props = (
+            davxml.GETETag.fromString(etag.generate()),
+        )
+        if returnRepresentation:
+            calendar = (yield self.iCalendarForUser(request))
+            props += (
+                caldavxml.CalendarData.fromCalendar(calendar),
+            )
+        xmlresponses.append(
+            davxml.PropertyStatusResponse(
+                davxml.HRef.fromString(uri),
+                davxml.PropertyStatus(
+                    davxml.PropertyContainer(*props),
+                    davxml.Status.fromResponseCode(responsecode.OK),
+                )
+            )
+        )
+
+        # New resource
+        uri = newchildURL
+        etag = (yield newchild.etag())
+        props = (
+            davxml.GETETag.fromString(etag.generate()),
+        )
+        if returnRepresentation:
+            calendar = (yield newchild.iCalendarForUser(request))
+            props += (
+                caldavxml.CalendarData.fromCalendar(calendar),
+            )
+        xmlresponses.append(
+            davxml.PropertyStatusResponse(
+                davxml.HRef.fromString(uri),
+                davxml.PropertyStatus(
+                    davxml.PropertyContainer(*props),
+                    davxml.Status.fromResponseCode(responsecode.CREATED),
+                )
+            )
+        )
+
+        result = MultiStatusResponse(xmlresponses)
+        returnValue(result)
+
+#        # Now look for scheduling
+#        scheduler = ImplicitScheduler()
+#        do_implicit_action, _ignore = (
+#            yield scheduler.testImplicitSchedulingSplitRecurrence(
+#                request, self, oldcalendar
+#            )
+#        )
+#        if do_implicit_action and scheduler.state != "organizer":
+#            raise HTTPError(ErrorResponse(
+#                responsecode.FORBIDDEN,
+#                (caldav_namespace, "valid-attendee-change"),
+#                "Cannot split recurrence",
+#            ))
+#
+#        if do_implicit_action:
+#            lock = MemcacheLock(
+#                "ImplicitUIDLock",
+#                oldcalendar.resourceUID(),
+#                timeout=config.Scheduling.Options.UIDLockTimeoutSeconds,
+#                expire_time=config.Scheduling.Options.UIDLockExpirySeconds,
+#            )
+#
+#        try:
+#            if lock:
+#                yield lock.acquire()
+#
+#            yield super(CalendarObjectResource, self).storeRemove(request)
+#
+#            # Do scheduling
+#            yield scheduler.doImplicitScheduling()
+#
+#        except MemcacheLockTimeoutError:
+#            raise HTTPError(StatusResponse(
+#                CONFLICT,
+#                "Resource: %s currently in use on the server." % (where,))
+#            )
+#
+#        finally:
+#            if lock:
+#                # Release lock after commit or abort
+#                transaction.postCommit(lock.clean)
+#                transaction.postAbort(lock.clean)
+
+    # POST actions
+    POSTactionDispatch = {
+        "recurrence-split": action_SplitRecurrence,
+    }
+
+
+
 class AddressBookCollectionResource(_CommonHomeChildCollectionMixin, CalDAVResource):
     """
     Wrapper around a L{txdav.carddav.iaddressbook.IAddressBook}.
@@ -2082,7 +2348,7 @@
             returnData = returnData,
         )
         yield storer.run()
-        
+
         returnValue(storer.returndata if hasattr(storer, "returndata") else None)
 
     @inlineCallbacks
@@ -2140,7 +2406,7 @@
         that address book's name.
         """
         defaultAddressBook = (yield self.isDefaultAddressBook(request))
-        
+
         result = (yield super(AddressBookCollectionResource, self).http_MOVE(request))
         if result == NO_CONTENT:
             destinationURI = urlsplit(request.headers.getHeader("destination"))[2]
@@ -2379,7 +2645,7 @@
     @requiresPermissions(fromParent=[davxml.Unbind()])
     def http_DELETE(self, request):
         """
-        Override http_DELETE to validate 'depth' header. 
+        Override http_DELETE to validate 'depth' header.
         """
         if not self.exists():
             log.debug("Resource not found: %s" % (self,))
@@ -2389,7 +2655,7 @@
 
     def http_PROPPATCH(self, request):
         """
-        No dead properties allowed on notification objects. 
+        No dead properties allowed on notification objects.
         """
         return FORBIDDEN
 

Modified: CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/test/test_icalendar.py
===================================================================
--- CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/test/test_icalendar.py	2012-08-25 14:25:38 UTC (rev 9745)
+++ CalendarServer/branches/users/cdaboo/recurrence-split/twistedcaldav/test/test_icalendar.py	2012-08-25 14:27:26 UTC (rev 9746)
@@ -4764,6 +4764,336 @@
             elif changed:
                 self.fail("Truncation happened when not expected: %s" % (title,))
 
+    def test_truncate_recurrence_split(self):
+        
+        data = (
+            (
+                "1.1 - no recurrence",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.2 - truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.3 - truncation - until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070105T000000Z
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "1.4 - truncation - count",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;COUNT=2000
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.1 - overrides, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890-1
+RECURRENCE-ID:20070102T000000Z
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890-1
+RECURRENCE-ID:20070105T000000Z
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+BEGIN:VEVENT
+UID:12345-67890-1
+RECURRENCE-ID:20070102T000000Z
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.2 - exdates, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+EXDATE:20070102T000000Z
+EXDATE:20070105T000000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+EXDATE:20070102T000000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.3 - exdates list, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+EXDATE:20070102T000000Z,20070105T000000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+EXDATE:20070102T000000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.4 - rdates, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE:20070102T080000Z
+RDATE:20070105T080000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE:20070102T080000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.5 - rdates period, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE;VALUE=PERIOD:20070102T080000Z/PT2H
+RDATE;VALUE=PERIOD:20070105T080000Z/PT2H
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE;VALUE=PERIOD:20070102T080000Z/PT2H
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.6 - rdates list, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE:20070102T080000Z,20070105T080000Z
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE:20070102T080000Z
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+            (
+                "2.7 - rdates period list, truncation - no count, no until",
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE;VALUE=PERIOD:20070102T080000Z/PT2H,20070105T080000Z/PT2H
+RRULE:FREQ=WEEKLY
+END:VEVENT
+END:VCALENDAR
+""",
+                PyCalendarDateTime.parseText("20070103T000000Z"),
+                """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890-1
+DTSTART:20070101T000000Z
+DTSTAMP:20080601T120000Z
+RDATE;VALUE=PERIOD:20070102T080000Z/PT2H
+RRULE:FREQ=WEEKLY;UNTIL=20070102T235959Z
+END:VEVENT
+END:VCALENDAR
+""",
+            ),
+        )
+        
+        for title, original, splitRID, result in data:
+            ical1 = Component.fromString(original.replace("\n", "\r\n"))
+            ical1.truncateRecurrenceDueToSplit(splitRID)
+            self.assertEqual(str(ical1), result.replace("\n", "\r\n"), "Failed comparison: %s" % (title))
+
     def test_valid_recurrence(self):
         
         data = (
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20120825/fa9bdb11/attachment-0001.html>


More information about the calendarserver-changes mailing list