fbe1428281de59f282fe5aeff80da5982be78bf7
[tine20] / tine20 / Calendar / Model / Rrule.php
1 <?php
2 /**
3  * Sql Calendar 
4  * 
5  * @package     Calendar
6  * @subpackage  Model
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Cornelius Weiss <c.weiss@metaways.de>
9  * @copyright   Copyright (c) 2009-2012 Metaways Infosystems GmbH (http://www.metaways.de)
10  */
11
12 /**
13  * Model of an rrule
14  *
15  * @todo move calculations to rrule controller
16  * @todo move date helpers to Tinebase_DateHelpers
17  * @todo rrule->until must be adopted to orginator tz for computations
18  * @todo rrule models should  string-->model converted from backend and viceavice
19  * 
20  * @package Calendar
21  * @subpackage  Model
22  */
23 class Calendar_Model_Rrule extends Tinebase_Record_Abstract
24 {
25     /**
26      * supported freq types
27      */
28     const FREQ_DAILY     = 'DAILY';
29     const FREQ_WEEKLY    = 'WEEKLY';
30     const FREQ_MONTHLY   = 'MONTHLY';
31     const FREQ_YEARLY    = 'YEARLY';
32
33     /**
34      * weekdays
35      */
36     const WDAY_SUNDAY    = 'SU';
37     const WDAY_MONDAY    = 'MO';
38     const WDAY_TUESDAY   = 'TU';
39     const WDAY_WEDNESDAY = 'WE';
40     const WDAY_THURSDAY  = 'TH';
41     const WDAY_FRIDAY    = 'FR';
42     const WDAY_SATURDAY  = 'SA';
43     
44     /**
45      * maps weeksdays to digits
46      */
47     static $WEEKDAY_DIGIT_MAP = array(
48         self::WDAY_SUNDAY     => 0,
49         self::WDAY_MONDAY     => 1,
50         self::WDAY_TUESDAY    => 2,
51         self::WDAY_WEDNESDAY  => 3,
52         self::WDAY_THURSDAY   => 4,
53         self::WDAY_FRIDAY     => 5,
54         self::WDAY_SATURDAY   => 6
55     );
56     
57     const TS_HOUR = 3600;
58     const TS_DAY  = 86400;
59     
60     /**
61      * key in $_validators/$_properties array for the filed which 
62      * represents the identifier
63      * 
64      * @var string
65      */
66     protected $_identifier = 'id';
67     
68     /**
69      * application the record belongs to
70      *
71      * @var string
72      */
73     protected $_application = 'Calendar';
74     
75     /**
76      * validators
77      *
78      * @var array
79      */
80     protected $_validators = array(
81         'id'                   => array('allowEmpty' => true,  /*'Alnum'*/),
82         'freq'                 => array(
83             'allowEmpty' => true,
84             array('InArray', array(self::FREQ_DAILY, self::FREQ_MONTHLY, self::FREQ_WEEKLY, self::FREQ_YEARLY)),
85         ),
86         'interval'             => array('allowEmpty' => true, 'Int'   ),
87         'byday'                => array('allowEmpty' => true, 'Regex' => '/^[\-0-9A_Z,]{2,}$/'),
88         'bymonth'              => array('allowEmpty' => true, 'Int'   ),
89         'bymonthday'           => array('allowEmpty' => true, 'Int'   ),
90         'wkst'                 => array(
91             'allowEmpty' => true,
92             array('InArray', array(self::WDAY_SUNDAY, self::WDAY_MONDAY, self::WDAY_TUESDAY, self::WDAY_WEDNESDAY, self::WDAY_THURSDAY, self::WDAY_FRIDAY, self::WDAY_SATURDAY)),
93         ),
94         'until'                => array('allowEmpty' => true          ),
95         'count'                => array('allowEmpty' => true, 'Int'   ),
96     );
97     
98     /**
99      * datetime fields
100      *
101      * @var array
102      */
103     protected $_datetimeFields = array(
104         'until',
105     );
106     
107     /**
108      * @var array supported rrule parts
109      */
110     protected $_rruleParts = array('freq', 'interval', 'until', 'count', 'wkst', 'byday', 'bymonth', 'bymonthday');
111     
112     /**
113      * @see /Tinebase/Record/Abstract::__construct
114      */
115     public function __construct($_data = NULL, $_bypassFilters = false, $_convertDates = true)
116     {
117         $rruleString = NULL;
118         
119         if (is_string($_data)) {
120             $rruleString = $_data;
121             $_data = NULL;
122         }
123         
124         parent::__construct($_data, $_bypassFilters, $_convertDates);
125         
126         if ($rruleString) {
127             $this->setFromString($rruleString);
128         }
129     }
130     
131     /**
132      * set from ical rrule string
133      *
134      * @param string $_rrule
135      */
136     public function setFromString($_rrule)
137     {
138         if ($_rrule) {
139             $parts = explode(';', $_rrule);
140             foreach ($parts as $part) {
141                 list($key, $value) = explode('=', $part);
142                 $part = strtolower($key);
143                 if (! in_array($part, $this->_rruleParts)) {
144                     throw new Tinebase_Exception_UnexpectedValue("$part is not a known rrule part");
145                 }
146                 $this->$part = $value;
147             }
148         }
149     }
150     
151     /**
152      * creates a rrule from string
153      *
154      * @param string $_rruleString
155      * @return Calendar_Model_Rrule
156      */
157     public static function getRruleFromString($_rruleString)
158     {
159         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
160         $rrule->setFromString($_rruleString);
161         
162         return $rrule;
163     }
164     
165     /**
166      * returns a ical rrule string
167      *
168      * @return string
169      */
170     public function __toString()
171     {
172         $stringParts = array();
173         
174         foreach ($this->_rruleParts as $part) {
175             if (!empty($this->$part)) {
176                 $value = $this->$part instanceof DateTime ? $this->$part->toString(self::ISO8601LONG) : $this->$part;
177                 $stringParts[] = strtoupper($part) . '=' . $value;
178             }
179         }
180         
181         return implode(';', $stringParts);
182     }
183     
184     /**
185      * set properties and convert them into internal representatin on the fly
186      *
187      * @param string $_name
188      * @param mixed $_value
189      * @return void
190      */
191     public function __set($_name, $_value) {
192         switch ($_name) {
193             case 'until':
194                 if (! empty($_value)) {
195                     if ($_value instanceof DateTime) {
196                         $this->_properties['until'] = $_value;
197                     } else {
198                         $this->_properties['until'] = new Tinebase_DateTime($_value);
199                     }
200                 }
201                 break;
202             case 'bymonth':
203             case 'bymonthday':
204                 if (! empty($_value)) {
205                     $values = explode(',', $_value);
206                     $this->_properties[$_name] = $values[0];
207                 }
208                 break;
209             default:
210                 parent::__set($_name, $_value);
211                 break;
212         }
213     }
214     
215     /**
216      * gets record related properties
217      * 
218      * @param string _name of property
219      * @throws Tinebase_Exception_UnexpectedValue
220      * @return mixed value of property
221      */
222     public function __get($_name)
223     {
224         $value = parent::__get($_name);
225         
226         switch ($_name) {
227             case 'interval':
228                 return (int) $value > 1 ? (int) $value : 1;
229                 break;
230             default:
231                 return $value;
232                 break;
233         }
234     }
235     
236     /**
237      * validate and filter the the internal data
238      *
239      * @param $_throwExceptionOnInvalidData
240      * @return bool
241      * @throws Tinebase_Exception_Record_Validation
242      */
243     public function isValid($_throwExceptionOnInvalidData = false)
244     {
245         $isValid = parent::isValid($_throwExceptionOnInvalidData);
246         
247         if (isset($this->_properties['count']) && isset($this->_properties['until'])) {
248             $isValid = $this->_isValidated = false;
249             
250             if ($_throwExceptionOnInvalidData) {
251                 $e = new Tinebase_Exception_Record_Validation('count and until can not be set both');
252                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . $e);
253                 throw $e;
254             }
255         }
256         
257         return $isValid;
258     }
259     /************************* Recurrence computation *****************************/
260     
261     /**
262      * merges Recurrences of given events into the given event set
263      * 
264      * @param  Tinebase_Record_RecordSet    $_events
265      * @param  Tinebase_DateTime                    $_from
266      * @param  Tinebase_DateTime                    $_until
267      * @return void
268      */
269     public static function mergeRecurrenceSet($_events, $_from, $_until)
270     {
271         //compute recurset
272         $candidates = $_events->filter('rrule', "/^FREQ.*/", TRUE);
273        
274         foreach ($candidates as $candidate) {
275             try {
276                 $exceptions = $_events->filter('recurid', "/^{$candidate->uid}-.*/", TRUE);
277                 
278                 $recurSet = Calendar_Model_Rrule::computeRecurrenceSet($candidate, $exceptions, $_from, $_until);
279                 foreach ($recurSet as $event) {
280                     $_events->addRecord($event);
281                 }
282                 
283                 // check if candidate/baseEvent has an exception itself -> in this case remove baseEvent from set
284                 if (is_array($candidate->exdate) && in_array($candidate->dtstart, $candidate->exdate)) {
285                     $_events->removeRecord($candidate);
286                 }
287                 
288             } catch (Exception $e) {
289                if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " could not compute recurSet of event: {$candidate->getId()} ");
290                continue;
291             }
292         }
293     }
294     
295     /**
296      * add given recurrence to given set and to nessesary adoptions
297      * 
298      * @param Calendar_Model_Event      $_recurrence
299      * @param Tinebase_Record_RecordSet $_eventSet
300      */
301     protected static function addRecurrence($_recurrence, $_eventSet)
302     {
303         $_recurrence->setId('fakeid' . $_recurrence->uid . $_recurrence->dtstart->getTimeStamp());
304         
305         // adjust alarms
306         if ($_recurrence->alarms instanceof Tinebase_Record_RecordSet) {
307             foreach($_recurrence->alarms as $alarm) {
308                 $alarm->alarm_time = clone $_recurrence->dtstart;
309                 $alarm->alarm_time->subMinute($alarm->getOption('minutes_before'));
310             }
311         }
312         
313         $_eventSet->addRecord($_recurrence);
314     }
315     
316     /**
317      * merge recurrences amd remove all events that do not match period filter
318      * 
319      * @param Tinebase_Record_RecordSet $_events
320      * @param Calendar_Model_EventFilter $_filter
321      */
322     public static function mergeAndRemoveNonMatchingRecurrences(Tinebase_Record_RecordSet $_events, Calendar_Model_EventFilter $_filter = NULL)
323     {
324         if (!$_filter) {
325             return;
326         }
327         
328         $period = $_filter->getFilter('period');
329         if ($period) {
330             self::mergeRecurrenceSet($_events, $period->getFrom(), $period->getUntil());
331             
332             foreach ($_events as $event) {
333                 if (! $event->isInPeriod($period)) {
334                     if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . ' (' . __LINE__ 
335                         . ') Removing not matching event ' . $event->summary);
336                     $_events->removeRecord($event);
337                 }
338             }
339         }
340     }
341     
342     /**
343      * returns next occurrence _ignoring exceptions_ or NULL if there is none/not computable
344      * 
345      * NOTE: an ongoing event during $from [start, end[ is considered as next 
346      * NOTE: for previous events on ongoing event is considered as previous
347      *  
348      * NOTE: computing the next occurrence of an open end rrule can be dangerous, as it might result
349      *       in a endless loop. Therefore we only make a limited number of attempts before giving up.
350      * 
351      * @param  Calendar_Model_Event         $_event
352      * @param  Tinebase_Record_RecordSet    $_exceptions
353      * @param  Tinebase_DateTime            $_from
354      * @param  Int                          $_which
355      * @return Calendar_Model_Event|NULL
356      */
357     public static function computeNextOccurrence($_event, $_exceptions, $_from, $_which = 1)
358     {
359         if ($_which === 0 || ($_event->dtstart >= $_from && $_event->dtend > $_from)) {
360             return $_event;
361         }
362         
363         $freqMap = array(
364             self::FREQ_DAILY   => Tinebase_DateTime::MODIFIER_DAY,
365             self::FREQ_WEEKLY  => Tinebase_DateTime::MODIFIER_WEEK,
366             self::FREQ_MONTHLY => Tinebase_DateTime::MODIFIER_MONTH,
367             self::FREQ_YEARLY  => Tinebase_DateTime::MODIFIER_YEAR
368         );
369         
370         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
371         $rrule->setFromString($_event->rrule);
372         
373         $from  = clone $_from;
374         $until = clone $from;
375         $interval = $_which * $rrule->interval;
376         
377         // we don't want to compute ourself
378         $ownEvent = clone $_event;
379         $ownEvent->setRecurId();
380         $exceptions = clone $_exceptions;
381         $exceptions->addRecord($ownEvent);
382         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
383         
384         if ($_from->isEarlier($_event->dtstart)) {
385             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
386                 . ' from is ealier dtstart -> given event is next occurrence');
387             return $_event;
388         }
389         
390         $rangeDate = $_which > 0 ? $until : $from;
391         $rangeDate->add($interval, $freqMap[$rrule->freq]);
392         $attempts = 0;
393         
394         if ($_event->rrule_until instanceof DateTime && Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
395             . ' Event rrule_until: ' . $_event->rrule_until->toString());
396         
397         while (TRUE) {
398             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
399                 . ' trying to find next occurrence from ' . $from->toString());
400             
401             if ($_event->rrule_until instanceof DateTime && $from->isLater($_event->rrule_until)) {
402                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
403                     . ' passed rrule_until -> no further occurrences');
404                 return NULL;
405             }
406             
407             $until = ($_event->rrule_until instanceof DateTime && $until->isLater($_event->rrule_until))
408                 ? clone $_event->rrule_until 
409                 : $until;
410
411             $recurSet->merge(self::computeRecurrenceSet($_event, $exceptions, $from, $until));
412             $attempts++;
413             
414             if (count($recurSet) >= abs($_which)) {
415                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
416                     . " found next occurrence after $attempts attempt(s)");
417                 break;
418             }
419             
420             if ($attempts > count($exceptions) + 5) {
421                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
422                     . " could not find the next occurrence after $attempts attempts, giving up");
423                 return NULL;
424             }
425             
426             $from->add($interval, $freqMap[$rrule->freq]);
427             $until->add($interval, $freqMap[$rrule->freq]);
428         }
429         
430         $recurSet->sort('dtstart', $_which > 0 ? 'ASC' : 'DESC');
431         return $recurSet[abs($_which)-1];
432     }
433     
434     /**
435      * Computes the Recurrence set of the given event leaving out $_event->exdate and $_exceptions
436      * 
437      * @todo respect rrule_until!
438      *
439      * @param  Calendar_Model_Event         $_event
440      * @param  Tinebase_Record_RecordSet    $_exceptions
441      * @param  Tinebase_DateTime            $_from
442      * @param  Tinebase_DateTime            $_until
443      * @return Tinebase_Record_RecordSet
444      * @throws Tinebase_Exception_UnexpectedValue
445      */
446     public static function computeRecurrenceSet($_event, $_exceptions, $_from, $_until)
447     {
448         if (! $_event->dtstart instanceof Tinebase_DateTime) {
449             throw new Tinebase_Exception_UnexpectedValue('Event needs DateTime dtstart: ' . print_r($_event->toArray(), TRUE));
450         }
451         
452         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
453         $rrule->setFromString($_event->rrule);
454         
455         $exceptionRecurIds = self::getExceptionsRecurIds($_event, $_exceptions);
456         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
457         
458         switch ($rrule->freq) {
459             case self::FREQ_DAILY:
460                 
461                 self::_computeRecurDaily($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
462                 break;
463                 
464             case self::FREQ_WEEKLY:
465                 // default BYDAY clause
466                 if (! $rrule->byday) {
467                     $rrule->byday = array_search($_event->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP);
468                 }
469                 
470                 if (! $rrule->wkst) {
471                     // @TODO if organizer has an account get its locales wkst
472                     $rrule->wkst = self::WDAY_MONDAY;
473                 }
474                 $weekDays = array_keys(self::$WEEKDAY_DIGIT_MAP);
475                 array_splice($weekDays, 0, 0, array_splice($weekDays, array_search($rrule->wkst, $weekDays)));
476                     
477                 $dailyrrule = clone ($rrule);
478                 $dailyrrule->freq = self::FREQ_DAILY;
479                 $dailyrrule->interval = 7 * $rrule->interval;
480                 
481                 $eventLength = $_event->dtstart->diff($_event->dtend);
482                 
483                 foreach (explode(',', $rrule->byday) as $recurWeekDay) {
484                     // NOTE: in weekly computation, each wdays base event is a recur instance itself
485                     $baseEvent = clone $_event;
486                     
487                     // NOTE: skipping must be done in organizer_tz
488                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
489                     $direction = array_search($recurWeekDay, $weekDays) >= array_search(array_search($baseEvent->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP), $weekDays) ? +1 : -1;
490                     self::skipWday($baseEvent->dtstart, $recurWeekDay, $direction, TRUE);
491                     $baseEvent->dtstart->setTimezone('UTC');
492                     
493                     $baseEvent->dtend = clone($baseEvent->dtstart);
494                     $baseEvent->dtend->add($eventLength);
495                     
496                     self::_computeRecurDaily($baseEvent, $dailyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
497                     
498                     // check if base event (recur instance) needs to be added to the set
499                     if ($baseEvent->dtstart > $_event->dtstart && $baseEvent->dtstart >= $_from && $baseEvent->dtstart < $_until) {
500                         if (! in_array($baseEvent->setRecurId(), $exceptionRecurIds)) {
501                             self::addRecurrence($baseEvent, $recurSet);
502                         }
503                     }
504                 }
505                 break;
506                 
507             case self::FREQ_MONTHLY:
508                 if ($rrule->byday) {
509                     self::_computeRecurMonthlyByDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
510                 } else {
511                     self::_computeRecurMonthlyByMonthDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
512                 }
513                 break;
514                 
515             case self::FREQ_YEARLY:
516                 $yearlyrrule = clone $rrule;
517                 $yearlyrrule->freq = self::FREQ_MONTHLY;
518                 $yearlyrrule->interval = 12;
519                 
520                 $baseEvent = clone $_event;
521                 $originatorsDtstart = clone $baseEvent->dtstart;
522                 $originatorsDtstart->setTimezone($_event->originator_tz);
523                 
524                 // @TODO respect BYMONTH
525                 if ($rrule->bymonth && $rrule->bymonth != $originatorsDtstart->format('n')) {
526                     // adopt
527                     $diff = (12 + $rrule->bymonth - $originatorsDtstart->format('n')) % 12;
528                     
529                     // NOTE: skipping must be done in organizer_tz
530                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
531                     $baseEvent->dtend->setTimezone($_event->originator_tz);
532                     $baseEvent->dtstart->addMonth($diff);
533                     $baseEvent->dtend->addMonth($diff);
534                     $baseEvent->dtstart->setTimezone('UTC');
535                     $baseEvent->dtend->setTimezone('UTC');
536                     
537                     // check if base event (recur instance) needs to be added to the set
538                     if ($baseEvent->dtstart->isLater($_from) && $baseEvent->dtstart->isEarlier($_until)) {
539                         if (! in_array($baseEvent->setRecurId(), $exceptionRecurIds)) {
540                             self::addRecurrence($baseEvent, $recurSet);
541                         }
542                     }
543                 }
544                 
545                 if ($rrule->byday) {
546                     self::_computeRecurMonthlyByDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
547                 } else {
548                     self::_computeRecurMonthlyByMonthDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
549                 }
550
551                 break;
552                 
553         }
554         
555         return $recurSet;
556     }
557     
558     /**
559      * returns array of exception recurids
560      *
561      * @param  Calendar_Model_Event         $_event
562      * @param  Tinebase_Record_RecordSet    $_exceptions
563      * @return array
564      */
565     public static function getExceptionsRecurIds($_event, $_exceptions)
566     {
567         $recurIds = $_exceptions->recurid;
568         
569         if (! empty($_event->exdate)) {
570             $exdates = is_array($_event->exdate) ? $_event->exdate : array($_event->exdate);
571             foreach ($exdates as $exdate) {
572                 $recurIds[] = $_event->uid . '-' . $exdate->toString(Tinebase_Record_Abstract::ISO8601LONG);
573             }
574         }
575         return array_values($recurIds);
576     }
577     
578     /**
579      * gets an cloned event to be used for new recur events
580      * 
581      * @param  Calendar_Model_Event         $_event
582      * @return Calendar_Model_Event         $_event
583      */
584     public static function cloneEvent($_event)
585     {
586         $clone = clone $_event;
587         $clone->setId(NULL);
588         //unset($clone->exdate);
589         //unset($clone->rrule);
590         //unset($clone->rrule_until);
591         
592         return $clone;
593     }
594     
595     /**
596      * computes daily recurring events and inserts them into given $_recurSet
597      *
598      * @param Calendar_Model_Event      $_event
599      * @param Calendar_Model_Rrule      $_rrule
600      * @param array                     $_exceptionRecurIds
601      * @param Tinebase_DateTime                 $_from
602      * @param Tinebase_DateTime                 $_until
603      * @param Tinebase_Record_RecordSet $_recurSet
604      * @return void
605      */
606     protected static function _computeRecurDaily($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
607     {
608         $computationStartDate = clone $_event->dtstart;
609         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
610         
611         // if dtstart is before $_from, we compute the offset where to start our calculations
612         if ($_event->dtstart->isEarlier($_from)) {
613             $computationOffsetDays = floor(($_from->getTimestamp() - $_event->dtend->getTimestamp()) / (self::TS_DAY * $_rrule->interval)) * $_rrule->interval;
614             $computationStartDate->add($computationOffsetDays, Tinebase_DateTime::MODIFIER_DAY);
615         }
616         
617         $eventLength = $_event->dtstart->diff($_event->dtend);
618         
619         $originatorsOriginalDtstart = clone $_event->dtstart;
620         $originatorsOriginalDtstart->setTimezone($_event->originator_tz);
621         
622         while (true) {
623             $computationStartDate->addDay($_rrule->interval);
624             
625             $recurEvent = self::cloneEvent($_event);
626             $recurEvent->dtstart = clone ($computationStartDate);
627             
628             $originatorsDtstart = clone $recurEvent->dtstart;
629             $originatorsDtstart->setTimezone($_event->originator_tz);
630             
631             $recurEvent->dtstart->add($originatorsOriginalDtstart->get('I') - $originatorsDtstart->get('I'), Tinebase_DateTime::MODIFIER_HOUR);
632
633             //$recurEvent->dtstart->sub($originatorsDtstart->get('I') ? 1 : 0, Tinebase_DateTime::MODIFIER_HOUR);
634             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
635                 break;
636             }            
637             
638             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
639             $recurEvent->dtend = clone $recurEvent->dtstart;
640             $recurEvent->dtend->add($eventLength);
641             
642             $recurEvent->setRecurId();
643             
644             if ($_from->compare($recurEvent->dtend) >= 0) {
645                 continue;
646             }
647             
648             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
649                 self::addRecurrence($recurEvent, $_recurSet);
650             }
651         }
652     }
653     
654     /**
655      * computes monthly (bymonthday) recurring events and inserts them into given $_recurSet
656      *
657      * @param Calendar_Model_Event      $_event
658      * @param Calendar_Model_Rrule      $_rrule
659      * @param array                     $_exceptionRecurIds
660      * @param Tinebase_DateTime                 $_from
661      * @param Tinebase_DateTime                 $_until
662      * @param Tinebase_Record_RecordSet $_recurSet
663      * @return void
664      */
665     protected static function _computeRecurMonthlyByMonthDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
666     {
667         
668         $eventInOrganizerTZ = clone $_event;
669         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
670         
671         // some clients skip the monthday e.g. for yearly rrules
672         if (! $_rrule->bymonthday) {
673             $_rrule->bymonthday = $eventInOrganizerTZ->dtstart->format('j');
674         }
675         
676         // NOTE: non existing dates will be discarded (e.g. 31. Feb.)
677         //       for correct computations we deal with virtual dates, represented as arrays
678         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
679         // adopt startdate if rrule monthday != dtstart monthday
680         // in this case, the first instance is not the base event!
681         if ($_rrule->bymonthday != $computationStartDateArray['day']) {
682             $computationStartDateArray['day'] = $_rrule->bymonthday;
683             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, -1 * $_rrule->interval);
684         }
685         
686         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
687         
688         
689         
690         // if dtstart is before $_from, we compute the offset where to start our calculations
691         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
692             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
693             // NOTE: $computationOffsetMonth must be multiple of interval!
694             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
695             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
696         }
697         
698         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
699         
700         $originatorsOriginalDtstart = clone $eventInOrganizerTZ->dtstart;
701         
702         while(true) {
703             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $_rrule->interval);
704             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
705             $recurEvent->dtstart = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
706             
707             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
708             $recurEvent->dtend = clone $recurEvent->dtstart;
709             $recurEvent->dtend->add($eventLength);
710             
711             $recurEvent->setTimezone('UTC');
712             
713             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
714                 break;
715             }
716             
717             // skip non existing dates
718             if (! Tinebase_DateTime::isDate(self::array2string($computationStartDateArray))) {
719                 continue;
720             }
721             
722             // skip events ending before our period.
723             // NOTE: such events could be included, cause our offset only calcs months and not seconds
724             if ($_from->compare($recurEvent->dtend) >= 0) {
725                 continue;
726             }
727             
728             $recurEvent->setRecurId();
729             
730             
731             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
732                 self::addRecurrence($recurEvent, $_recurSet);
733             }
734         }
735     }
736     
737     /**
738      * computes monthly (byday) recurring events and inserts them into given $_recurSet
739      *
740      * @param Calendar_Model_Event      $_event
741      * @param Calendar_Model_Rrule      $_rrule
742      * @param array                     $_exceptionRecurIds
743      * @param Tinebase_DateTime                 $_from
744      * @param Tinebase_DateTime                 $_until
745      * @param Tinebase_Record_RecordSet $_recurSet
746      * @return void
747      */
748     protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
749     {
750         $eventInOrganizerTZ = clone $_event;
751         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
752         
753         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
754         
755         // if period contains base events dtstart, we let computation start one intervall to early to catch
756         // the cases when dtstart of base event not equals the first instance. If it fits, we filter the additional 
757         // instance out later
758         if ($eventInOrganizerTZ->dtstart->isLater($_from) && $eventInOrganizerTZ->dtstart->isEarlier($_until)) {
759             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, -1 * $_rrule->interval);
760         }
761         
762         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
763         
764         // if dtstart is before $_from, we compute the offset where to start our calculations
765         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
766             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
767             // NOTE: $computationOffsetMonth must be multiple of interval!
768             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
769             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
770         }
771         
772         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
773         
774         $computationStartDateArray['day'] = 1;
775         
776         $byDayInterval = (int) substr($_rrule->byday, 0, -2);
777         $byDayWeekday  = substr($_rrule->byday, -2);
778         
779         if ($byDayInterval === 0 || ! array_key_exists($byDayWeekday, self::$WEEKDAY_DIGIT_MAP)) {
780             throw new Exception('mal formated rrule byday part: "' . $_rrule->byday . '"');
781         }
782         
783         while(true) {
784             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $_rrule->interval);
785             $computationStartDate = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
786             
787             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
788             $recurEvent->dtstart = clone $computationStartDate;
789             
790             if ($byDayInterval < 0) {
791                 $recurEvent->dtstart = self::array2date(self::addMonthIngnoringDay($computationStartDateArray, 1), $eventInOrganizerTZ->originator_tz);
792                 $recurEvent->dtstart->subDay(1);
793             }
794             
795             self::skipWday($recurEvent->dtstart, $byDayWeekday, $byDayInterval, TRUE);
796             
797             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
798             $recurEvent->dtend = clone $recurEvent->dtstart;
799             $recurEvent->dtend->add($eventLength);
800             
801             $recurEvent->setTimezone('UTC');
802             
803             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
804                 break;
805             }
806             
807             // skip non existing dates
808             if ($computationStartDate->get('m') != $recurEvent->dtstart->get('m')) {
809                 continue;
810             }
811             
812             // skip events ending before our period.
813             // NOTE: such events could be included, cause our offset only calcs months and not seconds
814             if ($_from->compare($recurEvent->dtend) >= 0) {
815                 continue;
816             }
817             
818             // skip instances begining before the baseEvent
819             if ($recurEvent->dtstart->compare($_event->dtstart) < 0) {
820                 continue;
821             }
822             
823             // skip if event equal baseevent
824             if ($_event->dtstart->equals($recurEvent->dtstart)) {
825                 continue;
826             }
827             
828             $recurEvent->setRecurId();
829             
830             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
831                 self::addRecurrence($recurEvent, $_recurSet);
832             }
833         }
834     }
835     
836     /**
837      * skips date to (n'th next/previous) occurance of $_wday
838      *
839      * @param Tinebase_DateTime  $_date
840      * @param int|string $_wday
841      * @param int        $_n
842      * @param bool       $_considerDateItself
843      */
844     public static function skipWday($_date, $_wday, $_n = +1, $_considerDateItself = FALSE)
845     {
846         $wdayDigit = is_int($_wday) ? $_wday : self::$WEEKDAY_DIGIT_MAP[$_wday];
847         $wdayOffset = $_date->get('w') - $wdayDigit;
848                                 
849         if ($_n == 0) {
850             throw new Exception('$_n must not be 0');
851         }
852         
853         $direction = $_n > 0 ? 'forward' : 'backward';
854         $weeks = abs($_n);
855         
856         if ($_considerDateItself && $wdayOffset == 0) {
857             $weeks--;
858         }
859         
860         switch ($direction) {
861             case 'forward':
862                 if ($wdayOffset >= 0) {
863                     $_date->addDay(($weeks * 7) - $wdayOffset);
864                 } else {
865                     $_date->addDay(abs($wdayOffset) + ($weeks -1) * 7);
866                 }
867                 
868                 break;
869             case 'backward':
870                 if ($wdayOffset > 0) {
871                     $_date->subDay(abs($wdayOffset) + ($weeks -1) * 7);
872                 } else {
873                     $_date->subDay(($weeks * 7) + $wdayOffset);
874                 }
875                 break;
876         }
877         
878         return $_date;
879     }
880     
881     /**
882      * converts a Tinebase_DateTime to Array
883      *
884      * @param  Tinebase_DateTime $_date
885      * @return array
886      * @throws Tinebase_Exception_UnexpectedValue
887      */
888     public static function date2array($_date)
889     {
890         if (! $_date instanceof Tinebase_DateTime) {
891             throw new Tinebase_Exception_UnexpectedValue('DateTime expected');
892         }
893         
894         return array_intersect_key($_date->toArray(), array_flip(array(
895             'day' , 'month', 'year', 'hour', 'minute', 'second'
896         )));
897     }
898     
899     /**
900      * converts date array to Tinebase_DateTime
901      *
902      * @param  array $_dateArray
903      * @param  string $_timezone
904      * @return Tinebase_DateTime
905      */
906     public static function array2date(array $_dateArray, $_timezone='UTC')
907     {
908         date_default_timezone_set($_timezone);
909         
910         $date = new Tinebase_DateTime(mktime($_dateArray['hour'], $_dateArray['minute'], $_dateArray['second'], $_dateArray['month'], $_dateArray['day'], $_dateArray['year']));
911         $date->setTimezone($_timezone);
912         
913         date_default_timezone_set('UTC');
914         
915         return $date;
916     }
917     
918     /**
919      * converts date array to string
920      *
921      * @param  array $_dateArray
922      * @return string
923      */
924     public static function array2string(array $_dateArray)
925     {
926         return $_dateArray['year'] . '-' . str_pad($_dateArray['month'], 2, '0', STR_PAD_LEFT) . '-' . str_pad($_dateArray['day'], 2, '0', STR_PAD_LEFT) . ' ' . 
927                 str_pad($_dateArray['hour'], 2, '0', STR_PAD_LEFT) . ':' . str_pad($_dateArray['minute'], 2, '0', STR_PAD_LEFT) . ':' . str_pad($_dateArray['second'], 2, '0', STR_PAD_LEFT);
928     }
929     
930     /**
931      * get number of month different from $_date1 to $_date2
932      *
933      * @param  Tinebase_DateTime|array $_from
934      * @param  Tinebase_DateTime|array $_until
935      * @return int
936      */
937     public static function getMonthDiff($_from, $_until)
938     {
939         $date1Array = is_array($_from) ? $_from : self::date2array($_from);
940         $date2Array = is_array($_until) ? $_until : self::date2array($_until);
941         
942         return (12 * $date2Array['year'] + $date2Array['month']) - (12 * $date1Array['year'] + $date1Array['month']);
943     }
944     
945     /**
946      * add month and don't touch the day.
947      * NOTE: The resulting date may no exist e.g. 31. Feb. -> virtual date 
948      *
949      * @param  Tinebase_DateTime|array  $_date
950      * @param  int              $_months
951      * @return array
952      */
953     public static function addMonthIngnoringDay($_date, $_months)
954     {
955         $dateArr = is_array($_date) ? $_date : self::date2array($_date);
956         
957         $totalMonth = 12 * $dateArr['year'] + $dateArr['month'] + $_months;
958         $dateArr['year'] = $totalMonth % 12 ? floor($totalMonth/12) : $totalMonth/12 -1;
959         $dateArr['month'] = $totalMonth % 12 ? $totalMonth % 12 : 12;
960         
961         return $dateArr;
962     }
963     
964     /**
965      * adds diff to date and applies dst fix
966      *
967      * @param Tinebase_DateTime $_dateInUTC
968      * @param DateTimeInterval $_diff
969      * @param string    $_timezoneForDstFix
970      */
971     public static function addUTCDateDstFix($_dateInUTC, $_diff, $_timezoneForDstFix)
972     {
973         $_dateInUTC->setTimezone($_timezoneForDstFix);
974         $_dateInUTC->add($_dateInUTC->get('I') ? 1 : 0, Tinebase_DateTime::MODIFIER_HOUR);
975         $_dateInUTC->add($_diff);
976         $_dateInUTC->subHour($_dateInUTC->get('I') ? 1 : 0);
977         $_dateInUTC->setTimezone('UTC');
978     }
979 }