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)
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
23 class Calendar_Model_Rrule extends Tinebase_Record_Abstract
26 * supported freq types
28 const FREQ_DAILY = 'DAILY';
29 const FREQ_WEEKLY = 'WEEKLY';
30 const FREQ_MONTHLY = 'MONTHLY';
31 const FREQ_YEARLY = 'YEARLY';
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';
45 * maps weeksdays to digits
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
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'
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
81 * key in $_validators/$_properties array for the filed which
82 * represents the identifier
86 protected $_identifier = 'id';
89 * application the record belongs to
93 protected $_application = 'Calendar';
100 protected $_validators = array(
101 'id' => array('allowEmpty' => true, /*'Alnum'*/),
103 'allowEmpty' => true,
104 array('InArray', array(self::FREQ_DAILY, self::FREQ_MONTHLY, self::FREQ_WEEKLY, self::FREQ_YEARLY)),
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' ),
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)),
114 'until' => array('allowEmpty' => true ),
115 'count' => array('allowEmpty' => true, 'Int' ),
123 protected $_datetimeFields = array(
128 * @var array supported standard rrule parts
130 protected $_rruleParts = array('freq', 'interval', 'until', 'count', 'wkst', 'byday', 'bymonth', 'bymonthday');
133 * @see /Tinebase/Record/Abstract::__construct
135 public function __construct($_data = NULL, $_bypassFilters = false, $_convertDates = true)
139 if (is_string($_data)) {
140 $rruleString = $_data;
144 parent::__construct($_data, $_bypassFilters, $_convertDates);
147 $this->setFromString($rruleString);
152 * set from ical rrule string
154 * @param string $_rrule
156 public function setFromString($_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)) {
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';
173 $skipParts[] = 'byday';
175 throw new Tinebase_Exception_UnexpectedValue("$part is not a known rrule part");
178 $this->$part = $value;
184 * creates a rrule from string
186 * @param string $_rruleString
187 * @return Calendar_Model_Rrule
189 public static function getRruleFromString($_rruleString)
191 $rrule = new Calendar_Model_Rrule(NULL, TRUE);
192 $rrule->setFromString($_rruleString);
198 * returns a ical rrule string
202 public function __toString()
204 $stringParts = array();
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;
213 return implode(';', $stringParts);
217 * set properties and convert them into internal representatin on the fly
219 * @param string $_name
220 * @param mixed $_value
223 public function __set($_name, $_value) {
226 if (! empty($_value)) {
227 if ($_value instanceof DateTime) {
228 $this->_properties['until'] = $_value;
230 $this->_properties['until'] = new Tinebase_DateTime($_value);
236 if (! empty($_value)) {
237 $values = explode(',', $_value);
238 $this->_properties[$_name] = (integer) $values[0];
242 parent::__set($_name, $_value);
248 * gets record related properties
250 * @param string _name of property
251 * @throws Tinebase_Exception_UnexpectedValue
252 * @return mixed value of property
254 public function __get($_name)
256 $value = parent::__get($_name);
260 return (int) $value > 1 ? (int) $value : 1;
269 * validate and filter the the internal data
271 * @param $_throwExceptionOnInvalidData
273 * @throws Tinebase_Exception_Record_Validation
275 public function isValid($_throwExceptionOnInvalidData = false)
277 $isValid = parent::isValid($_throwExceptionOnInvalidData);
279 if (isset($this->_properties['count']) && isset($this->_properties['until'])) {
280 $isValid = $this->_isValidated = false;
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);
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.
296 * @param Calendar_Model_Event $event
298 public function normalize(Calendar_Model_Event $event)
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);
306 switch ($this->freq) {
307 case self::FREQ_WEEKLY:
308 if (! $this->wkst ) {
309 $this->wkst = self::getWeekStart();
312 if (! $this->byday) {
313 $this->byday = array_search($originatorDtStart->format('w'), self::$WEEKDAY_DIGIT_MAP);
317 case self::FREQ_MONTHLY:
318 if (! $this->byday && ! $this->bymonthday) {
319 $this->bymonthday = $originatorDtStart->format('j');
323 case self::FREQ_YEARLY:
324 if (! $this->byday && ! $this->bymonthday) {
325 $this->bymonthday = $originatorDtStart->format('j');
327 if (! $this->bymonth) {
328 $this->bymonth = $originatorDtStart->format('n');
338 * get human readable version of this rrule
340 * @param Zend_Translate $translation
343 public function getTranslatedRule($translation)
346 $locale = new Zend_Locale($translation->getAdapter()->getLocale());
347 $numberFormatter = null;
348 $weekDays = Zend_Locale::getTranslationList('day', $locale);
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');
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') . ' ';
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') . ' ' : ', ';
372 case self::FREQ_MONTHLY:
374 $byDayInterval = (int) substr($this->byday, 0, -2);
375 $byDayIntervalTranslation = $this->_getIntervalTranslation($byDayInterval, $translation);
376 $byDayWeekday = substr($this->byday, -2);
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]]);
383 $bymonthday = $this->bymonthday;
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));
390 case self::FREQ_YEARLY:
391 $month = Zend_Locale::getTranslationList('month', $locale);
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]);
398 $rule .= sprintf($translation->_('Yearly on the %1$s of %2$s'), $this->_formatInterval($this->bymonthday, $translation, $numberFormatter), $month[$this->bymonth]);
408 * format interval (use NumberFormatter if intl extension is found)
410 * @param integer $number
411 * @param Zend_Translate $translation
412 * @param NumberFormatter|null $numberFormatter
415 protected function _formatInterval($number, $translation, $numberFormatter = null)
417 if ($numberFormatter === null && extension_loaded('intl')) {
418 $locale = new Zend_Locale($translation->getAdapter()->getLocale());
419 $numberFormatter = new NumberFormatter((string) $locale, NumberFormatter::ORDINAL);
422 $result = ($numberFormatter) ? $numberFormatter->format($number) : $this->_getIntervalTranslation($number, $translation);
428 * get translation string for interval (first, second, ...)
430 * @param integer $interval
431 * @param Zend_Translate $translation
434 protected function _getIntervalTranslation($interval, $translation)
438 $result = $translation->_('second to last');
441 $result = $translation->_('last');
444 throw new Tinebase_Exception_UnexpectedValue('0 is not supported');
447 $result = $translation->_('first');
450 $result = $translation->_('second');
453 $result = $translation->_('third');
456 $result = $translation->_('fourth');
459 $result = $translation->_('fifth');
462 switch ($interval % 10) {
464 $result = $interval . $translation->_('st');
467 $result = $interval . $translation->_('nd');
470 $result = $interval . $translation->_('rd');
473 $result = $interval . $translation->_('th');
480 /************************* Recurrence computation *****************************/
483 * merges Recurrences of given events into the given event set
485 * @param Tinebase_Record_RecordSet $_events
486 * @param Tinebase_DateTime $_from
487 * @param Tinebase_DateTime $_until
490 public static function mergeRecurrenceSet($_events, $_from, $_until)
492 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
493 . " from: $_from until: $_until");
496 $candidates = $_events->filter('rrule', "/^FREQ.*/", TRUE);
498 foreach ($candidates as $candidate) {
500 $exceptions = $_events->filter('recurid', "/^{$candidate->uid}-.*/", TRUE);
502 $recurSet = Calendar_Model_Rrule::computeRecurrenceSet($candidate, $exceptions, $_from, $_until);
503 foreach ($recurSet as $event) {
504 $_events->addRecord($event);
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);
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);
522 * add given recurrence to given set and to nessesary adoptions
524 * @param Calendar_Model_Event $_recurrence
525 * @param Tinebase_Record_RecordSet $_eventSet
527 protected static function addRecurrence($_recurrence, $_eventSet)
529 $_recurrence->setId('fakeid' . $_recurrence->base_event_id . '/' . $_recurrence->dtstart->getTimeStamp());
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'));
539 $_eventSet->addRecord($_recurrence);
543 * merge recurrences amd remove all events that do not match period filter
545 * @param Tinebase_Record_RecordSet $_events
546 * @param Calendar_Model_EventFilter $_filter
548 public static function mergeAndRemoveNonMatchingRecurrences(Tinebase_Record_RecordSet $_events, Calendar_Model_EventFilter $_filter = null)
554 $period = $_filter->getFilter('period', false, true);
556 self::mergeRecurrenceSet($_events, $period->getFrom(), $period->getUntil());
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);
569 * returns next occurrence _ignoring exceptions_ or NULL if there is none/not computable
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
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.
577 * @param Calendar_Model_Event $_event
578 * @param Tinebase_Record_RecordSet $_exceptions
579 * @param Tinebase_DateTime $_from
581 * @return Calendar_Model_Event|NULL
583 public static function computeNextOccurrence($_event, $_exceptions, $_from, $_which = 1)
585 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
586 . ' $from = ' . $_from->toString());
588 if ($_which === 0 || ($_event->dtstart >= $_from && $_event->dtend > $_from)) {
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
599 $rrule = new Calendar_Model_Rrule(NULL, TRUE);
600 $rrule->setFromString($_event->rrule);
602 $from = clone $_from;
603 $until = clone $from;
604 $interval = $_which * $rrule->interval;
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');
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');
619 $rangeDate = $_which > 0 ? $until : $from;
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);
626 $rangeDate->add($interval, $freqMap[$rrule->freq]);
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());
633 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
634 . ' trying to find next occurrence from ' . $from->toString());
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');
642 $until = ($_event->rrule_until instanceof DateTime && $until->isLater($_event->rrule_until))
643 ? clone $_event->rrule_until
646 $recurSet->merge(self::computeRecurrenceSet($_event, $exceptions, $from, $until));
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;});
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)");
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");
665 $from->add($interval, $freqMap[$rrule->freq]);
666 $until->add($interval, $freqMap[$rrule->freq]);
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;
677 * Computes the Recurrence set of the given event leaving out $_event->exdate and $_exceptions
679 * @todo respect rrule_until!
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
688 public static function computeRecurrenceSet($_event, $_exceptions, $_from, $_until)
690 if (! $_event->dtstart instanceof Tinebase_DateTime) {
691 throw new Tinebase_Exception_UnexpectedValue('Event needs DateTime dtstart: ' . print_r($_event->toArray(), TRUE));
694 $rrule = new Calendar_Model_Rrule(NULL, TRUE);
695 $rrule->setFromString($_event->rrule);
697 $exceptionRecurIds = self::getExceptionsRecurIds($_event, $_exceptions);
698 $recurSet = new Tinebase_Record_RecordSet('Calendar_Model_Event');
700 switch ($rrule->freq) {
701 case self::FREQ_DAILY:
703 self::_computeRecurDaily($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
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);
712 if (! $rrule->wkst) {
713 $rrule->wkst = self::getWeekStart();
715 $weekDays = array_keys(self::$WEEKDAY_DIGIT_MAP);
716 array_splice($weekDays, 0, 0, array_splice($weekDays, array_search($rrule->wkst, $weekDays)));
718 $dailyrrule = clone ($rrule);
719 $dailyrrule->freq = self::FREQ_DAILY;
720 $dailyrrule->interval = 7 * $rrule->interval;
722 $eventLength = $_event->dtstart->diff($_event->dtend);
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;
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');
734 $baseEvent->dtend = clone($baseEvent->dtstart);
735 $baseEvent->dtend->add($eventLength);
737 self::_computeRecurDaily($baseEvent, $dailyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
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);
748 case self::FREQ_MONTHLY:
750 self::_computeRecurMonthlyByDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
752 self::_computeRecurMonthlyByMonthDay($_event, $rrule, $exceptionRecurIds, $_from, $_until, $recurSet);
756 case self::FREQ_YEARLY:
757 $yearlyrrule = clone $rrule;
758 $yearlyrrule->freq = self::FREQ_MONTHLY;
759 $yearlyrrule->interval = 12;
761 $baseEvent = clone $_event;
762 $originatorsDtstart = clone $baseEvent->dtstart;
763 $originatorsDtstart->setTimezone($_event->originator_tz);
765 // @TODO respect BYMONTH
766 if ($rrule->bymonth && $rrule->bymonth != $originatorsDtstart->format('n')) {
768 $diff = (12 + $rrule->bymonth - $originatorsDtstart->format('n')) % 12;
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');
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);
787 self::_computeRecurMonthlyByDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
789 self::_computeRecurMonthlyByMonthDay($baseEvent, $yearlyrrule, $exceptionRecurIds, $_from, $_until, $recurSet);
800 * returns array of exception recurids
802 * @param Calendar_Model_Event $_event
803 * @param Tinebase_Record_RecordSet $_exceptions
806 public static function getExceptionsRecurIds($_event, $_exceptions)
808 $recurIds = $_exceptions->recurid;
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);
816 return array_values($recurIds);
820 * gets an cloned event to be used for new recur events
822 * @param Calendar_Model_Event $_event
823 * @return Calendar_Model_Event $_event
825 public static function cloneEvent($_event)
827 $clone = clone $_event;
829 //unset($clone->exdate);
830 //unset($clone->rrule);
831 //unset($clone->rrule_until);
837 * computes daily recurring events and inserts them into given $_recurSet
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
847 protected static function _computeRecurDaily($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
849 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
850 . " from: $_from until: $_until");
852 $computationStartDate = clone $_event->dtstart;
853 $endDate = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until))
854 ? $_event->rrule_until
856 if (! $endDate instanceof Tinebase_DateTime) {
857 throw new Tinebase_Exception_InvalidArgument('End date is no DateTime');
859 $computationEndDate = clone $endDate;
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);
866 $dstDiff = $originatorsFrom->get('I') - $originatorsOriginalDtend->get('I');
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);
872 $eventLength = $_event->dtstart->diff($_event->dtend);
874 $originatorsOriginalDtstart = $_event->dtstart->getClone()->setTimezone($_event->originator_tz);
877 $computationStartDate->addDay($_rrule->interval);
879 $recurEvent = self::cloneEvent($_event);
880 $recurEvent->dtstart = clone ($computationStartDate);
882 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
883 . " Checking candidate at " . $recurEvent->dtstart->format('c'));
885 $originatorsDtstart = $recurEvent->dtstart->getClone()->setTimezone($_event->originator_tz);
887 $recurEvent->dtstart->add($originatorsOriginalDtstart->get('I') - $originatorsDtstart->get('I'), Tinebase_DateTime::MODIFIER_HOUR);
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'));
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);
900 $recurEvent->setRecurId($_event->getId());
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);
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);
913 self::addRecurrence($recurEvent, $_recurSet);
919 * computes monthly (bymonthday) recurring events and inserts them into given $_recurSet
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
929 protected static function _computeRecurMonthlyByMonthDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
932 $eventInOrganizerTZ = clone $_event;
933 $eventInOrganizerTZ->setTimezone($_event->originator_tz);
935 // some clients skip the monthday e.g. for yearly rrules
936 if (! $_rrule->bymonthday) {
937 $_rrule->bymonthday = $eventInOrganizerTZ->dtstart->format('j');
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);
950 $computationEndDate = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
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);
962 $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
964 $originatorsOriginalDtstart = clone $eventInOrganizerTZ->dtstart;
967 $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $_rrule->interval);
968 $recurEvent = self::cloneEvent($eventInOrganizerTZ);
969 $recurEvent->dtstart = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
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);
975 $recurEvent->setTimezone('UTC');
977 if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
981 // skip non existing dates
982 if (! Tinebase_DateTime::isDate(self::array2string($computationStartDateArray))) {
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) {
992 $recurEvent->setRecurId($_event->getId());
995 if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
996 self::addRecurrence($recurEvent, $_recurSet);
1002 * computes monthly (byday) recurring events and inserts them into given $_recurSet
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
1012 protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptionRecurIds, $_from, $_until, $_recurSet)
1014 $eventInOrganizerTZ = clone $_event;
1015 $eventInOrganizerTZ->setTimezone($_event->originator_tz);
1017 $computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);
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);
1026 $computationEndDate = ($_event->rrule_until instanceof DateTime && $_until->isLater($_event->rrule_until)) ? $_event->rrule_until : $_until;
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);
1036 $eventLength = $eventInOrganizerTZ->dtstart->diff($eventInOrganizerTZ->dtend);
1038 $computationStartDateArray['day'] = 1;
1040 $byDayInterval = (int) substr($_rrule->byday, 0, -2);
1041 $byDayWeekday = substr($_rrule->byday, -2);
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 . '"');
1048 $computationStartDateArray = self::addMonthIgnoringDay($computationStartDateArray, $_rrule->interval);
1049 $computationStartDate = self::array2date($computationStartDateArray, $eventInOrganizerTZ->originator_tz);
1051 $recurEvent = self::cloneEvent($eventInOrganizerTZ);
1052 $recurEvent->dtstart = clone $computationStartDate;
1054 if ($byDayInterval < 0) {
1055 $recurEvent->dtstart = self::array2date(self::addMonthIgnoringDay($computationStartDateArray, 1), $eventInOrganizerTZ->originator_tz);
1056 $recurEvent->dtstart->subDay(1);
1059 self::skipWday($recurEvent->dtstart, $byDayWeekday, $byDayInterval, TRUE);
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);
1065 $recurEvent->setTimezone('UTC');
1067 if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
1071 // skip non existing dates
1072 if ($computationStartDate->get('m') != $recurEvent->dtstart->get('m')) {
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) {
1082 // skip instances begining before the baseEvent
1083 if ($recurEvent->dtstart->compare($_event->dtstart) < 0) {
1087 // skip if event equal baseevent
1088 if ($_event->dtstart->equals($recurEvent->dtstart)) {
1092 $recurEvent->setRecurId($_event->getId());
1094 if (! in_array($recurEvent->recurid, $_exceptionRecurIds)) {
1095 self::addRecurrence($recurEvent, $_recurSet);
1101 * skips date to (n'th next/previous) occurance of $_wday
1103 * @param Tinebase_DateTime $_date
1104 * @param int|string $_wday
1106 * @param bool $_considerDateItself
1108 public static function skipWday($_date, $_wday, $_n = +1, $_considerDateItself = FALSE)
1110 $wdayDigit = is_int($_wday) ? $_wday : self::$WEEKDAY_DIGIT_MAP[$_wday];
1111 $wdayOffset = $_date->get('w') - $wdayDigit;
1114 throw new Exception('$_n must not be 0');
1117 $direction = $_n > 0 ? 'forward' : 'backward';
1120 if ($_considerDateItself && $wdayOffset == 0) {
1124 switch ($direction) {
1126 if ($wdayOffset >= 0) {
1127 $_date->addDay(($weeks * 7) - $wdayOffset);
1129 $_date->addDay(abs($wdayOffset) + ($weeks -1) * 7);
1134 if ($wdayOffset > 0) {
1135 $_date->subDay(abs($wdayOffset) + ($weeks -1) * 7);
1137 $_date->subDay(($weeks * 7) + $wdayOffset);
1146 * converts a Tinebase_DateTime to Array
1148 * @param Tinebase_DateTime $_date
1150 * @throws Tinebase_Exception_UnexpectedValue
1152 public static function date2array($_date)
1154 if (! $_date instanceof Tinebase_DateTime) {
1155 throw new Tinebase_Exception_UnexpectedValue('DateTime expected');
1158 return array_intersect_key($_date->toArray(), array_flip(array(
1159 'day' , 'month', 'year', 'hour', 'minute', 'second'
1164 * converts date array to Tinebase_DateTime
1166 * @param array $_dateArray
1167 * @param string $_timezone
1168 * @return Tinebase_DateTime
1170 public static function array2date(array $_dateArray, $_timezone='UTC')
1172 date_default_timezone_set($_timezone);
1174 $date = new Tinebase_DateTime(mktime($_dateArray['hour'], $_dateArray['minute'], $_dateArray['second'], $_dateArray['month'], $_dateArray['day'], $_dateArray['year']));
1175 $date->setTimezone($_timezone);
1177 date_default_timezone_set('UTC');
1183 * converts date array to string
1185 * @param array $_dateArray
1188 public static function array2string(array $_dateArray)
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);
1195 * get number of month different from $_date1 to $_date2
1197 * @param Tinebase_DateTime|array $_from
1198 * @param Tinebase_DateTime|array $_until
1201 public static function getMonthDiff($_from, $_until)
1203 $date1Array = is_array($_from) ? $_from : self::date2array($_from);
1204 $date2Array = is_array($_until) ? $_until : self::date2array($_until);
1206 return (12 * $date2Array['year'] + $date2Array['month']) - (12 * $date1Array['year'] + $date1Array['month']);
1210 * add month and don't touch the day.
1211 * NOTE: The resulting date may no exist e.g. 31. Feb. -> virtual date
1213 * @param Tinebase_DateTime|array $_date
1214 * @param int $_months
1217 public static function addMonthIgnoringDay($_date, $_months)
1219 $dateArr = is_array($_date) ? $_date : self::date2array($_date);
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;
1229 * adds diff to date and applies dst fix
1231 * @param Tinebase_DateTime $_dateInUTC
1232 * @param DateTimeInterval $_diff
1233 * @param string $_timezoneForDstFix
1235 public static function addUTCDateDstFix($_dateInUTC, $_diff, $_timezoneForDstFix)
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');
1245 * returns weekstart in iCal day format
1247 * @param string $locale
1250 public static function getWeekStart($locale = NULL) {
1251 $locale = $locale ?: Tinebase_Core::getLocale();
1253 $weekInfo = Zend_Locale::getTranslationList('week', $locale);
1254 if (!isset($weekInfo['firstDay'])) {
1255 $weekInfo['firstDay'] = 'mon';
1257 return Tinebase_Helper::array_value($weekInfo['firstDay'], array_flip(self::$WEEKDAY_MAP));