9820: Infinite loop in adoptAlarmTime (DST Boundary)
[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 (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
360                 . ' $from = ' . $_from->toString());
361         
362         if ($_which === 0 || ($_event->dtstart >= $_from && $_event->dtend > $_from)) {
363             return $_event;
364         }
365         
366         $freqMap = array(
367             self::FREQ_DAILY   => Tinebase_DateTime::MODIFIER_DAY,
368             self::FREQ_WEEKLY  => Tinebase_DateTime::MODIFIER_WEEK,
369             self::FREQ_MONTHLY => Tinebase_DateTime::MODIFIER_MONTH,
370             self::FREQ_YEARLY  => Tinebase_DateTime::MODIFIER_YEAR
371         );
372         
373         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
374         $rrule->setFromString($_event->rrule);
375         
376         $from  = clone $_from;
377         $until = clone $from;
378         $interval = $_which * $rrule->interval;
379         
380         // we don't want to compute ourself
381         $ownEvent = clone $_event;
382         $ownEvent->setRecurId();
383         $exceptions = clone $_exceptions;
384         $exceptions->addRecord($ownEvent);
385         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
386         
387         if ($_from->isEarlier($_event->dtstart)) {
388             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
389                 . ' from is ealier dtstart -> given event is next occurrence');
390             return $_event;
391         }
392         
393         $rangeDate = $_which > 0 ? $until : $from;
394         $rangeDate->add($interval, $freqMap[$rrule->freq]);
395         $attempts = 0;
396         
397         if ($_event->rrule_until instanceof DateTime && Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
398             . ' Event rrule_until: ' . $_event->rrule_until->toString());
399         
400         while (TRUE) {
401             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
402                 . ' trying to find next occurrence from ' . $from->toString());
403             
404             if ($_event->rrule_until instanceof DateTime && $from->isLater($_event->rrule_until)) {
405                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
406                     . ' passed rrule_until -> no further occurrences');
407                 return NULL;
408             }
409             
410             $until = ($_event->rrule_until instanceof DateTime && $until->isLater($_event->rrule_until))
411                 ? clone $_event->rrule_until 
412                 : $until;
413
414             $recurSet->merge(self::computeRecurrenceSet($_event, $exceptions, $from, $until));
415             $attempts++;
416             
417             // NOTE: computeRecurrenceSet also returns events during $from in some cases, but we need 
418             // to events later than $from.
419             $recurSet = $recurSet->filter(function($event) use ($from) {return $event->dtstart >= $from;});
420             
421             if (count($recurSet) >= abs($_which)) {
422                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
423                     . " found next occurrence after $attempts attempt(s)");
424                 break;
425             }
426             
427             if ($attempts > count($exceptions) + 5) {
428                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
429                     . " could not find the next occurrence after $attempts attempts, giving up");
430                 return NULL;
431             }
432             
433             $from->add($interval, $freqMap[$rrule->freq]);
434             $until->add($interval, $freqMap[$rrule->freq]);
435         }
436         
437         $recurSet->sort('dtstart', $_which > 0 ? 'ASC' : 'DESC');
438         $nextOccurrence = $recurSet[abs($_which)-1];
439         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
440                 . ' $nextOccurrence->dtstart = ' . $nextOccurrence->dtstart->toString());
441         return $nextOccurrence;
442     }
443     
444     /**
445      * Computes the Recurrence set of the given event leaving out $_event->exdate and $_exceptions
446      * 
447      * @todo respect rrule_until!
448      *
449      * @param  Calendar_Model_Event         $_event
450      * @param  Tinebase_Record_RecordSet    $_exceptions
451      * @param  Tinebase_DateTime            $_from
452      * @param  Tinebase_DateTime            $_until
453      * @return Tinebase_Record_RecordSet
454      * @throws Tinebase_Exception_UnexpectedValue
455      */
456     public static function computeRecurrenceSet($_event, $_exceptions, $_from, $_until)
457     {
458         if (! $_event->dtstart instanceof Tinebase_DateTime) {
459             throw new Tinebase_Exception_UnexpectedValue('Event needs DateTime dtstart: ' . print_r($_event->toArray(), TRUE));
460         }
461         
462         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
463         $rrule->setFromString($_event->rrule);
464         
465         $exceptionRecurIds = self::getExceptionsRecurIds($_event, $_exceptions);
466         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
467         
468         switch ($rrule->freq) {
469             case self::FREQ_DAILY:
470                 
471                 self::_computeRecurDaily($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
472                 break;
473                 
474             case self::FREQ_WEEKLY:
475                 // default BYDAY clause
476                 if (! $rrule->byday) {
477                     $rrule->byday = array_search($_event->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP);
478                 }
479                 
480                 if (! $rrule->wkst) {
481                     // @TODO if organizer has an account get its locales wkst
482                     $rrule->wkst = self::WDAY_MONDAY;
483                 }
484                 $weekDays = array_keys(self::$WEEKDAY_DIGIT_MAP);
485                 array_splice($weekDays, 0, 0, array_splice($weekDays, array_search($rrule->wkst, $weekDays)));
486                     
487                 $dailyrrule = clone ($rrule);
488                 $dailyrrule->freq = self::FREQ_DAILY;
489                 $dailyrrule->interval = 7 * $rrule->interval;
490                 
491                 $eventLength = $_event->dtstart->diff($_event->dtend);
492                 
493                 foreach (explode(',', $rrule->byday) as $recurWeekDay) {
494                     // NOTE: in weekly computation, each wdays base event is a recur instance itself
495                     $baseEvent = clone $_event;
496                     
497                     // NOTE: skipping must be done in organizer_tz
498                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
499                     $direction = array_search($recurWeekDay, $weekDays) >= array_search(array_search($baseEvent->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP), $weekDays) ? +1 : -1;
500                     self::skipWday($baseEvent->dtstart, $recurWeekDay, $direction, TRUE);
501                     $baseEvent->dtstart->setTimezone('UTC');
502                     
503                     $baseEvent->dtend = clone($baseEvent->dtstart);
504                     $baseEvent->dtend->add($eventLength);
505                     
506                     self::_computeRecurDaily($baseEvent, $dailyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
507                     
508                     // check if base event (recur instance) needs to be added to the set
509                     if ($baseEvent->dtstart > $_event->dtstart && $baseEvent->dtstart >= $_from && $baseEvent->dtstart < $_until) {
510                         if (! in_array($baseEvent->setRecurId(), $exceptionRecurIds)) {
511                             self::addRecurrence($baseEvent, $recurSet);
512                         }
513                     }
514                 }
515                 break;
516                 
517             case self::FREQ_MONTHLY:
518                 if ($rrule->byday) {
519                     self::_computeRecurMonthlyByDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
520                 } else {
521                     self::_computeRecurMonthlyByMonthDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
522                 }
523                 break;
524                 
525             case self::FREQ_YEARLY:
526                 $yearlyrrule = clone $rrule;
527                 $yearlyrrule->freq = self::FREQ_MONTHLY;
528                 $yearlyrrule->interval = 12;
529                 
530                 $baseEvent = clone $_event;
531                 $originatorsDtstart = clone $baseEvent->dtstart;
532                 $originatorsDtstart->setTimezone($_event->originator_tz);
533                 
534                 // @TODO respect BYMONTH
535                 if ($rrule->bymonth && $rrule->bymonth != $originatorsDtstart->format('n')) {
536                     // adopt
537                     $diff = (12 + $rrule->bymonth - $originatorsDtstart->format('n')) % 12;
538                     
539                     // NOTE: skipping must be done in organizer_tz
540                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
541                     $baseEvent->dtend->setTimezone($_event->originator_tz);
542                     $baseEvent->dtstart->addMonth($diff);
543                     $baseEvent->dtend->addMonth($diff);
544                     $baseEvent->dtstart->setTimezone('UTC');
545                     $baseEvent->dtend->setTimezone('UTC');
546                     
547                     // check if base event (recur instance) needs to be added to the set
548                     if ($baseEvent->dtstart->isLater($_from) && $baseEvent->dtstart->isEarlier($_until)) {
549                         if (! in_array($baseEvent->setRecurId(), $exceptionRecurIds)) {
550                             self::addRecurrence($baseEvent, $recurSet);
551                         }
552                     }
553                 }
554                 
555                 if ($rrule->byday) {
556                     self::_computeRecurMonthlyByDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
557                 } else {
558                     self::_computeRecurMonthlyByMonthDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
559                 }
560
561                 break;
562                 
563         }
564         
565         return $recurSet;
566     }
567     
568     /**
569      * returns array of exception recurids
570      *
571      * @param  Calendar_Model_Event         $_event
572      * @param  Tinebase_Record_RecordSet    $_exceptions
573      * @return array
574      */
575     public static function getExceptionsRecurIds($_event, $_exceptions)
576     {
577         $recurIds = $_exceptions->recurid;
578         
579         if (! empty($_event->exdate)) {
580             $exdates = is_array($_event->exdate) ? $_event->exdate : array($_event->exdate);
581             foreach ($exdates as $exdate) {
582                 $recurIds[] = $_event->uid . '-' . $exdate->toString(Tinebase_Record_Abstract::ISO8601LONG);
583             }
584         }
585         return array_values($recurIds);
586     }
587     
588     /**
589      * gets an cloned event to be used for new recur events
590      * 
591      * @param  Calendar_Model_Event         $_event
592      * @return Calendar_Model_Event         $_event
593      */
594     public static function cloneEvent($_event)
595     {
596         $clone = clone $_event;
597         $clone->setId(NULL);
598         //unset($clone->exdate);
599         //unset($clone->rrule);
600         //unset($clone->rrule_until);
601         
602         return $clone;
603     }
604     
605     /**
606      * computes daily recurring events and inserts them into given $_recurSet
607      *
608      * @param Calendar_Model_Event      $_event
609      * @param Calendar_Model_Rrule      $_rrule
610      * @param array                     $_exceptionRecurIds
611      * @param Tinebase_DateTime                 $_from
612      * @param Tinebase_DateTime                 $_until
613      * @param Tinebase_Record_RecordSet $_recurSet
614      * @return void
615      */
616     protected static function _computeRecurDaily($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
617     {
618         $computationStartDate = clone $_event->dtstart;
619         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
620         
621         // if dtstart is before $_from, we compute the offset where to start our calculations
622         if ($_event->dtstart->isEarlier($_from)) {
623             $computationOffsetDays = floor(($_from->getTimestamp() - $_event->dtend->getTimestamp()) / (self::TS_DAY * $_rrule->interval)) * $_rrule->interval;
624             $computationStartDate->add($computationOffsetDays, Tinebase_DateTime::MODIFIER_DAY);
625         }
626         
627         $eventLength = $_event->dtstart->diff($_event->dtend);
628         
629         $originatorsOriginalDtstart = clone $_event->dtstart;
630         $originatorsOriginalDtstart->setTimezone($_event->originator_tz);
631         
632         while (true) {
633             $computationStartDate->addDay($_rrule->interval);
634             
635             $recurEvent = self::cloneEvent($_event);
636             $recurEvent->dtstart = clone ($computationStartDate);
637             
638             $originatorsDtstart = clone $recurEvent->dtstart;
639             $originatorsDtstart->setTimezone($_event->originator_tz);
640             
641             $recurEvent->dtstart->add($originatorsOriginalDtstart->get('I') - $originatorsDtstart->get('I'), Tinebase_DateTime::MODIFIER_HOUR);
642
643             //$recurEvent->dtstart->sub($originatorsDtstart->get('I') ? 1 : 0, Tinebase_DateTime::MODIFIER_HOUR);
644             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
645                 break;
646             }            
647             
648             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
649             $recurEvent->dtend = clone $recurEvent->dtstart;
650             $recurEvent->dtend->add($eventLength);
651             
652             $recurEvent->setRecurId();
653             
654             if ($_from->compare($recurEvent->dtend) >= 0) {
655                 continue;
656             }
657             
658             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
659                 self::addRecurrence($recurEvent, $_recurSet);
660             }
661         }
662     }
663     
664     /**
665      * computes monthly (bymonthday) recurring events and inserts them into given $_recurSet
666      *
667      * @param Calendar_Model_Event      $_event
668      * @param Calendar_Model_Rrule      $_rrule
669      * @param array                     $_exceptionRecurIds
670      * @param Tinebase_DateTime                 $_from
671      * @param Tinebase_DateTime                 $_until
672      * @param Tinebase_Record_RecordSet $_recurSet
673      * @return void
674      */
675     protected static function _computeRecurMonthlyByMonthDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
676     {
677         
678         $eventInOrganizerTZ = clone $_event;
679         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
680         
681         // some clients skip the monthday e.g. for yearly rrules
682         if (! $_rrule->bymonthday) {
683             $_rrule->bymonthday = $eventInOrganizerTZ->dtstart->format('j');
684         }
685         
686         // NOTE: non existing dates will be discarded (e.g. 31. Feb.)
687         //       for correct computations we deal with virtual dates, represented as arrays
688         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
689         // adopt startdate if rrule monthday != dtstart monthday
690         // in this case, the first instance is not the base event!
691         if ($_rrule->bymonthday != $computationStartDateArray['day']) {
692             $computationStartDateArray['day'] = $_rrule->bymonthday;
693             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, -1 * $_rrule->interval);
694         }
695         
696         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
697         
698         
699         
700         // if dtstart is before $_from, we compute the offset where to start our calculations
701         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
702             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
703             // NOTE: $computationOffsetMonth must be multiple of interval!
704             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
705             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
706         }
707         
708         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
709         
710         $originatorsOriginalDtstart = clone $eventInOrganizerTZ->dtstart;
711         
712         while(true) {
713             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $_rrule->interval);
714             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
715             $recurEvent->dtstart = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
716             
717             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
718             $recurEvent->dtend = clone $recurEvent->dtstart;
719             $recurEvent->dtend->add($eventLength);
720             
721             $recurEvent->setTimezone('UTC');
722             
723             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
724                 break;
725             }
726             
727             // skip non existing dates
728             if (! Tinebase_DateTime::isDate(self::array2string($computationStartDateArray))) {
729                 continue;
730             }
731             
732             // skip events ending before our period.
733             // NOTE: such events could be included, cause our offset only calcs months and not seconds
734             if ($_from->compare($recurEvent->dtend) >= 0) {
735                 continue;
736             }
737             
738             $recurEvent->setRecurId();
739             
740             
741             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
742                 self::addRecurrence($recurEvent, $_recurSet);
743             }
744         }
745     }
746     
747     /**
748      * computes monthly (byday) recurring events and inserts them into given $_recurSet
749      *
750      * @param Calendar_Model_Event      $_event
751      * @param Calendar_Model_Rrule      $_rrule
752      * @param array                     $_exceptionRecurIds
753      * @param Tinebase_DateTime                 $_from
754      * @param Tinebase_DateTime                 $_until
755      * @param Tinebase_Record_RecordSet $_recurSet
756      * @return void
757      */
758     protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
759     {
760         $eventInOrganizerTZ = clone $_event;
761         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
762         
763         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
764         
765         // if period contains base events dtstart, we let computation start one intervall to early to catch
766         // the cases when dtstart of base event not equals the first instance. If it fits, we filter the additional 
767         // instance out later
768         if ($eventInOrganizerTZ->dtstart->isLater($_from) && $eventInOrganizerTZ->dtstart->isEarlier($_until)) {
769             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, -1 * $_rrule->interval);
770         }
771         
772         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
773         
774         // if dtstart is before $_from, we compute the offset where to start our calculations
775         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
776             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
777             // NOTE: $computationOffsetMonth must be multiple of interval!
778             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
779             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
780         }
781         
782         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
783         
784         $computationStartDateArray['day'] = 1;
785         
786         $byDayInterval = (int) substr($_rrule->byday, 0, -2);
787         $byDayWeekday  = substr($_rrule->byday, -2);
788         
789         if ($byDayInterval === 0 || ! array_key_exists($byDayWeekday, self::$WEEKDAY_DIGIT_MAP)) {
790             throw new Exception('mal formated rrule byday part: "' . $_rrule->byday . '"');
791         }
792         
793         while(true) {
794             $computationStartDateArray = self::addMonthIngnoringDay($computationStartDateArray, $_rrule->interval);
795             $computationStartDate = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
796             
797             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
798             $recurEvent->dtstart = clone $computationStartDate;
799             
800             if ($byDayInterval < 0) {
801                 $recurEvent->dtstart = self::array2date(self::addMonthIngnoringDay($computationStartDateArray, 1), $eventInOrganizerTZ->originator_tz);
802                 $recurEvent->dtstart->subDay(1);
803             }
804             
805             self::skipWday($recurEvent->dtstart, $byDayWeekday, $byDayInterval, TRUE);
806             
807             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
808             $recurEvent->dtend = clone $recurEvent->dtstart;
809             $recurEvent->dtend->add($eventLength);
810             
811             $recurEvent->setTimezone('UTC');
812             
813             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
814                 break;
815             }
816             
817             // skip non existing dates
818             if ($computationStartDate->get('m') != $recurEvent->dtstart->get('m')) {
819                 continue;
820             }
821             
822             // skip events ending before our period.
823             // NOTE: such events could be included, cause our offset only calcs months and not seconds
824             if ($_from->compare($recurEvent->dtend) >= 0) {
825                 continue;
826             }
827             
828             // skip instances begining before the baseEvent
829             if ($recurEvent->dtstart->compare($_event->dtstart) < 0) {
830                 continue;
831             }
832             
833             // skip if event equal baseevent
834             if ($_event->dtstart->equals($recurEvent->dtstart)) {
835                 continue;
836             }
837             
838             $recurEvent->setRecurId();
839             
840             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
841                 self::addRecurrence($recurEvent, $_recurSet);
842             }
843         }
844     }
845     
846     /**
847      * skips date to (n'th next/previous) occurance of $_wday
848      *
849      * @param Tinebase_DateTime  $_date
850      * @param int|string $_wday
851      * @param int        $_n
852      * @param bool       $_considerDateItself
853      */
854     public static function skipWday($_date, $_wday, $_n = +1, $_considerDateItself = FALSE)
855     {
856         $wdayDigit = is_int($_wday) ? $_wday : self::$WEEKDAY_DIGIT_MAP[$_wday];
857         $wdayOffset = $_date->get('w') - $wdayDigit;
858                                 
859         if ($_n == 0) {
860             throw new Exception('$_n must not be 0');
861         }
862         
863         $direction = $_n > 0 ? 'forward' : 'backward';
864         $weeks = abs($_n);
865         
866         if ($_considerDateItself && $wdayOffset == 0) {
867             $weeks--;
868         }
869         
870         switch ($direction) {
871             case 'forward':
872                 if ($wdayOffset >= 0) {
873                     $_date->addDay(($weeks * 7) - $wdayOffset);
874                 } else {
875                     $_date->addDay(abs($wdayOffset) + ($weeks -1) * 7);
876                 }
877                 
878                 break;
879             case 'backward':
880                 if ($wdayOffset > 0) {
881                     $_date->subDay(abs($wdayOffset) + ($weeks -1) * 7);
882                 } else {
883                     $_date->subDay(($weeks * 7) + $wdayOffset);
884                 }
885                 break;
886         }
887         
888         return $_date;
889     }
890     
891     /**
892      * converts a Tinebase_DateTime to Array
893      *
894      * @param  Tinebase_DateTime $_date
895      * @return array
896      * @throws Tinebase_Exception_UnexpectedValue
897      */
898     public static function date2array($_date)
899     {
900         if (! $_date instanceof Tinebase_DateTime) {
901             throw new Tinebase_Exception_UnexpectedValue('DateTime expected');
902         }
903         
904         return array_intersect_key($_date->toArray(), array_flip(array(
905             'day' , 'month', 'year', 'hour', 'minute', 'second'
906         )));
907     }
908     
909     /**
910      * converts date array to Tinebase_DateTime
911      *
912      * @param  array $_dateArray
913      * @param  string $_timezone
914      * @return Tinebase_DateTime
915      */
916     public static function array2date(array $_dateArray, $_timezone='UTC')
917     {
918         date_default_timezone_set($_timezone);
919         
920         $date = new Tinebase_DateTime(mktime($_dateArray['hour'], $_dateArray['minute'], $_dateArray['second'], $_dateArray['month'], $_dateArray['day'], $_dateArray['year']));
921         $date->setTimezone($_timezone);
922         
923         date_default_timezone_set('UTC');
924         
925         return $date;
926     }
927     
928     /**
929      * converts date array to string
930      *
931      * @param  array $_dateArray
932      * @return string
933      */
934     public static function array2string(array $_dateArray)
935     {
936         return $_dateArray['year'] . '-' . str_pad($_dateArray['month'], 2, '0', STR_PAD_LEFT) . '-' . str_pad($_dateArray['day'], 2, '0', STR_PAD_LEFT) . ' ' . 
937                 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);
938     }
939     
940     /**
941      * get number of month different from $_date1 to $_date2
942      *
943      * @param  Tinebase_DateTime|array $_from
944      * @param  Tinebase_DateTime|array $_until
945      * @return int
946      */
947     public static function getMonthDiff($_from, $_until)
948     {
949         $date1Array = is_array($_from) ? $_from : self::date2array($_from);
950         $date2Array = is_array($_until) ? $_until : self::date2array($_until);
951         
952         return (12 * $date2Array['year'] + $date2Array['month']) - (12 * $date1Array['year'] + $date1Array['month']);
953     }
954     
955     /**
956      * add month and don't touch the day.
957      * NOTE: The resulting date may no exist e.g. 31. Feb. -> virtual date 
958      *
959      * @param  Tinebase_DateTime|array  $_date
960      * @param  int              $_months
961      * @return array
962      */
963     public static function addMonthIngnoringDay($_date, $_months)
964     {
965         $dateArr = is_array($_date) ? $_date : self::date2array($_date);
966         
967         $totalMonth = 12 * $dateArr['year'] + $dateArr['month'] + $_months;
968         $dateArr['year'] = $totalMonth % 12 ? floor($totalMonth/12) : $totalMonth/12 -1;
969         $dateArr['month'] = $totalMonth % 12 ? $totalMonth % 12 : 12;
970         
971         return $dateArr;
972     }
973     
974     /**
975      * adds diff to date and applies dst fix
976      *
977      * @param Tinebase_DateTime $_dateInUTC
978      * @param DateTimeInterval $_diff
979      * @param string    $_timezoneForDstFix
980      */
981     public static function addUTCDateDstFix($_dateInUTC, $_diff, $_timezoneForDstFix)
982     {
983         $_dateInUTC->setTimezone($_timezoneForDstFix);
984         $_dateInUTC->add($_dateInUTC->get('I') ? 1 : 0, Tinebase_DateTime::MODIFIER_HOUR);
985         $_dateInUTC->add($_diff);
986         $_dateInUTC->subHour($_dateInUTC->get('I') ? 1 : 0);
987         $_dateInUTC->setTimezone('UTC');
988     }
989 }