month view recurinstances not selectable
[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-2013 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     static $WEEKDAY_MAP = array(
58         self::WDAY_SUNDAY     => 'sun',
59         self::WDAY_MONDAY     => 'mon',
60         self::WDAY_TUESDAY    => 'tue',
61         self::WDAY_WEDNESDAY  => 'wed',
62         self::WDAY_THURSDAY   => 'thu',
63         self::WDAY_FRIDAY     => 'fri',
64         self::WDAY_SATURDAY   => 'sat'
65     );
66
67     static $WEEKDAY_MAP_REVERSE = array(
68         'sun' => self::WDAY_SUNDAY,
69         'mon' => self::WDAY_MONDAY,
70         'tue' => self::WDAY_TUESDAY,
71         'wed' => self::WDAY_WEDNESDAY,
72         'thu' => self::WDAY_THURSDAY,
73         'fri' => self::WDAY_FRIDAY,
74         'sat' => self::WDAY_SATURDAY
75     );
76
77     const TS_HOUR = 3600;
78     const TS_DAY  = 86400;
79     
80     /**
81      * key in $_validators/$_properties array for the filed which 
82      * represents the identifier
83      * 
84      * @var string
85      */
86     protected $_identifier = 'id';
87     
88     /**
89      * application the record belongs to
90      *
91      * @var string
92      */
93     protected $_application = 'Calendar';
94     
95     /**
96      * validators
97      *
98      * @var array
99      */
100     protected $_validators = array(
101         'id'                   => array('allowEmpty' => true,  /*'Alnum'*/),
102         'freq'                 => array(
103             'allowEmpty' => true,
104             array('InArray', array(self::FREQ_DAILY, self::FREQ_MONTHLY, self::FREQ_WEEKLY, self::FREQ_YEARLY)),
105         ),
106         'interval'             => array('allowEmpty' => true, 'Int'   ),
107         'byday'                => array('allowEmpty' => true, 'Regex' => '/^[\-0-9A_Z,]{2,}$/'),
108         'bymonth'              => array('allowEmpty' => true, 'Int'   ),
109         'bymonthday'           => array('allowEmpty' => true, 'Int'   ),
110         'wkst'                 => array(
111             'allowEmpty' => true,
112             array('InArray', array(self::WDAY_SUNDAY, self::WDAY_MONDAY, self::WDAY_TUESDAY, self::WDAY_WEDNESDAY, self::WDAY_THURSDAY, self::WDAY_FRIDAY, self::WDAY_SATURDAY)),
113         ),
114         'until'                => array('allowEmpty' => true          ),
115         'count'                => array('allowEmpty' => true, 'Int'   ),
116     );
117     
118     /**
119      * datetime fields
120      *
121      * @var array
122      */
123     protected $_datetimeFields = array(
124         'until',
125     );
126     
127     /**
128      * @var array supported standard rrule parts
129      */
130     protected $_rruleParts = array('freq', 'interval', 'until', 'count', 'wkst', 'byday', 'bymonth', 'bymonthday');
131     
132     /**
133      * @see /Tinebase/Record/Abstract::__construct
134      */
135     public function __construct($_data = NULL, $_bypassFilters = false, $_convertDates = true)
136     {
137         $rruleString = NULL;
138         
139         if (is_string($_data)) {
140             $rruleString = $_data;
141             $_data = NULL;
142         }
143         
144         parent::__construct($_data, $_bypassFilters, $_convertDates);
145         
146         if ($rruleString) {
147             $this->setFromString($rruleString);
148         }
149     }
150     
151     /**
152      * set from ical rrule string
153      *
154      * @param string $_rrule
155      */
156     public function setFromString($_rrule)
157     {
158         if ($_rrule) {
159             $parts = explode(';', $_rrule);
160             $skipParts = array();
161             foreach ($parts as $part) {
162                 list($key, $value) = explode('=', $part);
163                 $part = strtolower($key);
164                 if (in_array($part, $skipParts)) {
165                     continue;
166                 }
167                 if (! in_array($part, $this->_rruleParts)) {
168                     if ($part === 'bysetpos') {
169                         if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
170                             . " Map bysetpos to a supported RRULE part: bymonthday");
171                         $part = 'bymonthday';
172                         $this->byday = null;
173                         $skipParts[] = 'byday';
174                     } else {
175                         throw new Tinebase_Exception_UnexpectedValue("$part is not a known rrule part");
176                     }
177                 }
178                 $this->$part = $value;
179             }
180         }
181     }
182     
183     /**
184      * creates a rrule from string
185      *
186      * @param string $_rruleString
187      * @return Calendar_Model_Rrule
188      */
189     public static function getRruleFromString($_rruleString)
190     {
191         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
192         $rrule->setFromString($_rruleString);
193         
194         return $rrule;
195     }
196     
197     /**
198      * returns a ical rrule string
199      *
200      * @return string
201      */
202     public function __toString()
203     {
204         $stringParts = array();
205         
206         foreach ($this->_rruleParts as $part) {
207             if (!empty($this->$part)) {
208                 $value = $this->$part instanceof DateTime ? $this->$part->toString(self::ISO8601LONG) : $this->$part;
209                 $stringParts[] = strtoupper($part) . '=' . $value;
210             }
211         }
212         
213         return implode(';', $stringParts);
214     }
215     
216     /**
217      * set properties and convert them into internal representatin on the fly
218      *
219      * @param string $_name
220      * @param mixed $_value
221      * @return void
222      */
223     public function __set($_name, $_value) {
224         switch ($_name) {
225             case 'until':
226                 if (! empty($_value)) {
227                     if ($_value instanceof DateTime) {
228                         $this->_properties['until'] = $_value;
229                     } else {
230                         $this->_properties['until'] = new Tinebase_DateTime($_value);
231                     }
232                 }
233                 break;
234             case 'bymonth':
235             case 'bymonthday':
236                 if (! empty($_value)) {
237                     $values = explode(',', $_value);
238                     $this->_properties[$_name] = (integer) $values[0];
239                 }
240                 break;
241             default:
242                 parent::__set($_name, $_value);
243                 break;
244         }
245     }
246     
247     /**
248      * gets record related properties
249      * 
250      * @param string _name of property
251      * @throws Tinebase_Exception_UnexpectedValue
252      * @return mixed value of property
253      */
254     public function __get($_name)
255     {
256         $value = parent::__get($_name);
257         
258         switch ($_name) {
259             case 'interval':
260                 return (int) $value > 1 ? (int) $value : 1;
261                 break;
262             default:
263                 return $value;
264                 break;
265         }
266     }
267     
268     /**
269      * validate and filter the the internal data
270      *
271      * @param $_throwExceptionOnInvalidData
272      * @return bool
273      * @throws Tinebase_Exception_Record_Validation
274      */
275     public function isValid($_throwExceptionOnInvalidData = false)
276     {
277         $isValid = parent::isValid($_throwExceptionOnInvalidData);
278         
279         if (isset($this->_properties['count']) && isset($this->_properties['until'])) {
280             $isValid = $this->_isValidated = false;
281             
282             if ($_throwExceptionOnInvalidData) {
283                 $e = new Tinebase_Exception_Record_Validation('count and until can not be set both');
284                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . $e);
285                 throw $e;
286             }
287         }
288         
289         return $isValid;
290     }
291     
292     /**
293      * normalizes rrule by setting missing clauses. This is needed as some rrule computations
294      * need all clauses and have no access to the event itself.
295      * 
296      * @param Calendar_Model_Event $event
297      */
298     public function normalize(Calendar_Model_Event $event)
299     {
300         // set originators TZ to get correct byday/bymonth/bymonthday rrules
301         $originatorDtStart = clone($event->dtstart);
302         if (! empty($event->originator_tz)) {
303             $originatorDtStart->setTimezone($event->originator_tz);
304         }
305         
306         switch ($this->freq) {
307             case self::FREQ_WEEKLY:
308                 if (! $this->wkst ) {
309                     $this->wkst = self::getWeekStart();
310                 }
311             
312                 if (! $this->byday) {
313                     $this->byday = array_search($originatorDtStart->format('w'), self::$WEEKDAY_DIGIT_MAP);
314                 }
315                 break;
316             
317             case self::FREQ_MONTHLY:
318                 if (! $this->byday && ! $this->bymonthday) {
319                     $this->bymonthday = $originatorDtStart->format('j');
320                 }
321                 break;
322                 
323             case self::FREQ_YEARLY:
324                 if (! $this->byday && ! $this->bymonthday) {
325                     $this->bymonthday = $originatorDtStart->format('j');
326                 }
327                 if (! $this->bymonth) {
328                     $this->bymonth = $originatorDtStart->format('n');
329                 }
330                 break;
331             default:
332                 // do nothing
333                 break;
334         }
335     }
336     
337     /**
338      * get human readable version of this rrule
339      * 
340      * @param  Zend_Translate   $translation
341      * @return string
342      */
343     public function getTranslatedRule($translation)
344     {
345         $rule = '';
346         $locale = new Zend_Locale($translation->getAdapter()->getLocale());
347         $numberFormatter = null;
348         $weekDays = Zend_Locale::getTranslationList('day', $locale);
349         
350         switch ($this->freq) {
351             case self::FREQ_DAILY:
352                 $rule .= $this->interval > 1 ?
353                     sprintf($translation->_('Every %s day'), $this->_formatInterval($this->interval, $translation, $numberFormatter)) :
354                     $translation->_('Daily');
355                 break;
356                 
357             case self::FREQ_WEEKLY:
358                 $rule .= $this->interval > 1 ?
359                     sprintf($translation->_('Every %s week on') . ' ', $this->_formatInterval($this->interval, $translation, $numberFormatter)) :
360                     $translation->_('Weekly on') . ' ';
361                 
362                 $recurWeekDays = explode(',', $this->byday);
363                 $recurWeekDaysCount = count($recurWeekDays);
364                 foreach ($recurWeekDays as $idx => $recurWeekDay) {
365                     $rule .= $weekDays[self::$WEEKDAY_MAP[$recurWeekDay]];
366                     if ($recurWeekDaysCount && $idx+1 != $recurWeekDaysCount) {
367                         $rule .= $idx == $recurWeekDaysCount-2 ? ' ' . $translation->_('and') . ' ' : ', ';
368                     }
369                 }
370                 break;
371                 
372             case self::FREQ_MONTHLY:
373                 if ($this->byday) {
374                     $byDayInterval = (int) substr($this->byday, 0, -2);
375                     $byDayIntervalTranslation = $this->_getIntervalTranslation($byDayInterval, $translation);
376                     $byDayWeekday  = substr($this->byday, -2);
377                     
378                     $rule .= $this->interval > 1 ?
379                         sprintf($translation->_('Every %1$s month on the %2$s %3$s'), $this->_formatInterval($this->interval, $translation, $numberFormatter), $byDayIntervalTranslation, $weekDays[self::$WEEKDAY_MAP[$byDayWeekday]]) :
380                         sprintf($translation->_('Monthly every %1$s %2$s'), $byDayIntervalTranslation, $weekDays[self::$WEEKDAY_MAP[$byDayWeekday]]);
381                     
382                 } else {
383                     $bymonthday = $this->bymonthday;
384                     
385                     $rule .= $this->interval > 1 ?
386                         sprintf($translation->_('Every %1$s month on the %2$s'), $this->_formatInterval($this->interval, $translation, $numberFormatter), $this->_formatInterval($this->bymonthday, $translation, $numberFormatter)) :
387                         sprintf($translation->_('Monthly on the %1$s'), $this->_formatInterval($this->bymonthday, $translation, $numberFormatter));
388                 }
389                 break;
390             case self::FREQ_YEARLY:
391                 $month = Zend_Locale::getTranslationList('month', $locale);
392                 if ($this->byday) {
393                     $byDayInterval = (int) substr($this->byday, 0, -2);
394                     $byDayIntervalTranslation = $this->_getIntervalTranslation($byDayInterval, $translation);
395                     $byDayWeekday  = substr($this->byday, -2);
396                     $rule .= sprintf($translation->_('Yearly every %1$s %2$s of %3$s'), $byDayIntervalTranslation, $weekDays[self::$WEEKDAY_MAP[$byDayWeekday]], $month[$this->bymonth]);
397                 } else {
398                     $rule .= sprintf($translation->_('Yearly on the %1$s of %2$s'), $this->_formatInterval($this->bymonthday, $translation, $numberFormatter), $month[$this->bymonth]);
399                 }
400                 
401                 break;
402         }
403         
404         return $rule;
405     }
406     
407     /**
408      * format interval (use NumberFormatter if intl extension is found)
409      * 
410      * @param integer $number
411      * @param Zend_Translate $translation
412      * @param NumberFormatter|null $numberFormatter
413      * @return string
414      */
415     protected function _formatInterval($number, $translation, $numberFormatter = null)
416     {
417         if ($numberFormatter === null && extension_loaded('intl')) {
418             $locale = new Zend_Locale($translation->getAdapter()->getLocale());
419             $numberFormatter = new NumberFormatter((string) $locale, NumberFormatter::ORDINAL);
420         }
421         
422         $result = ($numberFormatter) ? $numberFormatter->format($number) : $this->_getIntervalTranslation($number, $translation);
423         
424         return $result;
425     }
426     
427     /**
428      * get translation string for interval (first, second, ...)
429      * 
430      * @param integer $interval
431      * @param Zend_Translate $translation
432      * @return string
433      */
434     protected function _getIntervalTranslation($interval, $translation)
435     {
436         switch ($interval) {
437             case -2: 
438                 $result = $translation->_('second to last');
439                 break;
440             case -1: 
441                 $result = $translation->_('last');
442                 break;
443             case 0: 
444                 throw new Tinebase_Exception_UnexpectedValue('0 is not supported');
445                 break;
446             case 1: 
447                 $result = $translation->_('first');
448                 break;
449             case 2: 
450                 $result = $translation->_('second');
451                 break;
452             case 3: 
453                 $result = $translation->_('third');
454                 break;
455             case 4: 
456                 $result = $translation->_('fourth');
457                 break;
458             case 5: 
459                 $result = $translation->_('fifth');
460                 break;
461             default:
462                 switch ($interval % 10) {
463                     case 1:
464                         $result = $interval . $translation->_('st');
465                         break;
466                     case 2:
467                         $result = $interval . $translation->_('nd');
468                         break;
469                     case 3:
470                         $result = $interval . $translation->_('rd');
471                         break;
472                     default:
473                         $result = $interval . $translation->_('th');
474                 }
475         }
476         
477         return $result;
478     }
479     
480     /************************* Recurrence computation *****************************/
481     
482     /**
483      * merges Recurrences of given events into the given event set
484      * 
485      * @param  Tinebase_Record_RecordSet    $_events
486      * @param  Tinebase_DateTime                    $_from
487      * @param  Tinebase_DateTime                    $_until
488      * @return void
489      */
490     public static function mergeRecurrenceSet($_events, $_from, $_until)
491     {
492         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
493             . " from: $_from until: $_until");
494         
495         //compute recurset
496         $candidates = $_events->filter('rrule', "/^FREQ.*/", TRUE);
497        
498         foreach ($candidates as $candidate) {
499             try {
500                 $exceptions = $_events->filter('recurid', "/^{$candidate->uid}-.*/", TRUE);
501                 
502                 $recurSet = Calendar_Model_Rrule::computeRecurrenceSet($candidate, $exceptions, $_from, $_until);
503                 foreach ($recurSet as $event) {
504                     $_events->addRecord($event);
505                 }
506                 
507                 // check if candidate/baseEvent has an exception itself -> in this case remove baseEvent from set
508                 if (is_array($candidate->exdate) && in_array($candidate->dtstart, $candidate->exdate)) {
509                     $_events->removeRecord($candidate);
510                 }
511                 
512             } catch (Exception $e) {
513                if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
514                        . " Could not compute recurSet of event: {$candidate->getId()}");
515                Tinebase_Exception::log($e);
516                continue;
517             }
518         }
519     }
520     
521     /**
522      * add given recurrence to given set and to nessesary adoptions
523      * 
524      * @param Calendar_Model_Event      $_recurrence
525      * @param Tinebase_Record_RecordSet $_eventSet
526      */
527     protected static function addRecurrence($_recurrence, $_eventSet)
528     {
529         $_recurrence->setId('fakeid' . $_recurrence->base_event_id . '/' . $_recurrence->dtstart->getTimeStamp());
530         
531         // adjust alarms
532         if ($_recurrence->alarms instanceof Tinebase_Record_RecordSet) {
533             foreach($_recurrence->alarms as $alarm) {
534                 $alarm->alarm_time = clone $_recurrence->dtstart;
535                 $alarm->alarm_time->subMinute($alarm->getOption('minutes_before'));
536             }
537         }
538         
539         $_eventSet->addRecord($_recurrence);
540     }
541     
542     /**
543      * merge recurrences amd remove all events that do not match period filter
544      * 
545      * @param Tinebase_Record_RecordSet $_events
546      * @param Calendar_Model_EventFilter $_filter
547      */
548     public static function mergeAndRemoveNonMatchingRecurrences(Tinebase_Record_RecordSet $_events, Calendar_Model_EventFilter $_filter = null)
549     {
550         if (!$_filter) {
551             return;
552         }
553         
554         $period = $_filter->getFilter('period', false, true);
555         if ($period) {
556             self::mergeRecurrenceSet($_events, $period->getFrom(), $period->getUntil());
557             
558             foreach ($_events as $event) {
559                 if (! $event->isInPeriod($period)) {
560                     if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . ' (' . __LINE__ 
561                         . ') Removing not matching event ' . $event->summary);
562                     $_events->removeRecord($event);
563                 }
564             }
565         }
566     }
567     
568     /**
569      * returns next occurrence _ignoring exceptions_ or NULL if there is none/not computable
570      * 
571      * NOTE: an ongoing event during $from [start, end[ is considered as next 
572      * NOTE: for previous events on ongoing event is considered as previous
573      *  
574      * NOTE: computing the next occurrence of an open end rrule can be dangerous, as it might result
575      *       in a endless loop. Therefore we only make a limited number of attempts before giving up.
576      * 
577      * @param  Calendar_Model_Event         $_event
578      * @param  Tinebase_Record_RecordSet    $_exceptions
579      * @param  Tinebase_DateTime            $_from
580      * @param  Int                          $_which
581      * @return Calendar_Model_Event|NULL
582      */
583     public static function computeNextOccurrence($_event, $_exceptions, $_from, $_which = 1)
584     {
585         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
586                 . ' $from = ' . $_from->toString());
587         
588         if ($_which === 0 || ($_event->dtstart >= $_from && $_event->dtend > $_from)) {
589             return $_event;
590         }
591         
592         $freqMap = array(
593             self::FREQ_DAILY   => Tinebase_DateTime::MODIFIER_DAY,
594             self::FREQ_WEEKLY  => Tinebase_DateTime::MODIFIER_WEEK,
595             self::FREQ_MONTHLY => Tinebase_DateTime::MODIFIER_MONTH,
596             self::FREQ_YEARLY  => Tinebase_DateTime::MODIFIER_YEAR
597         );
598         
599         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
600         $rrule->setFromString($_event->rrule);
601         
602         $from  = clone $_from;
603         $until = clone $from;
604         $interval = $_which * $rrule->interval;
605         
606         // we don't want to compute ourself
607         $ownEvent = clone $_event;
608         $ownEvent->setRecurId($_event->getId());
609         $exceptions = clone $_exceptions;
610         $exceptions->addRecord($ownEvent);
611         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
612         
613         if ($_from->isEarlier($_event->dtstart)) {
614             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
615                 . ' from is ealier dtstart -> given event is next occurrence');
616             return $_event;
617         }
618         
619         $rangeDate = $_which > 0 ? $until : $from;
620         
621         if (! isset($freqMap[$rrule->freq])) {
622             if (Tinebase_Core::isLogLevel(Zend_Log::ERR)) Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__
623                     . ' Invalid RRULE:' . print_r($rrule->toArray(), true));
624             throw new Calendar_Exception('Invalid freq in RRULE: ' . $rrule->freq);
625         }
626         $rangeDate->add($interval, $freqMap[$rrule->freq]);
627         $attempts = 0;
628         
629         if ($_event->rrule_until instanceof DateTime && Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
630             . ' Event rrule_until: ' . $_event->rrule_until->toString());
631         
632         while (TRUE) {
633             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
634                 . ' trying to find next occurrence from ' . $from->toString());
635             
636             if ($_event->rrule_until instanceof DateTime && $from->isLater($_event->rrule_until)) {
637                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
638                     . ' passed rrule_until -> no further occurrences');
639                 return NULL;
640             }
641             
642             $until = ($_event->rrule_until instanceof DateTime && $until->isLater($_event->rrule_until))
643                 ? clone $_event->rrule_until 
644                 : $until;
645
646             $recurSet->merge(self::computeRecurrenceSet($_event, $exceptions, $from, $until));
647             $attempts++;
648             
649             // NOTE: computeRecurrenceSet also returns events during $from in some cases, but we need 
650             // to events later than $from.
651             $recurSet = $recurSet->filter(function($event) use ($from) {return $event->dtstart >= $from;});
652             
653             if (count($recurSet) >= abs($_which)) {
654                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
655                     . " found next occurrence after $attempts attempt(s)");
656                 break;
657             }
658             
659             if ($attempts > count($exceptions) + 5) {
660                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
661                     . " could not find the next occurrence after $attempts attempts, giving up");
662                 return NULL;
663             }
664             
665             $from->add($interval, $freqMap[$rrule->freq]);
666             $until->add($interval, $freqMap[$rrule->freq]);
667         }
668         
669         $recurSet->sort('dtstart', $_which > 0 ? 'ASC' : 'DESC');
670         $nextOccurrence = $recurSet[abs($_which)-1];
671         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
672                 . ' $nextOccurrence->dtstart = ' . $nextOccurrence->dtstart->toString());
673         return $nextOccurrence;
674     }
675     
676     /**
677      * Computes the Recurrence set of the given event leaving out $_event->exdate and $_exceptions
678      * 
679      * @todo respect rrule_until!
680      *
681      * @param  Calendar_Model_Event         $_event
682      * @param  Tinebase_Record_RecordSet    $_exceptions
683      * @param  Tinebase_DateTime            $_from
684      * @param  Tinebase_DateTime            $_until
685      * @return Tinebase_Record_RecordSet
686      * @throws Tinebase_Exception_UnexpectedValue
687      */
688     public static function computeRecurrenceSet($_event, $_exceptions, $_from, $_until)
689     {
690         if (! $_event->dtstart instanceof Tinebase_DateTime) {
691             throw new Tinebase_Exception_UnexpectedValue('Event needs DateTime dtstart: ' . print_r($_event->toArray(), TRUE));
692         }
693         
694         $rrule = new Calendar_Model_Rrule(NULL, TRUE);
695         $rrule->setFromString($_event->rrule);
696         
697         $exceptionRecurIds = self::getExceptionsRecurIds($_event, $_exceptions);
698         $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
699         
700         switch ($rrule->freq) {
701             case self::FREQ_DAILY:
702                 
703                 self::_computeRecurDaily($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
704                 break;
705                 
706             case self::FREQ_WEEKLY:
707                 // default BYDAY clause
708                 if (! $rrule->byday) {
709                     $rrule->byday = array_search($_event->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP);
710                 }
711                 
712                 if (! $rrule->wkst) {
713                     $rrule->wkst = self::getWeekStart();
714                 }
715                 $weekDays = array_keys(self::$WEEKDAY_DIGIT_MAP);
716                 array_splice($weekDays, 0, 0, array_splice($weekDays, array_search($rrule->wkst, $weekDays)));
717                     
718                 $dailyrrule = clone ($rrule);
719                 $dailyrrule->freq = self::FREQ_DAILY;
720                 $dailyrrule->interval = 7 * $rrule->interval;
721                 
722                 $eventLength = $_event->dtstart->diff($_event->dtend);
723                 
724                 foreach (explode(',', $rrule->byday) as $recurWeekDay) {
725                     // NOTE: in weekly computation, each wdays base event is a recur instance itself
726                     $baseEvent = clone $_event;
727                     
728                     // NOTE: skipping must be done in organizer_tz
729                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
730                     $direction = array_search($recurWeekDay, $weekDays) >= array_search(array_search($baseEvent->dtstart->format('w'), self::$WEEKDAY_DIGIT_MAP), $weekDays) ? +1 : -1;
731                     self::skipWday($baseEvent->dtstart, $recurWeekDay, $direction, TRUE);
732                     $baseEvent->dtstart->setTimezone('UTC');
733                     
734                     $baseEvent->dtend = clone($baseEvent->dtstart);
735                     $baseEvent->dtend->add($eventLength);
736                     
737                     self::_computeRecurDaily($baseEvent, $dailyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
738                     
739                     // check if base event (recur instance) needs to be added to the set
740                     if ($baseEvent->dtstart > $_event->dtstart && $baseEvent->dtstart >= $_from && $baseEvent->dtstart < $_until) {
741                         if (! in_array($baseEvent->setRecurId($baseEvent->getId()), $exceptionRecurIds)) {
742                             self::addRecurrence($baseEvent, $recurSet);
743                         }
744                     }
745                 }
746                 break;
747                 
748             case self::FREQ_MONTHLY:
749                 if ($rrule->byday) {
750                     self::_computeRecurMonthlyByDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
751                 } else {
752                     self::_computeRecurMonthlyByMonthDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
753                 }
754                 break;
755                 
756             case self::FREQ_YEARLY:
757                 $yearlyrrule = clone $rrule;
758                 $yearlyrrule->freq = self::FREQ_MONTHLY;
759                 $yearlyrrule->interval = 12;
760                 
761                 $baseEvent = clone $_event;
762                 $originatorsDtstart = clone $baseEvent->dtstart;
763                 $originatorsDtstart->setTimezone($_event->originator_tz);
764                 
765                 // @TODO respect BYMONTH
766                 if ($rrule->bymonth && $rrule->bymonth != $originatorsDtstart->format('n')) {
767                     // adopt
768                     $diff = (12 + $rrule->bymonth - $originatorsDtstart->format('n')) % 12;
769                     
770                     // NOTE: skipping must be done in organizer_tz
771                     $baseEvent->dtstart->setTimezone($_event->originator_tz);
772                     $baseEvent->dtend->setTimezone($_event->originator_tz);
773                     $baseEvent->dtstart->addMonth($diff);
774                     $baseEvent->dtend->addMonth($diff);
775                     $baseEvent->dtstart->setTimezone('UTC');
776                     $baseEvent->dtend->setTimezone('UTC');
777                     
778                     // check if base event (recur instance) needs to be added to the set
779                     if ($baseEvent->dtstart->isLater($_from) && $baseEvent->dtstart->isEarlier($_until)) {
780                         if (! in_array($baseEvent->setRecurId($baseEvent->getId()), $exceptionRecurIds)) {
781                             self::addRecurrence($baseEvent, $recurSet);
782                         }
783                     }
784                 }
785                 
786                 if ($rrule->byday) {
787                     self::_computeRecurMonthlyByDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
788                 } else {
789                     self::_computeRecurMonthlyByMonthDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
790                 }
791
792                 break;
793                 
794         }
795         
796         return $recurSet;
797     }
798     
799     /**
800      * returns array of exception recurids
801      *
802      * @param  Calendar_Model_Event         $_event
803      * @param  Tinebase_Record_RecordSet    $_exceptions
804      * @return array
805      */
806     public static function getExceptionsRecurIds($_event, $_exceptions)
807     {
808         $recurIds = $_exceptions->recurid;
809         
810         if (! empty($_event->exdate)) {
811             $exdates = is_array($_event->exdate) ? $_event->exdate : array($_event->exdate);
812             foreach ($exdates as $exdate) {
813                 $recurIds[] = $_event->uid . '-' . $exdate->toString(Tinebase_Record_Abstract::ISO8601LONG);
814             }
815         }
816         return array_values($recurIds);
817     }
818     
819     /**
820      * gets an cloned event to be used for new recur events
821      * 
822      * @param  Calendar_Model_Event         $_event
823      * @return Calendar_Model_Event         $_event
824      */
825     public static function cloneEvent($_event)
826     {
827         $clone = clone $_event;
828         $clone->setId(NULL);
829         //unset($clone->exdate);
830         //unset($clone->rrule);
831         //unset($clone->rrule_until);
832         
833         return $clone;
834     }
835     
836     /**
837      * computes daily recurring events and inserts them into given $_recurSet
838      *
839      * @param Calendar_Model_Event      $_event
840      * @param Calendar_Model_Rrule      $_rrule
841      * @param array                     $_exceptionRecurIds
842      * @param Tinebase_DateTime                 $_from
843      * @param Tinebase_DateTime                 $_until
844      * @param Tinebase_Record_RecordSet $_recurSet
845      * @return void
846      */
847     protected static function _computeRecurDaily($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
848     {
849         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
850             . " from: $_from until: $_until");
851         
852         $computationStartDate = clone $_event->dtstart;
853         $endDate = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until))
854             ? $_event->rrule_until
855             : $_until;
856         if (! $endDate instanceof Tinebase_DateTime) {
857             throw new Tinebase_Exception_InvalidArgument('End date is no DateTime');
858         }
859         $computationEndDate   = clone $endDate;
860
861         // if dtstart is before $_from, we compute the offset where to start our calculations
862         if ($_event->dtstart->isEarlier($_from)) {
863             $originatorsOriginalDtend = $_event->dtend->getClone()->setTimezone($_event->originator_tz);
864             $originatorsFrom = $_from->getClone()->setTimezone($_event->originator_tz);
865
866             $dstDiff = $originatorsFrom->get('I') - $originatorsOriginalDtend->get('I');
867
868             $computationOffsetDays = floor(($_from->getTimestamp() - $_event->dtend->getTimestamp() + $dstDiff * 3600) / (self::TS_DAY * $_rrule->interval)) * $_rrule->interval;
869             $computationStartDate->add($computationOffsetDays, Tinebase_DateTime::MODIFIER_DAY);
870         }
871
872         $eventLength = $_event->dtstart->diff($_event->dtend);
873
874         $originatorsOriginalDtstart = $_event->dtstart->getClone()->setTimezone($_event->originator_tz);
875
876         while (true) {
877             $computationStartDate->addDay($_rrule->interval);
878
879             $recurEvent = self::cloneEvent($_event);
880             $recurEvent->dtstart = clone ($computationStartDate);
881             
882             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
883                 . " Checking candidate at " . $recurEvent->dtstart->format('c'));
884             
885             $originatorsDtstart = $recurEvent->dtstart->getClone()->setTimezone($_event->originator_tz);
886
887             $recurEvent->dtstart->add($originatorsOriginalDtstart->get('I') - $originatorsDtstart->get('I'), Tinebase_DateTime::MODIFIER_HOUR);
888
889             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
890                 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
891                     . " Leaving loop: end date " . $computationEndDate->format('c') . " is earlier than recurEvent->dtstart "
892                     . $recurEvent->dtstart->format('c'));
893                 break;
894             }
895             
896             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
897             $recurEvent->dtend = clone $recurEvent->dtstart;
898             $recurEvent->dtend->add($eventLength);
899
900             $recurEvent->setRecurId($_event->getId());
901
902             if ($_from->compare($recurEvent->dtend) >= 0) {
903                 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
904                     . " Skip event: end date $_from is after recurEvent->dtend " . $recurEvent->dtend);
905
906                 continue;
907             }
908             
909             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
910                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
911                     . " Found recurrence at " . $recurEvent->dtstart);
912
913                 self::addRecurrence($recurEvent, $_recurSet);
914             }
915         }
916     }
917     
918     /**
919      * computes monthly (bymonthday) recurring events and inserts them into given $_recurSet
920      *
921      * @param Calendar_Model_Event      $_event
922      * @param Calendar_Model_Rrule      $_rrule
923      * @param array                     $_exceptionRecurIds
924      * @param Tinebase_DateTime                 $_from
925      * @param Tinebase_DateTime                 $_until
926      * @param Tinebase_Record_RecordSet $_recurSet
927      * @return void
928      */
929     protected static function _computeRecurMonthlyByMonthDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
930     {
931         
932         $eventInOrganizerTZ = clone $_event;
933         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
934         
935         // some clients skip the monthday e.g. for yearly rrules
936         if (! $_rrule->bymonthday) {
937             $_rrule->bymonthday = $eventInOrganizerTZ->dtstart->format('j');
938         }
939         
940         // NOTE: non existing dates will be discarded (e.g. 31. Feb.)
941         //       for correct computations we deal with virtual dates, represented as arrays
942         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
943         // adopt startdate if rrule monthday != dtstart monthday
944         // in this case, the first instance is not the base event!
945         if ($_rrule->bymonthday != $computationStartDateArray['day']) {
946             $computationStartDateArray['day'] = $_rrule->bymonthday;
947             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, -1 * $_rrule->interval);
948         }
949         
950         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
951         
952         
953         
954         // if dtstart is before $_from, we compute the offset where to start our calculations
955         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
956             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
957             // NOTE: $computationOffsetMonth must be multiple of interval!
958             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
959             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
960         }
961         
962         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
963         
964         $originatorsOriginalDtstart = clone $eventInOrganizerTZ->dtstart;
965         
966         while(true) {
967             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $_rrule->interval);
968             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
969             $recurEvent->dtstart = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
970             
971             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
972             $recurEvent->dtend = clone $recurEvent->dtstart;
973             $recurEvent->dtend->add($eventLength);
974             
975             $recurEvent->setTimezone('UTC');
976             
977             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
978                 break;
979             }
980             
981             // skip non existing dates
982             if (! Tinebase_DateTime::isDate(self::array2string($computationStartDateArray))) {
983                 continue;
984             }
985             
986             // skip events ending before our period.
987             // NOTE: such events could be included, cause our offset only calcs months and not seconds
988             if ($_from->compare($recurEvent->dtend) >= 0) {
989                 continue;
990             }
991             
992             $recurEvent->setRecurId($_event->getId());
993             
994             
995             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
996                 self::addRecurrence($recurEvent, $_recurSet);
997             }
998         }
999     }
1000     
1001     /**
1002      * computes monthly (byday) recurring events and inserts them into given $_recurSet
1003      *
1004      * @param Calendar_Model_Event      $_event
1005      * @param Calendar_Model_Rrule      $_rrule
1006      * @param array                     $_exceptionRecurIds
1007      * @param Tinebase_DateTime                 $_from
1008      * @param Tinebase_DateTime                 $_until
1009      * @param Tinebase_Record_RecordSet $_recurSet
1010      * @return void
1011      */
1012     protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
1013     {
1014         $eventInOrganizerTZ = clone $_event;
1015         $eventInOrganizerTZ->setTimezone($_event->originator_tz);
1016         
1017         $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
1018         
1019         // if period contains base events dtstart, we let computation start one intervall to early to catch
1020         // the cases when dtstart of base event not equals the first instance. If it fits, we filter the additional 
1021         // instance out later
1022         if ($eventInOrganizerTZ->dtstart->isLater($_from) && $eventInOrganizerTZ->dtstart->isEarlier($_until)) {
1023             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, -1 * $_rrule->interval);
1024         }
1025         
1026         $computationEndDate   = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
1027         
1028         // if dtstart is before $_from, we compute the offset where to start our calculations
1029         if ($eventInOrganizerTZ->dtstart->isEarlier($_from)) {
1030             $computationOffsetMonth = self::getMonthDiff($eventInOrganizerTZ->dtend, $_from);
1031             // NOTE: $computationOffsetMonth must be multiple of interval!
1032             $computationOffsetMonth = floor($computationOffsetMonth/$_rrule->interval) * $_rrule->interval;
1033             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $computationOffsetMonth - $_rrule->interval);
1034         }
1035         
1036         $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
1037         
1038         $computationStartDateArray['day'] = 1;
1039         
1040         $byDayInterval = (int) substr($_rrule->byday, 0, -2);
1041         $byDayWeekday  = substr($_rrule->byday, -2);
1042         
1043         if ($byDayInterval === 0 || ! (isset(self::$WEEKDAY_DIGIT_MAP[$byDayWeekday]) || array_key_exists($byDayWeekday, self::$WEEKDAY_DIGIT_MAP))) {
1044             throw new Exception('mal formated rrule byday part: "' . $_rrule->byday . '"');
1045         }
1046         
1047         while(true) {
1048             $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $_rrule->interval);
1049             $computationStartDate = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
1050             
1051             $recurEvent = self::cloneEvent($eventInOrganizerTZ);
1052             $recurEvent->dtstart = clone $computationStartDate;
1053             
1054             if ($byDayInterval < 0) {
1055                 $recurEvent->dtstart = self::array2date(self::addMonthIgnoringDay($computationStartDateArray, 1), $eventInOrganizerTZ->originator_tz);
1056                 $recurEvent->dtstart->subDay(1);
1057             }
1058             
1059             self::skipWday($recurEvent->dtstart, $byDayWeekday, $byDayInterval, TRUE);
1060             
1061             // we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise 
1062             $recurEvent->dtend = clone $recurEvent->dtstart;
1063             $recurEvent->dtend->add($eventLength);
1064             
1065             $recurEvent->setTimezone('UTC');
1066             
1067             if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
1068                 break;
1069             }
1070             
1071             // skip non existing dates
1072             if ($computationStartDate->get('m') != $recurEvent->dtstart->get('m')) {
1073                 continue;
1074             }
1075             
1076             // skip events ending before our period.
1077             // NOTE: such events could be included, cause our offset only calcs months and not seconds
1078             if ($_from->compare($recurEvent->dtend) >= 0) {
1079                 continue;
1080             }
1081             
1082             // skip instances begining before the baseEvent
1083             if ($recurEvent->dtstart->compare($_event->dtstart) < 0) {
1084                 continue;
1085             }
1086             
1087             // skip if event equal baseevent
1088             if ($_event->dtstart->equals($recurEvent->dtstart)) {
1089                 continue;
1090             }
1091             
1092             $recurEvent->setRecurId($_event->getId());
1093             
1094             if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
1095                 self::addRecurrence($recurEvent, $_recurSet);
1096             }
1097         }
1098     }
1099
1100     /**
1101      * skips date to (n'th next/previous) occurance of $_wday
1102      *
1103      * @param Tinebase_DateTime  $_date
1104      * @param int|string $_wday
1105      * @param int        $_n
1106      * @param bool       $_considerDateItself
1107      */
1108     public static function skipWday($_date, $_wday, $_n = +1, $_considerDateItself = FALSE)
1109     {
1110         $wdayDigit = is_int($_wday) ? $_wday : self::$WEEKDAY_DIGIT_MAP[$_wday];
1111         $wdayOffset = $_date->get('w') - $wdayDigit;
1112                                 
1113         if ($_n == 0) {
1114             throw new Exception('$_n must not be 0');
1115         }
1116         
1117         $direction = $_n > 0 ? 'forward' : 'backward';
1118         $weeks = abs($_n);
1119         
1120         if ($_considerDateItself && $wdayOffset == 0) {
1121             $weeks--;
1122         }
1123         
1124         switch ($direction) {
1125             case 'forward':
1126                 if ($wdayOffset >= 0) {
1127                     $_date->addDay(($weeks * 7) - $wdayOffset);
1128                 } else {
1129                     $_date->addDay(abs($wdayOffset) + ($weeks -1) * 7);
1130                 }
1131                 
1132                 break;
1133             case 'backward':
1134                 if ($wdayOffset > 0) {
1135                     $_date->subDay(abs($wdayOffset) + ($weeks -1) * 7);
1136                 } else {
1137                     $_date->subDay(($weeks * 7) + $wdayOffset);
1138                 }
1139                 break;
1140         }
1141         
1142         return $_date;
1143     }
1144     
1145     /**
1146      * converts a Tinebase_DateTime to Array
1147      *
1148      * @param  Tinebase_DateTime $_date
1149      * @return array
1150      * @throws Tinebase_Exception_UnexpectedValue
1151      */
1152     public static function date2array($_date)
1153     {
1154         if (! $_date instanceof Tinebase_DateTime) {
1155             throw new Tinebase_Exception_UnexpectedValue('DateTime expected');
1156         }
1157         
1158         return array_intersect_key($_date->toArray(), array_flip(array(
1159             'day' , 'month', 'year', 'hour', 'minute', 'second'
1160         )));
1161     }
1162     
1163     /**
1164      * converts date array to Tinebase_DateTime
1165      *
1166      * @param  array $_dateArray
1167      * @param  string $_timezone
1168      * @return Tinebase_DateTime
1169      */
1170     public static function array2date(array $_dateArray, $_timezone='UTC')
1171     {
1172         date_default_timezone_set($_timezone);
1173         
1174         $date = new Tinebase_DateTime(mktime($_dateArray['hour'], $_dateArray['minute'], $_dateArray['second'], $_dateArray['month'], $_dateArray['day'], $_dateArray['year']));
1175         $date->setTimezone($_timezone);
1176         
1177         date_default_timezone_set('UTC');
1178         
1179         return $date;
1180     }
1181     
1182     /**
1183      * converts date array to string
1184      *
1185      * @param  array $_dateArray
1186      * @return string
1187      */
1188     public static function array2string(array $_dateArray)
1189     {
1190         return $_dateArray['year'] . '-' . str_pad($_dateArray['month'], 2, '0', STR_PAD_LEFT) . '-' . str_pad($_dateArray['day'], 2, '0', STR_PAD_LEFT) . ' ' . 
1191                 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);
1192     }
1193     
1194     /**
1195      * get number of month different from $_date1 to $_date2
1196      *
1197      * @param  Tinebase_DateTime|array $_from
1198      * @param  Tinebase_DateTime|array $_until
1199      * @return int
1200      */
1201     public static function getMonthDiff($_from, $_until)
1202     {
1203         $date1Array = is_array($_from) ? $_from : self::date2array($_from);
1204         $date2Array = is_array($_until) ? $_until : self::date2array($_until);
1205         
1206         return (12 * $date2Array['year'] + $date2Array['month']) - (12 * $date1Array['year'] + $date1Array['month']);
1207     }
1208     
1209     /**
1210      * add month and don't touch the day.
1211      * NOTE: The resulting date may no exist e.g. 31. Feb. -> virtual date 
1212      *
1213      * @param  Tinebase_DateTime|array  $_date
1214      * @param  int              $_months
1215      * @return array
1216      */
1217     public static function addMonthIgnoringDay($_date, $_months)
1218     {
1219         $dateArr = is_array($_date) ? $_date : self::date2array($_date);
1220         
1221         $totalMonth = 12 * $dateArr['year'] + $dateArr['month'] + $_months;
1222         $dateArr['year'] = $totalMonth % 12 ? floor($totalMonth/12) : $totalMonth/12 -1;
1223         $dateArr['month'] = $totalMonth % 12 ? $totalMonth % 12 : 12;
1224         
1225         return $dateArr;
1226     }
1227     
1228     /**
1229      * adds diff to date and applies dst fix
1230      *
1231      * @param Tinebase_DateTime $_dateInUTC
1232      * @param DateTimeInterval $_diff
1233      * @param string    $_timezoneForDstFix
1234      */
1235     public static function addUTCDateDstFix($_dateInUTC, $_diff, $_timezoneForDstFix)
1236     {
1237         $_dateInUTC->setTimezone($_timezoneForDstFix);
1238         $_dateInUTC->add($_dateInUTC->get('I') ? 1 : 0, Tinebase_DateTime::MODIFIER_HOUR);
1239         $_dateInUTC->add($_diff);
1240         $_dateInUTC->subHour($_dateInUTC->get('I') ? 1 : 0);
1241         $_dateInUTC->setTimezone('UTC');
1242     }
1243     
1244     /**
1245      * returns weekstart in iCal day format
1246      * 
1247      * @param  string $locale
1248      * @return string
1249      */
1250     public static function getWeekStart($locale = NULL) {
1251         $locale = $locale ?: Tinebase_Core::getLocale();
1252         
1253         $weekInfo = Zend_Locale::getTranslationList('week', $locale);
1254         if (!isset($weekInfo['firstDay'])) {
1255             $weekInfo['firstDay'] = 'mon';
1256         }
1257         return Tinebase_Helper::array_value($weekInfo['firstDay'], array_flip(self::$WEEKDAY_MAP));
1258     }
1259 }