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