diff --git a/plugins/libcalendaring/lib/Horde_Date_Recurrence.php b/plugins/libcalendaring/lib/Horde_Date_Recurrence.php index 9247257f..4dfdfaf6 100644 --- a/plugins/libcalendaring/lib/Horde_Date_Recurrence.php +++ b/plugins/libcalendaring/lib/Horde_Date_Recurrence.php @@ -1,1744 +1,1747 @@ 1 ? $plur : $sing); } } /** * This file contains the Horde_Date_Recurrence class and according constants. * * Copyright 2007-2015 Horde LLC (http://www.horde.org/) * * See the enclosed file COPYING for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * * @category Horde * @package Date */ /** * The Horde_Date_Recurrence class implements algorithms for calculating * recurrences of events, including several recurrence types, intervals, * exceptions, and conversion from and to vCalendar and iCalendar recurrence * rules. * * All methods expecting dates as parameters accept all values that the * Horde_Date constructor accepts, i.e. a timestamp, another Horde_Date * object, an ISO time string or a hash. * * @author Jan Schneider * @category Horde * @package Date */ class Horde_Date_Recurrence { /** No Recurrence **/ const RECUR_NONE = 0; /** Recurs daily. */ const RECUR_DAILY = 1; /** Recurs weekly. */ const RECUR_WEEKLY = 2; /** Recurs monthly on the same date. */ const RECUR_MONTHLY_DATE = 3; /** Recurs monthly on the same week day. */ const RECUR_MONTHLY_WEEKDAY = 4; /** Recurs yearly on the same date. */ const RECUR_YEARLY_DATE = 5; /** Recurs yearly on the same day of the year. */ const RECUR_YEARLY_DAY = 6; /** Recurs yearly on the same week day. */ const RECUR_YEARLY_WEEKDAY = 7; /** * The start time of the event. * * @var Horde_Date */ public $start; /** * The end date of the recurrence interval. * * @var Horde_Date */ public $recurEnd = null; /** * The number of recurrences. * * @var integer */ public $recurCount = null; /** * The type of recurrence this event follows. RECUR_* constant. * * @var integer */ public $recurType = self::RECUR_NONE; /** * The length of time between recurrences. The time unit depends on the * recurrence type. * * @var integer */ public $recurInterval = 1; /** * Any additional recurrence data. * * @var integer */ public $recurData = null; /** * BYDAY recurrence number * * @var integer */ public $recurNthDay = null; /** * BYMONTH recurrence data * * @var array */ public $recurMonths = array(); /** * RDATE recurrence values * * @var array */ public $rdates = array(); /** * All the exceptions from recurrence for this event. * * @var array */ public $exceptions = array(); /** * All the dates this recurrence has been marked as completed. * * @var array */ public $completions = array(); /** * Constructor. * * @param Horde_Date $start Start of the recurring event. */ public function __construct($start) { $this->start = new Horde_Date($start); } /** * Resets the class properties. */ public function reset() { $this->recurEnd = null; $this->recurCount = null; $this->recurType = self::RECUR_NONE; $this->recurInterval = 1; $this->recurData = null; $this->exceptions = array(); $this->completions = array(); } /** * Checks if this event recurs on a given day of the week. * * @param integer $dayMask A mask consisting of Horde_Date::MASK_* * constants specifying the day(s) to check. * * @return boolean True if this event recurs on the given day(s). */ public function recurOnDay($dayMask) { return ($this->recurData & $dayMask); } /** * Specifies the days this event recurs on. * * @param integer $dayMask A mask consisting of Horde_Date::MASK_* * constants specifying the day(s) to recur on. */ public function setRecurOnDay($dayMask) { $this->recurData = $dayMask; } /** * * @param integer $nthDay The nth weekday of month to repeat events on */ public function setRecurNthWeekday($nth) { $this->recurNthDay = (int)$nth; } /** * * @return integer The nth weekday of month to repeat events. */ public function getRecurNthWeekday() { return isset($this->recurNthDay) ? $this->recurNthDay : ceil($this->start->mday / 7); } /** * Specifies the months for yearly (weekday) recurrence * * @param array $months List of months (integers) this event recurs on. */ function setRecurByMonth($months) { $this->recurMonths = (array)$months; } /** * Returns a list of months this yearly event recurs on * * @return array List of months (integers) this event recurs on. */ function getRecurByMonth() { return $this->recurMonths; } /** * Returns the days this event recurs on. * * @return integer A mask consisting of Horde_Date::MASK_* constants * specifying the day(s) this event recurs on. */ public function getRecurOnDays() { return $this->recurData; } /** * Returns whether this event has a specific recurrence type. * * @param integer $recurrence RECUR_* constant of the * recurrence type to check for. * * @return boolean True if the event has the specified recurrence type. */ public function hasRecurType($recurrence) { return ($recurrence == $this->recurType); } /** * Sets a recurrence type for this event. * * @param integer $recurrence A RECUR_* constant. */ public function setRecurType($recurrence) { $this->recurType = $recurrence; } /** * Returns recurrence type of this event. * * @return integer A RECUR_* constant. */ public function getRecurType() { return $this->recurType; } /** * Returns a description of this event's recurring type. * * @return string Human readable recurring type. */ public function getRecurName() { switch ($this->getRecurType()) { case self::RECUR_NONE: return Horde_Date_Translation::t("No recurrence"); case self::RECUR_DAILY: return Horde_Date_Translation::t("Daily"); case self::RECUR_WEEKLY: return Horde_Date_Translation::t("Weekly"); case self::RECUR_MONTHLY_DATE: case self::RECUR_MONTHLY_WEEKDAY: return Horde_Date_Translation::t("Monthly"); case self::RECUR_YEARLY_DATE: case self::RECUR_YEARLY_DAY: case self::RECUR_YEARLY_WEEKDAY: return Horde_Date_Translation::t("Yearly"); } } /** * Sets the length of time between recurrences of this event. * * @param integer $interval The time between recurrences. */ public function setRecurInterval($interval) { if ($interval > 0) { $this->recurInterval = $interval; } } /** * Retrieves the length of time between recurrences of this event. * * @return integer The number of seconds between recurrences. */ public function getRecurInterval() { return $this->recurInterval; } /** * Sets the number of recurrences of this event. * * @param integer $count The number of recurrences. */ public function setRecurCount($count) { if ($count > 0) { $this->recurCount = (int)$count; // Recurrence counts and end dates are mutually exclusive. $this->recurEnd = null; } else { $this->recurCount = null; } } /** * Retrieves the number of recurrences of this event. * * @return integer The number recurrences. */ public function getRecurCount() { return $this->recurCount; } /** * Returns whether this event has a recurrence with a fixed count. * * @return boolean True if this recurrence has a fixed count. */ public function hasRecurCount() { return isset($this->recurCount); } /** * Sets the start date of the recurrence interval. * * @param Horde_Date $start The recurrence start. */ public function setRecurStart($start) { $this->start = clone $start; } /** * Retrieves the start date of the recurrence interval. * * @return Horde_Date The recurrence start. */ public function getRecurStart() { return $this->start; } /** * Sets the end date of the recurrence interval. * * @param Horde_Date $end The recurrence end. */ public function setRecurEnd($end) { if (!empty($end)) { // Recurrence counts and end dates are mutually exclusive. $this->recurCount = null; $this->recurEnd = clone $end; } else { $this->recurEnd = $end; } } /** * Retrieves the end date of the recurrence interval. * * @return Horde_Date The recurrence end. */ public function getRecurEnd() { return $this->recurEnd; } /** * Returns whether this event has a recurrence end. * * @return boolean True if this recurrence ends. */ public function hasRecurEnd() { return isset($this->recurEnd) && isset($this->recurEnd->year) && $this->recurEnd->year != 9999; } /** * Finds the next recurrence of this event that's after $afterDate. * * @param Horde_Date|string $after Return events after this date. * * @return Horde_Date|boolean The date of the next recurrence or false * if the event does not recur after * $afterDate. */ public function nextRecurrence($after) { if (!($after instanceof Horde_Date)) { $after = new Horde_Date($after); } else { $after = clone($after); } // Make sure $after and $this->start are in the same TZ $after->setTimezone($this->start->timezone); if ($this->start->compareDateTime($after) >= 0) { return clone $this->start; } if ($this->recurInterval == 0 && empty($this->rdates)) { return false; } switch ($this->getRecurType()) { case self::RECUR_DAILY: $diff = $this->start->diff($after); $recur = ceil($diff / $this->recurInterval); if ($this->recurCount && $recur >= $this->recurCount) { return false; } $recur *= $this->recurInterval; $next = $this->start->add(array('day' => $recur)); if ((!$this->hasRecurEnd() || $next->compareDateTime($this->recurEnd) <= 0) && $next->compareDateTime($after) >= 0) { return $next; } break; case self::RECUR_WEEKLY: if (empty($this->recurData)) { return false; } $start_week = Horde_Date_Utils::firstDayOfWeek($this->start->format('W'), $this->start->year); $start_week->timezone = $this->start->timezone; $start_week->hour = $this->start->hour; $start_week->min = $this->start->min; $start_week->sec = $this->start->sec; // Make sure we are not at the ISO-8601 first week of year while // still in month 12...OR in the ISO-8601 last week of year while // in month 1 and adjust the year accordingly. $week = $after->format('W'); if ($week == 1 && $after->month == 12) { $theYear = $after->year + 1; } elseif ($week >= 52 && $after->month == 1) { $theYear = $after->year - 1; } else { $theYear = $after->year; } $after_week = Horde_Date_Utils::firstDayOfWeek($week, $theYear); $after_week->timezone = $this->start->timezone; $after_week_end = clone $after_week; $after_week_end->mday += 7; $diff = $start_week->diff($after_week); $interval = $this->recurInterval * 7; $repeats = floor($diff / $interval); if ($diff % $interval < 7) { $recur = $diff; } else { /** * If the after_week is not in the first week interval the * search needs to skip ahead a complete interval. The way it is * calculated here means that an event that occurs every second * week on Monday and Wednesday with the event actually starting * on Tuesday or Wednesday will only have one incidence in the * first week. */ $recur = $interval * ($repeats + 1); } if ($this->hasRecurCount()) { $recurrences = 0; /** * Correct the number of recurrences by the number of events * that lay between the start of the start week and the * recurrence start. */ $next = clone $start_week; while ($next->compareDateTime($this->start) < 0) { if ($this->recurOnDay((int)pow(2, $next->dayOfWeek()))) { $recurrences--; } ++$next->mday; } if ($repeats > 0) { $weekdays = $this->recurData; $total_recurrences_per_week = 0; while ($weekdays > 0) { if ($weekdays % 2) { $total_recurrences_per_week++; } $weekdays = ($weekdays - ($weekdays % 2)) / 2; } $recurrences += $total_recurrences_per_week * $repeats; } } $next = clone $start_week; $next->mday += $recur; while ($next->compareDateTime($after) < 0 && $next->compareDateTime($after_week_end) < 0) { if ($this->hasRecurCount() && $next->compareDateTime($after) < 0 && $this->recurOnDay((int)pow(2, $next->dayOfWeek()))) { $recurrences++; } ++$next->mday; } if ($this->hasRecurCount() && $recurrences >= $this->recurCount) { return false; } if (!$this->hasRecurEnd() || $next->compareDateTime($this->recurEnd) <= 0) { if ($next->compareDateTime($after_week_end) >= 0) { return $this->nextRecurrence($after_week_end); } while (!$this->recurOnDay((int)pow(2, $next->dayOfWeek())) && $next->compareDateTime($after_week_end) < 0) { ++$next->mday; } if (!$this->hasRecurEnd() || $next->compareDateTime($this->recurEnd) <= 0) { if ($next->compareDateTime($after_week_end) >= 0) { return $this->nextRecurrence($after_week_end); } else { return $next; } } } break; case self::RECUR_MONTHLY_DATE: $start = clone $this->start; if ($after->compareDateTime($start) < 0) { $after = clone $start; } else { $after = clone $after; } // If we're starting past this month's recurrence of the event, // look in the next month on the day the event recurs. if ($after->mday > $start->mday) { ++$after->month; $after->mday = $start->mday; } // Adjust $start to be the first match. $offset = ($after->month - $start->month) + ($after->year - $start->year) * 12; $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; if ($this->recurCount && ($offset / $this->recurInterval) >= $this->recurCount) { return false; } $start->month += $offset; $count = $offset / $this->recurInterval; do { if ($this->recurCount && $count++ >= $this->recurCount) { return false; } // Bail if we've gone past the end of recurrence. if ($this->hasRecurEnd() && $this->recurEnd->compareDateTime($start) < 0) { return false; } if ($start->isValid()) { return $start; } // If the interval is 12, and the date isn't valid, then we // need to see if February 29th is an option. If not, then the // event will _never_ recur, and we need to stop checking to // avoid an infinite loop. if ($this->recurInterval == 12 && ($start->month != 2 || $start->mday > 29)) { return false; } // Add the recurrence interval. $start->month += $this->recurInterval; } while (true); break; case self::RECUR_MONTHLY_WEEKDAY: // Start with the start date of the event. $estart = clone $this->start; // What day of the week, and week of the month, do we recur on? if (isset($this->recurNthDay)) { $nth = $this->recurNthDay; $weekday = log($this->recurData, 2); } else { $nth = ceil($this->start->mday / 7); $weekday = $estart->dayOfWeek(); } // Adjust $estart to be the first candidate. $offset = ($after->month - $estart->month) + ($after->year - $estart->year) * 12; $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; // Adjust our working date until it's after $after. $estart->month += $offset - $this->recurInterval; $count = $offset / $this->recurInterval; do { if ($this->recurCount && $count++ >= $this->recurCount) { return false; } $estart->month += $this->recurInterval; $next = clone $estart; $next->setNthWeekday($weekday, $nth); if ($next->month != $estart->month) { // We're already in the next month. continue; } if ($next->compareDateTime($after) < 0) { // We haven't made it past $after yet, try again. continue; } if ($this->hasRecurEnd() && $next->compareDateTime($this->recurEnd) > 0) { // We've gone past the end of recurrence; we can give up // now. return false; } // We have a candidate to return. break; } while (true); return $next; case self::RECUR_YEARLY_DATE: // Start with the start date of the event. $estart = clone $this->start; $after = clone $after; if ($after->month > $estart->month || ($after->month == $estart->month && $after->mday > $estart->mday)) { ++$after->year; $after->month = $estart->month; $after->mday = $estart->mday; } // Seperate case here for February 29th if ($estart->month == 2 && $estart->mday == 29) { while (!Horde_Date_Utils::isLeapYear($after->year)) { ++$after->year; } } // Adjust $estart to be the first candidate. $offset = $after->year - $estart->year; if ($offset > 0) { $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; $estart->year += $offset; } // We've gone past the end of recurrence; give up. if ($this->recurCount && $offset >= $this->recurCount) { return false; } if ($this->hasRecurEnd() && $this->recurEnd->compareDateTime($estart) < 0) { return false; } return $estart; case self::RECUR_YEARLY_DAY: // Check count first. $dayofyear = $this->start->dayOfYear(); $count = ($after->year - $this->start->year) / $this->recurInterval + 1; if ($this->recurCount && ($count > $this->recurCount || ($count == $this->recurCount && $after->dayOfYear() > $dayofyear))) { return false; } // Start with a rough interval. $estart = clone $this->start; $estart->year += floor($count - 1) * $this->recurInterval; // Now add the difference to the required day of year. $estart->mday += $dayofyear - $estart->dayOfYear(); // Add an interval if the estimation was wrong. if ($estart->compareDate($after) < 0) { $estart->year += $this->recurInterval; $estart->mday += $dayofyear - $estart->dayOfYear(); } // We've gone past the end of recurrence; give up. if ($this->hasRecurEnd() && $this->recurEnd->compareDateTime($estart) < 0) { return false; } return $estart; case self::RECUR_YEARLY_WEEKDAY: // Start with the start date of the event. $estart = clone $this->start; // What day of the week, and week of the month, do we recur on? if (isset($this->recurNthDay)) { $nth = $this->recurNthDay; $weekday = log($this->recurData, 2); } else { $nth = ceil($this->start->mday / 7); $weekday = $estart->dayOfWeek(); } // Adjust $estart to be the first candidate. $offset = floor(($after->year - $estart->year + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; // Adjust our working date until it's after $after. $estart->year += $offset - $this->recurInterval; $count = $offset / $this->recurInterval; do { if ($this->recurCount && $count++ >= $this->recurCount) { return false; } $estart->year += $this->recurInterval; $next = clone $estart; $next->setNthWeekday($weekday, $nth); if ($next->compareDateTime($after) < 0) { // We haven't made it past $after yet, try again. continue; } if ($this->hasRecurEnd() && $next->compareDateTime($this->recurEnd) > 0) { // We've gone past the end of recurrence; we can give up // now. return false; } // We have a candidate to return. break; } while (true); return $next; } // fall-back to RDATE properties if (!empty($this->rdates)) { $next = clone $this->start; foreach ($this->rdates as $rdate) { $next->year = $rdate->year; $next->month = $rdate->month; $next->mday = $rdate->mday; if ($next->compareDateTime($after) >= 0) { return $next; } } } // We didn't find anything, the recurType was bad, or something else // went wrong - return false. return false; } /** * Returns whether this event has any date that matches the recurrence * rules and is not an exception. * * @return boolean True if an active recurrence exists. */ public function hasActiveRecurrence() { if (!$this->hasRecurEnd()) { return true; } $next = $this->nextRecurrence(new Horde_Date($this->start)); while (is_object($next)) { if (!$this->hasException($next->year, $next->month, $next->mday) && !$this->hasCompletion($next->year, $next->month, $next->mday)) { return true; } $next = $this->nextRecurrence($next->add(array('day' => 1))); } return false; } /** * Returns the next active recurrence. * * @param Horde_Date $afterDate Return events after this date. * * @return Horde_Date|boolean The date of the next active * recurrence or false if the event * has no active recurrence after * $afterDate. */ public function nextActiveRecurrence($afterDate) { $next = $this->nextRecurrence($afterDate); while (is_object($next)) { if (!$this->hasException($next->year, $next->month, $next->mday) && !$this->hasCompletion($next->year, $next->month, $next->mday)) { return $next; } $next->mday++; $next = $this->nextRecurrence($next); } return false; } /** * Adds an absolute recurrence date. * * @param integer $year The year of the instance. * @param integer $month The month of the instance. * @param integer $mday The day of the month of the instance. */ public function addRDate($year, $month, $mday) { $this->rdates[] = new Horde_Date($year, $month, $mday); } /** * Adds an exception to a recurring event. * * @param integer $year The year of the execption. * @param integer $month The month of the execption. * @param integer $mday The day of the month of the exception. */ public function addException($year, $month, $mday) { $key = sprintf('%04d%02d%02d', $year, $month, $mday); if (array_search($key, $this->exceptions) === false) { $this->exceptions[] = sprintf('%04d%02d%02d', $year, $month, $mday); } } /** * Deletes an exception from a recurring event. * * @param integer $year The year of the execption. * @param integer $month The month of the execption. * @param integer $mday The day of the month of the exception. */ public function deleteException($year, $month, $mday) { $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->exceptions); if ($key !== false) { unset($this->exceptions[$key]); } } /** * Checks if an exception exists for a given reccurence of an event. * * @param integer $year The year of the reucrance. * @param integer $month The month of the reucrance. * @param integer $mday The day of the month of the reucrance. * * @return boolean True if an exception exists for the given date. */ public function hasException($year, $month, $mday) { return in_array(sprintf('%04d%02d%02d', $year, $month, $mday), $this->getExceptions()); } /** * Retrieves all the exceptions for this event. * * @return array Array containing the dates of all the exceptions in * YYYYMMDD form. */ public function getExceptions() { return $this->exceptions; } /** * Adds a completion to a recurring event. * * @param integer $year The year of the execption. * @param integer $month The month of the execption. * @param integer $mday The day of the month of the completion. */ public function addCompletion($year, $month, $mday) { $this->completions[] = sprintf('%04d%02d%02d', $year, $month, $mday); } /** * Deletes a completion from a recurring event. * * @param integer $year The year of the execption. * @param integer $month The month of the execption. * @param integer $mday The day of the month of the completion. */ public function deleteCompletion($year, $month, $mday) { $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->completions); if ($key !== false) { unset($this->completions[$key]); } } /** * Checks if a completion exists for a given reccurence of an event. * * @param integer $year The year of the reucrance. * @param integer $month The month of the recurrance. * @param integer $mday The day of the month of the recurrance. * * @return boolean True if a completion exists for the given date. */ public function hasCompletion($year, $month, $mday) { return in_array(sprintf('%04d%02d%02d', $year, $month, $mday), $this->getCompletions()); } /** * Retrieves all the completions for this event. * * @return array Array containing the dates of all the completions in * YYYYMMDD form. */ public function getCompletions() { return $this->completions; } /** * Parses a vCalendar 1.0 recurrence rule. * * @link http://www.imc.org/pdi/vcal-10.txt * @link http://www.shuchow.com/vCalAddendum.html * * @param string $rrule A vCalendar 1.0 conform RRULE value. */ public function fromRRule10($rrule) { $this->reset(); if (!$rrule) { return; } if (!preg_match('/([A-Z]+)(\d+)?(.*)/', $rrule, $matches)) { // No recurrence data - event does not recur. $this->setRecurType(self::RECUR_NONE); } // Always default the recurInterval to 1. $this->setRecurInterval(!empty($matches[2]) ? $matches[2] : 1); $remainder = trim($matches[3]); switch ($matches[1]) { case 'D': $this->setRecurType(self::RECUR_DAILY); break; case 'W': $this->setRecurType(self::RECUR_WEEKLY); if (!empty($remainder)) { $mask = 0; while (preg_match('/^ ?[A-Z]{2} ?/', $remainder, $matches)) { $day = trim($matches[0]); $remainder = substr($remainder, strlen($matches[0])); $mask |= $maskdays[$day]; } $this->setRecurOnDay($mask); } else { // Recur on the day of the week of the original recurrence. $maskdays = array( Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY, Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY, Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY, Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY, Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY, Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY, Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY, ); $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]); } break; case 'MP': $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); break; case 'MD': $this->setRecurType(self::RECUR_MONTHLY_DATE); break; case 'YM': $this->setRecurType(self::RECUR_YEARLY_DATE); break; case 'YD': $this->setRecurType(self::RECUR_YEARLY_DAY); break; } // We don't support modifiers at the moment, strip them. while ($remainder && !preg_match('/^(#\d+|\d{8})($| |T\d{6})/', $remainder)) { $remainder = substr($remainder, 1); } if (!empty($remainder)) { if (strpos($remainder, '#') === 0) { $this->setRecurCount(substr($remainder, 1)); } else { list($year, $month, $mday, $hour, $min, $sec, $tz) = sscanf($remainder, '%04d%02d%02dT%02d%02d%02d%s'); $this->setRecurEnd(new Horde_Date(array('year' => $year, 'month' => $month, 'mday' => $mday, 'hour' => $hour, 'min' => $min, 'sec' => $sec), $tz == 'Z' ? 'UTC' : $this->start->timezone)); } } } /** * Creates a vCalendar 1.0 recurrence rule. * * @link http://www.imc.org/pdi/vcal-10.txt * @link http://www.shuchow.com/vCalAddendum.html * * @param Horde_Icalendar $calendar A Horde_Icalendar object instance. * * @return string A vCalendar 1.0 conform RRULE value. */ public function toRRule10($calendar) { switch ($this->recurType) { case self::RECUR_NONE: return ''; case self::RECUR_DAILY: $rrule = 'D' . $this->recurInterval; break; case self::RECUR_WEEKLY: $rrule = 'W' . $this->recurInterval; $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); for ($i = 0; $i <= 7; ++$i) { if ($this->recurOnDay(pow(2, $i))) { $rrule .= ' ' . $vcaldays[$i]; } } break; case self::RECUR_MONTHLY_DATE: $rrule = 'MD' . $this->recurInterval . ' ' . trim($this->start->mday); break; case self::RECUR_MONTHLY_WEEKDAY: $nth_weekday = (int)($this->start->mday / 7); if (($this->start->mday % 7) > 0) { $nth_weekday++; } $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); $rrule = 'MP' . $this->recurInterval . ' ' . $nth_weekday . '+ ' . $vcaldays[$this->start->dayOfWeek()]; break; case self::RECUR_YEARLY_DATE: $rrule = 'YM' . $this->recurInterval . ' ' . trim($this->start->month); break; case self::RECUR_YEARLY_DAY: $rrule = 'YD' . $this->recurInterval . ' ' . $this->start->dayOfYear(); break; default: return ''; } if ($this->hasRecurEnd()) { $recurEnd = clone $this->recurEnd; return $rrule . ' ' . $calendar->_exportDateTime($recurEnd); } return $rrule . ' #' . (int)$this->getRecurCount(); } /** * Parses an iCalendar 2.0 recurrence rule. * * @link http://rfc.net/rfc2445.html#s4.3.10 * @link http://rfc.net/rfc2445.html#s4.8.5 * @link http://www.shuchow.com/vCalAddendum.html * * @param string $rrule An iCalendar 2.0 conform RRULE value. */ public function fromRRule20($rrule) { $this->reset(); // Parse the recurrence rule into keys and values. $rdata = array(); $parts = explode(';', $rrule); foreach ($parts as $part) { - list($key, $value) = explode('=', $part, 2); - $rdata[strtoupper($key)] = $value; + $value = null; + if (strpos($part, '=')) { + list($part, $value) = explode('=', $part, 2); + } + $rdata[strtoupper($part)] = $value; } if (isset($rdata['FREQ'])) { // Always default the recurInterval to 1. $this->setRecurInterval(isset($rdata['INTERVAL']) ? $rdata['INTERVAL'] : 1); $maskdays = array( 'SU' => Horde_Date::MASK_SUNDAY, 'MO' => Horde_Date::MASK_MONDAY, 'TU' => Horde_Date::MASK_TUESDAY, 'WE' => Horde_Date::MASK_WEDNESDAY, 'TH' => Horde_Date::MASK_THURSDAY, 'FR' => Horde_Date::MASK_FRIDAY, 'SA' => Horde_Date::MASK_SATURDAY, ); switch (strtoupper($rdata['FREQ'])) { case 'DAILY': $this->setRecurType(self::RECUR_DAILY); break; case 'WEEKLY': $this->setRecurType(self::RECUR_WEEKLY); if (isset($rdata['BYDAY'])) { $days = explode(',', $rdata['BYDAY']); $mask = 0; foreach ($days as $day) { $mask |= $maskdays[$day]; } $this->setRecurOnDay($mask); } else { // Recur on the day of the week of the original // recurrence. $maskdays = array( Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY, Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY, Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY, Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY, Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY, Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY, Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY); $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]); } break; case 'MONTHLY': if (isset($rdata['BYDAY'])) { $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); if (preg_match('/(-?[1-4])([A-Z]+)/', $rdata['BYDAY'], $m)) { $this->setRecurOnDay($maskdays[$m[2]]); $this->setRecurNthWeekday($m[1]); } } else { $this->setRecurType(self::RECUR_MONTHLY_DATE); } break; case 'YEARLY': if (isset($rdata['BYYEARDAY'])) { $this->setRecurType(self::RECUR_YEARLY_DAY); } elseif (isset($rdata['BYDAY'])) { $this->setRecurType(self::RECUR_YEARLY_WEEKDAY); if (preg_match('/(-?[1-4])([A-Z]+)/', $rdata['BYDAY'], $m)) { $this->setRecurOnDay($maskdays[$m[2]]); $this->setRecurNthWeekday($m[1]); } if ($rdata['BYMONTH']) { $months = explode(',', $rdata['BYMONTH']); $this->setRecurByMonth($months); } } else { $this->setRecurType(self::RECUR_YEARLY_DATE); } break; } // MUST take into account the time portion if it is present. // See Bug: 12869 and Bug: 2813 if (isset($rdata['UNTIL'])) { if (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})T? ?(\d{2}):?(\d{2}):?(\d{2})(?:\.\d+)?(Z?)$/', $rdata['UNTIL'], $parts)) { $until = new Horde_Date($rdata['UNTIL'], 'UTC'); $until->setTimezone($this->start->timezone); } else { list($year, $month, $mday) = sscanf($rdata['UNTIL'], '%04d%02d%02d'); $until = new Horde_Date( array('year' => $year, 'month' => $month, 'mday' => $mday + 1), $this->start->timezone ); } $this->setRecurEnd($until); } if (isset($rdata['COUNT'])) { $this->setRecurCount($rdata['COUNT']); } } else { // No recurrence data - event does not recur. $this->setRecurType(self::RECUR_NONE); } } /** * Creates an iCalendar 2.0 recurrence rule. * * @link http://rfc.net/rfc2445.html#s4.3.10 * @link http://rfc.net/rfc2445.html#s4.8.5 * @link http://www.shuchow.com/vCalAddendum.html * * @param Horde_Icalendar $calendar A Horde_Icalendar object instance. * * @return string An iCalendar 2.0 conform RRULE value. */ public function toRRule20($calendar) { switch ($this->recurType) { case self::RECUR_NONE: return ''; case self::RECUR_DAILY: $rrule = 'FREQ=DAILY;INTERVAL=' . $this->recurInterval; break; case self::RECUR_WEEKLY: $rrule = 'FREQ=WEEKLY;INTERVAL=' . $this->recurInterval; $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); for ($i = $flag = 0; $i <= 7; ++$i) { if ($this->recurOnDay(pow(2, $i))) { if ($flag == 0) { $rrule .= ';BYDAY='; $flag = 1; } else { $rrule .= ','; } $rrule .= $vcaldays[$i]; } } break; case self::RECUR_MONTHLY_DATE: $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval; break; case self::RECUR_MONTHLY_WEEKDAY: if (isset($this->recurNthDay)) { $nth_weekday = $this->recurNthDay; $day_of_week = log($this->recurData, 2); } else { $day_of_week = $this->start->dayOfWeek(); $nth_weekday = (int)($this->start->mday / 7); if (($this->start->mday % 7) > 0) { $nth_weekday++; } } $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval . ';BYDAY=' . $nth_weekday . $vcaldays[$day_of_week]; break; case self::RECUR_YEARLY_DATE: $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval; break; case self::RECUR_YEARLY_DAY: $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval . ';BYYEARDAY=' . $this->start->dayOfYear(); break; case self::RECUR_YEARLY_WEEKDAY: if (isset($this->recurNthDay)) { $nth_weekday = $this->recurNthDay; $day_of_week = log($this->recurData, 2); } else { $day_of_week = $this->start->dayOfWeek(); $nth_weekday = (int)($this->start->mday / 7); if (($this->start->mday % 7) > 0) { $nth_weekday++; } } $months = !empty($this->recurMonths) ? join(',', $this->recurMonths) : $this->start->month; $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval . ';BYDAY=' . $nth_weekday . $vcaldays[$day_of_week] . ';BYMONTH=' . $this->start->month; break; } if ($this->hasRecurEnd()) { $recurEnd = clone $this->recurEnd; $rrule .= ';UNTIL=' . $calendar->_exportDateTime($recurEnd); } if ($count = $this->getRecurCount()) { $rrule .= ';COUNT=' . $count; } return $rrule; } /** * Parses the recurrence data from a Kolab hash. * * @param array $hash The hash to convert. * * @return boolean True if the hash seemed valid, false otherwise. */ public function fromKolab($hash) { $this->reset(); if (!isset($hash['interval']) || !isset($hash['cycle'])) { $this->setRecurType(self::RECUR_NONE); return false; } $this->setRecurInterval((int)$hash['interval']); $month2number = array( 'january' => 1, 'february' => 2, 'march' => 3, 'april' => 4, 'may' => 5, 'june' => 6, 'july' => 7, 'august' => 8, 'september' => 9, 'october' => 10, 'november' => 11, 'december' => 12, ); $parse_day = false; $set_daymask = false; $update_month = false; $update_daynumber = false; $update_weekday = false; $nth_weekday = -1; switch ($hash['cycle']) { case 'daily': $this->setRecurType(self::RECUR_DAILY); break; case 'weekly': $this->setRecurType(self::RECUR_WEEKLY); $parse_day = true; $set_daymask = true; break; case 'monthly': if (!isset($hash['daynumber'])) { $this->setRecurType(self::RECUR_NONE); return false; } switch ($hash['type']) { case 'daynumber': $this->setRecurType(self::RECUR_MONTHLY_DATE); $update_daynumber = true; break; case 'weekday': $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); $this->setRecurNthWeekday($hash['daynumber']); $parse_day = true; $set_daymask = true; break; } break; case 'yearly': if (!isset($hash['type'])) { $this->setRecurType(self::RECUR_NONE); return false; } switch ($hash['type']) { case 'monthday': $this->setRecurType(self::RECUR_YEARLY_DATE); $update_month = true; $update_daynumber = true; break; case 'yearday': if (!isset($hash['daynumber'])) { $this->setRecurType(self::RECUR_NONE); return false; } $this->setRecurType(self::RECUR_YEARLY_DAY); // Start counting days in January. $hash['month'] = 'january'; $update_month = true; $update_daynumber = true; break; case 'weekday': if (!isset($hash['daynumber'])) { $this->setRecurType(self::RECUR_NONE); return false; } $this->setRecurType(self::RECUR_YEARLY_WEEKDAY); $this->setRecurNthWeekday($hash['daynumber']); $parse_day = true; $set_daymask = true; if ($hash['month'] && isset($month2number[$hash['month']])) { $this->setRecurByMonth($month2number[$hash['month']]); } break; } } if (isset($hash['range-type']) && isset($hash['range'])) { switch ($hash['range-type']) { case 'number': $this->setRecurCount((int)$hash['range']); break; case 'date': $recur_end = new Horde_Date($hash['range']); $recur_end->hour = 23; $recur_end->min = 59; $recur_end->sec = 59; $this->setRecurEnd($recur_end); break; } } // Need to parse ? $last_found_day = -1; if ($parse_day) { if (!isset($hash['day'])) { $this->setRecurType(self::RECUR_NONE); return false; } $mask = 0; $bits = array( 'monday' => Horde_Date::MASK_MONDAY, 'tuesday' => Horde_Date::MASK_TUESDAY, 'wednesday' => Horde_Date::MASK_WEDNESDAY, 'thursday' => Horde_Date::MASK_THURSDAY, 'friday' => Horde_Date::MASK_FRIDAY, 'saturday' => Horde_Date::MASK_SATURDAY, 'sunday' => Horde_Date::MASK_SUNDAY, ); $days = array( 'monday' => Horde_Date::DATE_MONDAY, 'tuesday' => Horde_Date::DATE_TUESDAY, 'wednesday' => Horde_Date::DATE_WEDNESDAY, 'thursday' => Horde_Date::DATE_THURSDAY, 'friday' => Horde_Date::DATE_FRIDAY, 'saturday' => Horde_Date::DATE_SATURDAY, 'sunday' => Horde_Date::DATE_SUNDAY, ); foreach ($hash['day'] as $day) { // Validity check. if (empty($day) || !isset($bits[$day])) { continue; } $mask |= $bits[$day]; $last_found_day = $days[$day]; } if ($set_daymask) { $this->setRecurOnDay($mask); } } if ($update_month || $update_daynumber || $update_weekday) { if ($update_month) { if (isset($month2number[$hash['month']])) { $this->start->month = $month2number[$hash['month']]; } } if ($update_daynumber) { if (!isset($hash['daynumber'])) { $this->setRecurType(self::RECUR_NONE); return false; } $this->start->mday = $hash['daynumber']; } if ($update_weekday) { $this->setNthWeekday($nth_weekday); } } // Exceptions. if (isset($hash['exclusion'])) { foreach ($hash['exclusion'] as $exception) { if ($exception instanceof DateTime) { $this->exceptions[] = $exception->format('Ymd'); } } } if (isset($hash['complete'])) { foreach ($hash['complete'] as $completion) { if ($exception instanceof DateTime) { $this->completions[] = $completion->format('Ymd'); } } } return true; } /** * Export this object into a Kolab hash. * * @return array The recurrence hash. */ public function toKolab() { if ($this->getRecurType() == self::RECUR_NONE) { return array(); } $day2number = array( 0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday', 4 => 'thursday', 5 => 'friday', 6 => 'saturday' ); $month2number = array( 1 => 'january', 2 => 'february', 3 => 'march', 4 => 'april', 5 => 'may', 6 => 'june', 7 => 'july', 8 => 'august', 9 => 'september', 10 => 'october', 11 => 'november', 12 => 'december' ); $hash = array('interval' => $this->getRecurInterval()); $start = $this->getRecurStart(); switch ($this->getRecurType()) { case self::RECUR_DAILY: $hash['cycle'] = 'daily'; break; case self::RECUR_WEEKLY: $hash['cycle'] = 'weekly'; $bits = array( 'monday' => Horde_Date::MASK_MONDAY, 'tuesday' => Horde_Date::MASK_TUESDAY, 'wednesday' => Horde_Date::MASK_WEDNESDAY, 'thursday' => Horde_Date::MASK_THURSDAY, 'friday' => Horde_Date::MASK_FRIDAY, 'saturday' => Horde_Date::MASK_SATURDAY, 'sunday' => Horde_Date::MASK_SUNDAY, ); $days = array(); foreach ($bits as $name => $bit) { if ($this->recurOnDay($bit)) { $days[] = $name; } } $hash['day'] = $days; break; case self::RECUR_MONTHLY_DATE: $hash['cycle'] = 'monthly'; $hash['type'] = 'daynumber'; $hash['daynumber'] = $start->mday; break; case self::RECUR_MONTHLY_WEEKDAY: $hash['cycle'] = 'monthly'; $hash['type'] = 'weekday'; $hash['daynumber'] = $start->weekOfMonth(); $hash['day'] = array ($day2number[$start->dayOfWeek()]); break; case self::RECUR_YEARLY_DATE: $hash['cycle'] = 'yearly'; $hash['type'] = 'monthday'; $hash['daynumber'] = $start->mday; $hash['month'] = $month2number[$start->month]; break; case self::RECUR_YEARLY_DAY: $hash['cycle'] = 'yearly'; $hash['type'] = 'yearday'; $hash['daynumber'] = $start->dayOfYear(); break; case self::RECUR_YEARLY_WEEKDAY: $hash['cycle'] = 'yearly'; $hash['type'] = 'weekday'; $hash['daynumber'] = $start->weekOfMonth(); $hash['day'] = array ($day2number[$start->dayOfWeek()]); $hash['month'] = $month2number[$start->month]; } if ($this->hasRecurCount()) { $hash['range-type'] = 'number'; $hash['range'] = $this->getRecurCount(); } elseif ($this->hasRecurEnd()) { $date = $this->getRecurEnd(); $hash['range-type'] = 'date'; $hash['range'] = $date->toDateTime(); } else { $hash['range-type'] = 'none'; $hash['range'] = ''; } // Recurrence exceptions $hash['exclusion'] = $hash['complete'] = array(); foreach ($this->exceptions as $exception) { $hash['exclusion'][] = new DateTime($exception); } foreach ($this->completions as $completionexception) { $hash['complete'][] = new DateTime($completionexception); } return $hash; } /** * Returns a simple object suitable for json transport representing this * object. * * Possible properties are: * - t: type * - i: interval * - e: end date * - c: count * - d: data * - co: completions * - ex: exceptions * * @return object A simple object. */ public function toJson() { $json = new stdClass; $json->t = $this->recurType; $json->i = $this->recurInterval; if ($this->hasRecurEnd()) { $json->e = $this->recurEnd->toJson(); } if ($this->recurCount) { $json->c = $this->recurCount; } if ($this->recurData) { $json->d = $this->recurData; } if ($this->completions) { $json->co = $this->completions; } if ($this->exceptions) { $json->ex = $this->exceptions; } return $json; } } diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php index a96f33eb..b62ba42f 100644 --- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php +++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php @@ -1,235 +1,253 @@ * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_recurrence { protected $lib; protected $start; protected $next; protected $engine; protected $recurrence; protected $dateonly = false; protected $hour = 0; /** * Default constructor * * @param calendar The calendar plugin instance */ function __construct($lib) { - // use Horde classes to compute recurring instances - // TODO: replace with something that has less than 6'000 lines of code - require_once(__DIR__ . '/Horde_Date_Recurrence.php'); + // use Horde classes to compute recurring instances + // TODO: replace with something that has less than 6'000 lines of code + require_once(__DIR__ . '/Horde_Date_Recurrence.php'); - $this->lib = $lib; + $this->lib = $lib; } /** * Initialize recurrence engine * * @param array The recurrence properties * @param DateTime The recurrence start date */ public function init($recurrence, $start = null) { $this->recurrence = $recurrence; $this->engine = new Horde_Date_Recurrence($start); $this->engine->fromRRule20(libcalendaring::to_rrule($recurrence)); $this->set_start($start); if (!empty($recurrence['EXDATE'])) { foreach ((array) $recurrence['EXDATE'] as $exdate) { if ($exdate instanceof DateTimeInterface) { $this->engine->addException($exdate->format('Y'), $exdate->format('n'), $exdate->format('j')); } } } if (!empty($recurrence['RDATE'])) { foreach ((array) $recurrence['RDATE'] as $rdate) { if ($rdate instanceof DateTimeInterface) { $this->engine->addRDate($rdate->format('Y'), $rdate->format('n'), $rdate->format('j')); } } } } /** * Setter for (new) recurrence start date * * @param DateTime The recurrence start date */ public function set_start($start) { $this->start = $start; - $this->dateonly = $start->_dateonly; + $this->dateonly = !empty($start->_dateonly); $this->next = new Horde_Date($start, $this->lib->timezone->getName()); $this->hour = $this->next->hour; $this->engine->setRecurStart($this->next); } /** * Get date/time of the next occurence of this event * - * @return DateTime|int|false object or False if recurrence ended + * @return DateTime|false object or False if recurrence ended */ public function next() { $time = false; $after = clone $this->next; $after->mday = $after->mday + 1; + if ($this->next && ($next = $this->engine->nextActiveRecurrence($after))) { // avoid endless loops if recurrence computation fails if (!$next->after($this->next)) { return false; } + // fix time for all-day events if ($this->dateonly) { $next->hour = $this->hour; $next->min = 0; } - $time = $next->toDateTime(); $this->next = $next; + + $time = $this->toDateTime($next); } return $time; } /** * Get the end date of the occurence of this recurrence cycle * * @return DateTime|bool End datetime of the last occurence or False if recurrence exceeds limit */ public function end() { // recurrence end date is given if ($this->recurrence['UNTIL'] instanceof DateTimeInterface) { return $this->recurrence['UNTIL']; } // take the last RDATE entry if set if (is_array($this->recurrence['RDATE']) && !empty($this->recurrence['RDATE'])) { $last = end($this->recurrence['RDATE']); if ($last instanceof DateTimeInterface) { return $last; } } // run through all items till we reach the end if ($this->recurrence['COUNT']) { $last = $this->start; $this->next = new Horde_Date($this->start, $this->lib->timezone->getName()); while (($next = $this->next()) && $c < 1000) { $last = $next; $c++; } } return $last; } /** * Find date/time of the first occurrence (excluding start date) */ public function first_occurrence() { $start = clone $this->start; $orig_start = clone $this->start; $r = $this->recurrence; $interval = !empty($r['INTERVAL']) ? intval($r['INTERVAL']) : 1; $frequency = isset($this->recurrence['FREQ']) ? $this->recurrence['FREQ'] : null; switch ($frequency) { case 'WEEKLY': if (empty($this->recurrence['BYDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}W")); break; case 'MONTHLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTHDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}M")); break; case 'YEARLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTH'])) { return $start; } $start->sub(new DateInterval("P{$interval}Y")); break; default: return $start; } $r = $this->recurrence; $r['INTERVAL'] = $interval; if (!empty($r['COUNT'])) { // Increase count so we do not stop the loop to early $r['COUNT'] += 100; } // Create recurrence that starts in the past $recurrence = new self($this->lib); $recurrence->init($r, $start); // find the first occurrence $found = false; while ($next = $recurrence->next()) { $start = $next; if ($next >= $orig_start) { $found = true; break; } } if (!$found) { rcube::raise_error(array( 'file' => __FILE__, 'line' => __LINE__, 'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s", $orig_start->format(DateTime::ISO8601), json_encode($r)), ), true); return null; } - if ($start Instanceof Horde_Date) { - $start = $start->toDateTime(); + $start = $this->toDateTime($start); + + return $start; + } + + private function toDateTime($date) + { + if ($date Instanceof Horde_Date) { + $date = $date->toDateTime(); } - $start->_dateonly = $this->dateonly; + if ($date instanceof DateTimeInterface) { + $date = libcalendaring_datetime::createFromFormat( + 'Y-m-d\\TH:i:s', + $date->format('Y-m-d\\TH:i:s'), + $date->getTimezone() + ); + } - return $start; + $date->_dateonly = $this->dateonly; + + return $date; } } diff --git a/plugins/libcalendaring/tests/RecurrenceTest.php b/plugins/libcalendaring/tests/RecurrenceTest.php new file mode 100644 index 00000000..7d191474 --- /dev/null +++ b/plugins/libcalendaring/tests/RecurrenceTest.php @@ -0,0 +1,272 @@ + + * + * Copyright (C) 2022, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +class RecurrenceTest extends PHPUnit\Framework\TestCase +{ + private $plugin; + + function setUp(): void + { + $rcube = rcmail::get_instance(); + $rcube->plugins->load_plugin('libcalendaring', true, true); + + $this->plugin = $rcube->plugins->get_plugin('libcalendaring'); + } + + /** + * Test for libcalendaring_recurrence::first_occurrence() + * + * @dataProvider data_first_occurrence + */ + function test_first_occurrence($recurrence_data, $start, $expected) + { + $start = new DateTime($start); + if (!empty($recurrence_data['UNTIL'])) { + $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); + } + + $recurrence = $this->plugin->get_recurrence(); + + $recurrence->init($recurrence_data, $start); + $first = $recurrence->first_occurrence(); + + $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); + } + + /** + * Data for test_first_occurrence() + */ + function data_first_occurrence() + { + // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST + + return array( + // non-recurring + array( + array(), // recurrence data + '2017-08-31 11:00:00', // start date + '2017-08-31 11:00:00', // expected result + ), + // daily + array( + array('FREQ' => 'DAILY', 'INTERVAL' => '1'), // recurrence data + '2017-08-31 11:00:00', // start date + '2017-08-31 11:00:00', // expected result + ), + // TODO: this one is not supported by the Calendar UI +/* + array( + array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1), + '2017-08-31 11:00:00', + '2018-01-01 11:00:00', + ), +*/ + // weekly + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'), + '2017-08-31 11:00:00', // Thursday + '2017-08-31 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'), + '2017-08-31 11:00:00', // Thursday + '2017-09-06 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'), + '2017-08-31 11:00:00', // Thursday + '2017-08-31 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'), + '2017-08-31 11:00:00', // Thursday + '2017-09-01 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '2'), + '2017-08-31 11:00:00', // Thursday + '2017-08-31 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'), + '2017-08-31 11:00:00', // Thursday + '2017-09-20 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1), + '2017-08-31 11:00:00', // Thursday + '2017-09-06 11:00:00', + ), + array( + array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'), + '2017-08-31 11:00:00', // Thursday + '', + ), + // monthly + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '1'), + '2017-09-08 11:00:00', + '2017-09-08 11:00:00', + ), +/* + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), + '2017-08-31 11:00:00', + '2017-09-08 11:00:00', + ), +*/ + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), + '2017-09-08 11:00:00', + '2017-09-08 11:00:00', + ), + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'), + '2017-08-16 11:00:00', + '2017-09-06 11:00:00', + ), + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'), + '2017-08-16 11:00:00', + '2017-08-30 11:00:00', + ), + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '2'), + '2017-09-08 11:00:00', + '2017-09-08 11:00:00', + ), +/* + array( + array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'), + '2017-08-31 11:00:00', + '2017-09-08 11:00:00', // ?????? + ), +*/ + // yearly + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1'), + '2017-08-16 12:00:00', + '2017-08-16 12:00:00', + ), + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'), + '2017-08-16 12:00:00', + '2017-08-16 12:00:00', + ), +/* + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'), + '2017-08-16 11:00:00', + '2017-12-25 11:00:00', + ), +*/ + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'), + '2017-08-16 11:00:00', + '2017-08-28 11:00:00', + ), +/* + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'), + '2017-08-16 11:00:00', + '2018-01-01 11:00:00', + ), + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'), + '2017-08-16 11:00:00', + '2017-09-04 11:00:00', + ), +*/ + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '2'), + '2017-08-16 11:00:00', + '2017-08-16 11:00:00', + ), + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'), + '2017-08-16 11:00:00', + '2017-08-16 11:00:00', + ), +/* + array( + array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'), + '2017-08-16 11:00:00', + '2017-12-25 11:00:00', + ), +*/ + // on dates (FIXME: do we really expect the first occurrence to be on the start date?) + array( + array('RDATE' => array(new DateTime('2017-08-10 11:00:00 Europe/Warsaw'))), + '2017-08-01 11:00:00', + '2017-08-01 11:00:00', + ), + ); + } + + /** + * Test for libcalendaring_recurrence::first_occurrence() for all-day events + * + * @dataProvider data_first_occurrence + */ + function test_first_occurrence_allday($recurrence_data, $start, $expected) + { + $start = new libcalendaring_datetime($start); + $start->_dateonly = true; + + if (!empty($recurrence_data['UNTIL'])) { + $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); + } + + $recurrence = $this->plugin->get_recurrence(); + + $recurrence->init($recurrence_data, $start); + $first = $recurrence->first_occurrence(); + + $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); + + if ($expected) { + $this->assertTrue($first->_dateonly); + } + } + + /** + * Test for libcalendaring_recurrence::next() + */ + function test_next_instance() + { + date_default_timezone_set('America/New_York'); + + $start = new libcalendaring_datetime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); + $start->_dateonly = true; + + $recurrence = $this->plugin->get_recurrence(); + + $recurrence->init(['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], $start); + + $next = $recurrence->next(); + + $this->assertEquals($start->format('2017-09-07 H:i:s'), $next->format('Y-m-d H:i:s'), 'Same time'); + $this->assertEquals($start->getTimezone()->getName(), $next->getTimezone()->getName(), 'Same timezone'); + $this->assertTrue($next->_dateonly, '_dateonly flag'); + } +} diff --git a/plugins/libcalendaring/tests/LibvcalendarTest.php b/plugins/libcalendaring/tests/VcalendarTest.php similarity index 99% rename from plugins/libcalendaring/tests/LibvcalendarTest.php rename to plugins/libcalendaring/tests/VcalendarTest.php index b687d2b5..232f673b 100644 --- a/plugins/libcalendaring/tests/LibvcalendarTest.php +++ b/plugins/libcalendaring/tests/VcalendarTest.php @@ -1,609 +1,609 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class LibvcalendarTest extends PHPUnit\Framework\TestCase +class VcalendarTest extends PHPUnit\Framework\TestCase { private $attachment_data; function setUp(): void { require_once __DIR__ . '/../libcalendaring.php'; require_once __DIR__ . '/../lib/libcalendaring_vcalendar.php'; require_once __DIR__ . '/../lib/libcalendaring_datetime.php'; } /** * Simple iCal parsing test */ function test_import() { $ical = new libcalendaring_vcalendar(); $ics = file_get_contents(__DIR__ . '/resources/snd.ics'); $events = $ical->import($ics, 'UTF-8'); $this->assertEquals(1, count($events)); $event = $events[0]; $this->assertInstanceOf('DateTimeInterface', $event['created'], "'created' property is DateTime object"); $this->assertInstanceOf('DateTimeInterface', $event['changed'], "'changed' property is DateTime object"); $this->assertEquals('UTC', $event['created']->getTimezone()->getName(), "'created' date is in UTC"); $this->assertInstanceOf('DateTimeInterface', $event['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTimeInterface', $event['end'], "'end' property is DateTime object"); $this->assertEquals('08-01', $event['start']->format('m-d'), "Start date is August 1st"); $this->assertTrue($event['allday'], "All-day event flag"); $this->assertEquals('B968B885-08FB-40E5-B89E-6DA05F26AA79', $event['uid'], "Event UID"); $this->assertEquals('Swiss National Day', $event['title'], "Event title"); $this->assertEquals('http://en.wikipedia.org/wiki/Swiss_National_Day', $event['url'], "URL property"); $this->assertEquals(2, $event['sequence'], "Sequence number"); $desclines = explode("\n", $event['description']); $this->assertEquals(4, count($desclines), "Multiline description"); $this->assertEquals("French: FĂȘte nationale Suisse", rtrim($desclines[1]), "UTF-8 encoding"); } /** * Test parsing from files */ function test_import_from_file() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $this->assertEquals(2, count($events)); $events = $ical->import_from_file(__DIR__ . '/resources/invalid.txt', 'UTF-8'); $this->assertEmpty($events); } /** * Test parsing from files with multiple VCALENDAR blocks (#2884) */ function test_import_from_file_multiple() { $ical = new libcalendaring_vcalendar(); $ical->fopen(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $events = []; foreach ($ical as $event) { $events[] = $event; } $this->assertEquals(2, count($events)); $this->assertEquals("AAAA6A8C3CCE4EE2C1257B5C00FFFFFF-Lotus_Notes_Generated", $events[0]['uid']); $this->assertEquals("AAAA1C572093EC3FC125799C004AFFFF-Lotus_Notes_Generated", $events[1]['uid']); } function test_invalid_dates() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/invalid-dates.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(1, count($events), "Import event data"); $this->assertInstanceOf('DateTimeInterface', $event['created'], "Created date field"); $this->assertFalse(array_key_exists('changed', $event), "No changed date field"); } /** * Test some extended ical properties such as attendees, recurrence rules, alarms and attachments */ function test_extended() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('REQUEST', $ical->method, "iTip method"); // attendees $this->assertEquals(3, count($event['attendees']), "Attendees list (including organizer)"); $organizer = $event['attendees'][0]; $this->assertEquals('ORGANIZER', $organizer['role'], 'Organizer ROLE'); $this->assertEquals('Rolf Test', $organizer['name'], 'Organizer name'); $attendee = $event['attendees'][1]; $this->assertEquals('REQ-PARTICIPANT', $attendee['role'], 'Attendee ROLE'); $this->assertEquals('NEEDS-ACTION', $attendee['status'], 'Attendee STATUS'); $this->assertEquals('rolf2@mykolab.com', $attendee['email'], 'Attendee mailto:'); $this->assertEquals('carl@mykolab.com', $attendee['delegated-from'], 'Attendee delegated-from'); $this->assertTrue($attendee['rsvp'], 'Attendee RSVP'); $delegator = $event['attendees'][2]; $this->assertEquals('NON-PARTICIPANT', $delegator['role'], 'Delegator ROLE'); $this->assertEquals('DELEGATED', $delegator['status'], 'Delegator STATUS'); $this->assertEquals('INDIVIDUAL', $delegator['cutype'], 'Delegator CUTYPE'); $this->assertEquals('carl@mykolab.com', $delegator['email'], 'Delegator mailto:'); $this->assertEquals('rolf2@mykolab.com', $delegator['delegated-to'], 'Delegator delegated-to'); $this->assertFalse($delegator['rsvp'], 'Delegator RSVP'); // attachments $this->assertEquals(1, count($event['attachments']), "Embedded attachments"); $attachment = $event['attachments'][0]; $this->assertEquals('text/html', $attachment['mimetype'], "Attachment mimetype attribute"); $this->assertEquals('calendar.html', $attachment['name'], "Attachment filename (X-LABEL) attribute"); $this->assertStringContainsString('Kalender', $attachment['data'], "Attachment content (decoded)"); // recurrence rules $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertTrue(is_array($event['recurrence']), 'Recurrences rule as hash array'); $rrule = $event['recurrence']; $this->assertEquals('MONTHLY', $rrule['FREQ'], "Recurrence frequency"); $this->assertEquals('1', $rrule['INTERVAL'], "Recurrence interval"); $this->assertEquals('3WE', $rrule['BYDAY'], "Recurrence frequency"); $this->assertInstanceOf('DateTimeInterface', $rrule['UNTIL'], "Recurrence end date"); $this->assertEquals(2, count($rrule['EXDATE']), "Recurrence EXDATEs"); $this->assertInstanceOf('DateTimeInterface', $rrule['EXDATE'][0], "Recurrence EXDATE as DateTime"); $this->assertTrue(is_array($rrule['EXCEPTIONS'])); $this->assertEquals(1, count($rrule['EXCEPTIONS']), "Recurrence Exceptions"); $exception = $rrule['EXCEPTIONS'][0]; $this->assertEquals($event['uid'], $event['uid'], "Exception UID"); $this->assertEquals('Recurring Test (Exception)', $exception['title'], "Exception title"); $this->assertInstanceOf('DateTimeInterface', $exception['start'], "Exception start"); // categories, class $this->assertEquals('libcalendaring tests', join(',', (array)$event['categories']), "Event categories"); // parse a recurrence chain instance $events = $ical->import_from_file(__DIR__ . '/resources/recurrence-id.ics', 'UTF-8'); $this->assertEquals(1, count($events), "Fall back to Component::getComponents() when getBaseComponents() is empty"); $this->assertInstanceOf('DateTimeInterface', $events[0]['recurrence_date'], "Recurrence-ID as date"); $this->assertTrue($events[0]['thisandfuture'], "Range=THISANDFUTURE"); $this->assertEquals(count($events[0]['exceptions']), 1, "Second VEVENT as exception"); $this->assertEquals($events[0]['exceptions'][0]['uid'], $events[0]['uid'], "Exception UID match"); $this->assertEquals($events[0]['exceptions'][0]['sequence'], '2', "Exception sequence"); } /** * */ function test_alarms() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-12H:DISPLAY', $event['alarms'], "Serialized alarms string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('12', $alarm[0], "Alarm value"); $this->assertEquals('-H', $alarm[1], "Alarm unit"); $this->assertEquals('DISPLAY', $event['valarms'][0]['action'], "Full alarm item (action)"); $this->assertEquals('-PT12H', $event['valarms'][0]['trigger'], "Full alarm item (trigger)"); $this->assertEquals('END', $event['valarms'][0]['related'], "Full alarm item (related)"); // alarm trigger with 0 values $events = $ical->import_from_file(__DIR__ . '/resources/alarms.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-30M:DISPLAY', $event['alarms'], "Stripped alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('30', $alarm[0], "Alarm value"); $this->assertEquals('-M', $alarm[1], "Alarm unit"); $this->assertEquals('-30M', $alarm[2], "Alarm string"); $this->assertEquals('-PT30M', $alarm[3], "Unified alarm string (stripped zero-values)"); $this->assertEquals('DISPLAY', $event['valarms'][0]['action'], "First alarm action"); $this->assertTrue(empty($event['valarms'][0]['related']), "First alarm related property"); $this->assertEquals('This is the first event reminder', $event['valarms'][0]['description'], "First alarm text"); $this->assertEquals(3, count($event['valarms']), "List all VALARM blocks"); $valarm = $event['valarms'][1]; $this->assertEquals(1, count($valarm['attendees']), "Email alarm attendees"); $this->assertEquals('EMAIL', $valarm['action'], "Second alarm item (action)"); $this->assertEquals('-P1D', $valarm['trigger'], "Second alarm item (trigger)"); $this->assertEquals('This is the reminder message', $valarm['summary'], "Email alarm text"); $this->assertInstanceOf('DateTimeInterface', $event['valarms'][2]['trigger'], "Absolute trigger date/time"); // test alarms export $ics = $ical->export([$event]); $this->assertStringContainsString('ACTION:DISPLAY', $ics, "Display alarm block"); $this->assertStringContainsString('ACTION:EMAIL', $ics, "Email alarm block"); $this->assertStringContainsString('DESCRIPTION:This is the first event reminder', $ics, "Alarm description"); $this->assertStringContainsString('SUMMARY:This is the reminder message', $ics, "Email alarm summary"); $this->assertStringContainsString('ATTENDEE:mailto:reminder-recipient@example.org', $ics, "Email alarm recipient"); $this->assertStringContainsString('TRIGGER;VALUE=DATE-TIME:20130812', $ics, "Date-Time trigger"); } /** * @depends test_import_from_file */ function test_attachment() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/attachment.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(2, count($events)); $this->assertEquals(1, count($event['attachments'])); $this->assertEquals('image/png', $event['attachments'][0]['mimetype']); $this->assertEquals('500px-Opensource.svg.png', $event['attachments'][0]['name']); } /** * @depends test_import */ function test_apple_alarms() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/apple-alarms.ics', 'UTF-8'); $event = $events[0]; // alarms $this->assertEquals('-45M:AUDIO', $event['alarms'], "Relative alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('45', $alarm[0], "Alarm value"); $this->assertEquals('-M', $alarm[1], "Alarm unit"); $this->assertEquals(1, count($event['valarms']), "Ignore invalid alarm blocks"); $this->assertEquals('AUDIO', $event['valarms'][0]['action'], "Full alarm item (action)"); $this->assertEquals('-PT45M', $event['valarms'][0]['trigger'], "Full alarm item (trigger)"); $this->assertEquals('Basso', $event['valarms'][0]['uri'], "Full alarm item (attachment)"); } /** * */ function test_escaped_values() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/escaped.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals("House, Street, Zip Place", $event['location'], "Decode escaped commas in location value"); $this->assertEquals("Me, meets Them\nThem, meet Me", $event['description'], "Decode description value"); $this->assertEquals("Kolab, Thomas", $event['attendees'][3]['name'], "Unescaped"); $ics = $ical->export($events); $this->assertStringContainsString('ATTENDEE;CN="Kolab, Thomas";PARTSTAT=', $ics, "Quoted attendee parameters"); } /** * Parse RDATE properties (#2885) */ function test_rdate() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(9, count($event['recurrence']['RDATE'])); $this->assertInstanceOf('DateTimeInterface', $event['recurrence']['RDATE'][0]); $this->assertInstanceOf('DateTimeInterface', $event['recurrence']['RDATE'][1]); } /** * @depends test_import */ function test_freebusy() { $ical = new libcalendaring_vcalendar(); $ical->import_from_file(__DIR__ . '/resources/freebusy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertInstanceOf('DateTimeInterface', $freebusy['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTimeInterface', $freebusy['end'], "'end' property is DateTime object"); $this->assertEquals(11, count($freebusy['periods']), "Number of freebusy periods defined"); $periods = $ical->get_busy_periods(); $this->assertEquals(9, count($periods), "Number of busy periods found"); $this->assertEquals('BUSY-TENTATIVE', $periods[8][2], "FBTYPE=BUSY-TENTATIVE"); } /** * @depends test_import */ function test_freebusy_dummy() { $ical = new libcalendaring_vcalendar(); $ical->import_from_file(__DIR__ . '/resources/dummy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertEquals(0, count($freebusy['periods']), "Ignore 0-length freebudy periods"); $this->assertStringContainsString('dummy', $freebusy['comment'], "Parse comment"); } function test_vtodo() { $ical = new libcalendaring_vcalendar(); $tasks = $ical->import_from_file(__DIR__ . '/resources/vtodo.ics', 'UTF-8', true); $task = $tasks[0]; $this->assertInstanceOf('DateTimeInterface', $task['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTimeInterface', $task['due'], "'due' property is DateTime object"); $this->assertEquals('-1D:DISPLAY', $task['alarms'], "Taks alarm value"); $this->assertEquals('IN-PROCESS', $task['status'], "Task status property"); $this->assertEquals(1, count($task['x-custom']), "Custom properties"); $this->assertEquals(4, count($task['categories'])); $this->assertEquals('1234567890-12345678-PARENT', $task['parent_id'], "Parent Relation"); $completed = $tasks[1]; $this->assertEquals('COMPLETED', $completed['status'], "Task status=completed when COMPLETED property is present"); $this->assertEquals(100, $completed['complete'], "Task percent complete value"); $ics = $ical->export([$completed]); $this->assertMatchesRegularExpression('/COMPLETED(;VALUE=DATE-TIME)?:[0-9TZ]+/', $ics, "Export COMPLETED property"); } /** * Test for iCal export from internal hash array representation */ function test_export() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event += $events[0]; $this->attachment_data = $event['attachments'][0]['data']; unset($event['attachments'][0]['data']); $event['attachments'][0]['id'] = '1'; $event['description'] = '*Exported by libcalendaring_vcalendar*'; $event['start']->setTimezone(new DateTimezone('America/Montreal')); $event['end']->setTimezone(new DateTimezone('Europe/Berlin')); $ics = $ical->export([$event], 'REQUEST', false, [$this, 'get_attachment_data'], true); $this->assertStringContainsString('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertStringContainsString('BEGIN:VTIMEZONE', $ics, "VTIMEZONE encapsulation BEGIN"); $this->assertStringContainsString('TZID:Europe/Berlin', $ics, "Timezone ID"); $this->assertStringContainsString('TZOFFSETFROM:+0100', $ics, "Timzone transition FROM"); $this->assertStringContainsString('TZOFFSETTO:+0200', $ics, "Timzone transition TO"); $this->assertStringContainsString('TZOFFSETFROM:-0400', $ics, "TZOFFSETFROM with negative offset (Bug T428)"); $this->assertStringContainsString('TZOFFSETTO:-0500', $ics, "TZOFFSETTO with negative offset (Bug T428)"); $this->assertStringContainsString('END:VTIMEZONE', $ics, "VTIMEZONE encapsulation END"); $this->assertStringContainsString('BEGIN:VEVENT', $ics, "VEVENT encapsulation BEGIN"); $this->assertSame(2, substr_count($ics, 'DTSTAMP'), "Duplicate DTSTAMP (T1148)"); $this->assertStringContainsString('UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0', $ics, "Event UID"); $this->assertStringContainsString('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number"); $this->assertStringContainsString('DESCRIPTION:*Exported by', $ics, "Export Description"); $this->assertStringContainsString('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer"); $this->assertMatchesRegularExpression('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE"); $this->assertMatchesRegularExpression('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status"); $this->assertMatchesRegularExpression('/ATTENDEE.*;RSVP=TRUE/', $ics, "Export Attendee RSVP"); $this->assertMatchesRegularExpression('/:mailto:rolf2@/', $ics, "Export Attendee mailto:"); $rrule = $event['recurrence']; $this->assertMatchesRegularExpression('/RRULE:.*FREQ='.$rrule['FREQ'].'/', $ics, "Export Recurrence Frequence"); $this->assertMatchesRegularExpression('/RRULE:.*INTERVAL='.$rrule['INTERVAL'].'/', $ics, "Export Recurrence Interval"); $this->assertMatchesRegularExpression('/RRULE:.*UNTIL=20140718T215959Z/', $ics, "Export Recurrence End date"); $this->assertMatchesRegularExpression('/RRULE:.*BYDAY='.$rrule['BYDAY'].'/', $ics, "Export Recurrence BYDAY"); $this->assertMatchesRegularExpression('/EXDATE.*:20131218/', $ics, "Export Recurrence EXDATE"); $this->assertStringContainsString('BEGIN:VALARM', $ics, "Export VALARM"); $this->assertStringContainsString('TRIGGER;RELATED=END:-PT12H', $ics, "Export Alarm trigger"); $this->assertMatchesRegularExpression('/ATTACH.*;VALUE=BINARY/', $ics, "Embed attachment"); $this->assertMatchesRegularExpression('/ATTACH.*;ENCODING=BASE64/', $ics, "Attachment B64 encoding"); $this->assertMatchesRegularExpression('!ATTACH.*;FMTTYPE=text/html!', $ics, "Attachment mimetype"); $this->assertMatchesRegularExpression('!ATTACH.*;X-LABEL=calendar.html!', $ics, "Attachment filename with X-LABEL"); $this->assertStringContainsString('END:VEVENT', $ics, "VEVENT encapsulation END"); $this->assertStringContainsString('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); } /** * @depends test_extended * @depends test_export */ function test_export_multiple() { $ical = new libcalendaring_vcalendar(); $events = array_merge( $ical->import_from_file(__DIR__ . '/resources/snd.ics', 'UTF-8'), $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8') ); $num = count($events); $ics = $ical->export($events, null, false); $this->assertStringContainsString('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertStringContainsString('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); } /** * @depends test_export */ function test_export_recurrence_exceptions() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); // add exceptions $event = $events[0]; unset($event['recurrence']['EXCEPTIONS']); $exception1 = $event; $exception1['start'] = clone $event['start']; $exception1['start']->setDate(2013, 8, 14); $exception1['end'] = clone $event['end']; $exception1['end']->setDate(2013, 8, 14); $exception2 = $event; $exception2['start'] = clone $event['start']; $exception2['start']->setDate(2013, 11, 13); $exception2['end'] = clone $event['end']; $exception2['end']->setDate(2013, 11, 13); $exception2['title'] = 'Recurring Exception'; $events[0]['recurrence']['EXCEPTIONS'] = [$exception1, $exception2]; $ics = $ical->export($events, null, false); $num = count($events[0]['recurrence']['EXCEPTIONS']) + 1; $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'UID:'.$event['uid']), "Recurrence Exceptions with same UID"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); $this->assertStringContainsString('RECURRENCE-ID;TZID=Europe/Zurich:20130814', $ics, "Recurrence-ID (1) being the exception date"); $this->assertStringContainsString('RECURRENCE-ID;TZID=Europe/Zurich:20131113', $ics, "Recurrence-ID (2) being the exception date"); $this->assertStringContainsString('SUMMARY:'.$exception2['title'], $ics, "Exception title"); } function test_export_valid_rrules() { $event = [ 'uid' => '1234567890', 'start' => new DateTime('now'), 'end' => new DateTime('now + 30min'), 'title' => 'test_export_valid_rrules', 'recurrence' => [ 'FREQ' => 'DAILY', 'COUNT' => 5, 'EXDATE' => [], 'RDATE' => [], ], ]; $ical = new libcalendaring_vcalendar(); $ics = $ical->export([$event], null, false, null, false); $this->assertStringNotContainsString('EXDATE=', $ics); $this->assertStringNotContainsString('RDATE=', $ics); } /** * */ function test_export_rdate() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $ics = $ical->export($events, null, false); $this->assertStringContainsString('RDATE:20140520T020000Z', $ics, "VALUE=PERIOD is translated into single DATE-TIME values"); } /** * @depends test_export */ function test_export_direct() { $ical = new libcalendaring_vcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $num = count($events); ob_start(); $return = $ical->export($events, null, true); $output = ob_get_contents(); ob_end_clean(); $this->assertTrue($return, "Return true on successful writing"); $this->assertStringContainsString('BEGIN:VCALENDAR', $output, "VCALENDAR encapsulation BEGIN"); $this->assertStringContainsString('END:VCALENDAR', $output, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($output, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($output, 'END:VEVENT'), "VEVENT encapsulation END"); } function test_datetime() { $ical = new libcalendaring_vcalendar(); $cal = new \Sabre\VObject\Component\VCalendar(); $localtime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin'))); $localdate = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01', new DateTimeZone('Europe/Berlin')), false, true); $utctime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('UTC'))); $asutctime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin')), true); $this->assertStringContainsString('TZID=Europe/Berlin', $localtime->serialize()); $this->assertStringContainsString('VALUE=DATE', $localdate->serialize()); $this->assertStringContainsString('20130901T120000Z', $utctime->serialize()); $this->assertStringContainsString('20130901T100000Z', $asutctime->serialize()); } function test_get_vtimezone() { $vtz = libcalendaring_vcalendar::get_vtimezone('Europe/Berlin', strtotime('2014-08-22T15:00:00+02:00')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz, "VTIMEZONE is a Component object"); $this->assertEquals('Europe/Berlin', $vtz->TZID); $this->assertEquals('4', $vtz->{'X-MICROSOFT-CDO-TZID'}); // check for transition to daylight saving time which is BEFORE the given date $dst = array_first($vtz->select('DAYLIGHT')); $this->assertEquals('DAYLIGHT', $dst->name); $this->assertEquals('20140330T010000', $dst->DTSTART); $this->assertEquals('+0100', $dst->TZOFFSETFROM); $this->assertEquals('+0200', $dst->TZOFFSETTO); $this->assertEquals('CEST', $dst->TZNAME); // check (last) transition to standard time which is AFTER the given date $std = $vtz->select('STANDARD'); $std = end($std); $this->assertEquals('STANDARD', $std->name); $this->assertEquals('20141026T010000', $std->DTSTART); $this->assertEquals('+0200', $std->TZOFFSETFROM); $this->assertEquals('+0100', $std->TZOFFSETTO); $this->assertEquals('CET', $std->TZNAME); // unknown timezone $vtz = libcalendaring_vcalendar::get_vtimezone('America/Foo Bar'); $this->assertEquals(false, $vtz); // invalid input data $vtz = libcalendaring_vcalendar::get_vtimezone(new DateTime()); $this->assertEquals(false, $vtz); // DateTimezone as input data $vtz = libcalendaring_vcalendar::get_vtimezone(new DateTimezone('Pacific/Chatham')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); $this->assertStringContainsString('TZOFFSETFROM:+1245', $vtz->serialize()); $this->assertStringContainsString('TZOFFSETTO:+1345', $vtz->serialize()); // Making sure VTIMEZOONE contains at least one STANDARD/DAYLIGHT component // when there's only one transition in specified time period (T5626) $vtz = libcalendaring_vcalendar::get_vtimezone('Europe/Istanbul', strtotime('2019-10-04T15:00:00')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); $dst = $vtz->select('DAYLIGHT'); $std = $vtz->select('STANDARD'); $this->assertEmpty($dst); $this->assertCount(1, $std); $std = end($std); $this->assertEquals('STANDARD', $std->name); $this->assertEquals('20181009T150000', $std->DTSTART); $this->assertEquals('+0300', $std->TZOFFSETFROM); $this->assertEquals('+0300', $std->TZOFFSETTO); $this->assertEquals('+03', $std->TZNAME); } function get_attachment_data($id, $event) { return $this->attachment_data; } }