diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index 395f5b3..62d9252 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,1250 +1,1363 @@ self::WEEKINDEX_SUNDAY, self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY, self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY, self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY, self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY, self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY, self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY, ); return $map; } private static function getWeekdayIndex($weekday) { $map = self::getWeekdayIndexMap(); if (!isset($map[$weekday])) { $constants = array_keys($map); throw new Exception( pht( 'Weekday "%s" is not a valid weekday constant. Valid constants '. 'are: %s.', $weekday, implode(', ', $constants))); } return $map[$weekday]; } public function setStartDateTime(PhutilCalendarDateTime $start) { $this->startDateTime = $start; return $this; } public function getStartDateTime() { return $this->startDateTime; } public function setFrequency($frequency) { static $map = array( self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY, self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY, self::FREQUENCY_HOURLY => self::SCALE_HOURLY, self::FREQUENCY_DAILY => self::SCALE_DAILY, self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY, self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY, self::FREQUENCY_YEARLY => self::SCALE_YEARLY, ); if (empty($map[$frequency])) { throw new Exception( pht( 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.', $frequency, implode(', ', array_keys($map)))); } $this->frequency = $frequency; $this->frequencyScale = $map[$frequency]; return $this; } public function getFrequency() { return $this->frequency; } public function getFrequencyScale() { return $this->frequencyScale; } public function setInterval($interval) { if (!is_int($interval)) { throw new Exception( pht( 'RRULE INTERVAL "%s" is invalid: interval must be an integer.', $interval)); } if ($interval < 1) { throw new Exception( pht( 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.', $interval)); } $this->interval = $interval; return $this; } public function getInterval() { return $this->interval; } public function setBySecond(array $by_second) { $this->assertByRange('BYSECOND', $by_second, 0, 60); $this->bySecond = array_fuse($by_second); return $this; } public function getBySecond() { return $this->bySecond; } public function setByMinute(array $by_minute) { $this->assertByRange('BYMINUTE', $by_minute, 0, 59); $this->byMinute = array_fuse($by_minute); return $this; } public function getByMinute() { return $this->byMinute; } public function setByHour(array $by_hour) { $this->assertByRange('BYHOUR', $by_hour, 0, 23); $this->byHour = array_fuse($by_hour); return $this; } public function getByHour() { return $this->byHour; } public function setByDay(array $by_day) { $constants = self::getAllWeekdayConstants(); $constants = implode('|', $constants); $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/'; foreach ($by_day as $key => $value) { $matches = null; if (!preg_match($pattern, $value, $matches)) { throw new Exception( pht( 'RRULE BYDAY value "%s" is invalid: rule part must be in the '. 'expected form (like "MO", "-3TH", or "+2SU").', $value)); } // The maximum allowed value is 53, which corresponds to "the 53rd // Monday every year" or similar when evaluated against a YEARLY rule. $maximum = 53; $magnitude = (int)$matches[1]; if ($magnitude > $maximum) { throw new Exception( pht( 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '. 'the maximum permitted value is "%s".', $value, $magnitude, $maximum)); } // Normalize "+3FR" into "3FR". $by_day[$key] = ltrim($value, '+'); } $this->byDay = array_fuse($by_day); return $this; } public function getByDay() { return $this->byDay; } public function setByMonthDay(array $by_month_day) { $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false); $this->byMonthDay = array_fuse($by_month_day); return $this; } public function getByMonthDay() { return $this->byMonthDay; } public function setByYearDay($by_year_day) { $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false); $this->byYearDay = array_fuse($by_year_day); return $this; } public function getByYearDay() { return $this->byYearDay; } public function setByMonth(array $by_month) { $this->assertByRange('BYMONTH', $by_month, 1, 12); $this->byMonth = array_fuse($by_month); return $this; } public function getByMonth() { return $this->byMonth; } public function setByWeekNumber(array $by_week_number) { $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false); $this->byWeekNumber = array_fuse($by_week_number); return $this; } public function getByWeekNumber() { return $this->byWeekNumber; } public function setBySetPosition(array $by_set_position) { $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false); $this->bySetPosition = $by_set_position; return $this; } public function getBySetPosition() { return $this->bySetPosition; } public function setWeekStart($week_start) { // Make sure this is a valid weekday constant. self::getWeekdayIndex($week_start); $this->weekStart = $week_start; return $this; } public function getWeekStart() { return $this->weekStart; } public function resetSource() { $date = $this->getStartDateTime(); $this->cursorSecond = $date->getSecond(); $this->cursorMinute = $date->getMinute(); $this->cursorHour = $date->getHour(); // TODO: Figure this out. $this->cursorWeek = null; $this->cursorDay = $date->getDay(); $this->cursorMonth = $date->getMonth(); $this->cursorYear = $date->getYear(); $this->setSeconds = array(); $this->setMinutes = array(); $this->setHours = array(); $this->setDays = array(); $this->setMonths = array(); $this->setYears = array(); $this->stateSecond = null; $this->stateMinute = null; $this->stateHour = null; $this->stateDay = null; $this->stateMonth = null; $this->stateYear = null; // If we have a BYSETPOS, we need to generate the entire set before we // can filter it and return results. Normally, we start generating at // the start date, but we need to go back one interval to generate // BYSETPOS events so we can make sure the entire set is generated. if ($this->getBySetPosition()) { $frequency = $this->getFrequency(); $interval = $this->getInterval(); switch ($frequency) { case self::FREQUENCY_YEARLY: $this->cursorYear -= $interval; break; case self::FREQUENCY_MONTHLY: $this->cursorMonth -= $interval; while ($this->cursorMonth < 1) { $this->rewindMonth(); } break; case self::FREQUENCY_DAILY: $this->cursorDay -= $interval; $week_start = $this->getWeekStart(); while ($this->cursorDay < 1) { $year_map = $this->getYearMap($this->cursorYear, $week_start); $this->cursorDay += $year_map['monthDays'][$this->cursorMonth]; $this->rewindMonth(); } break; default: throw new Exception( pht( 'BYSETPOS not yet supported for FREQ "%s".', $frequency)); } $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); } else { $this->minimumEpoch = null; } - $this->cursorDayMonth = $this->cursorMonth; - $this->cursorDayYear = $this->cursorYear; + $cursor_state = array( + 'year' => $this->cursorYear, + 'month' => $this->cursorMonth, + 'day' => $this->cursorDay, + 'hour' => $this->cursorHour, + ); + + $this->cursorDayState = $cursor_state; + $this->cursorHourState = $cursor_state; $by_hour = $this->getByHour(); $by_minute = $this->getByMinute(); $by_second = $this->getBySecond(); $scale = $this->getFrequencyScale(); // We return all-day events if the start date is an all-day event and we // don't have more granular selectors or a more granular frequency. $this->isAllDay = $date->getIsAllDay() && !$by_hour && !$by_minute && !$by_second && ($scale > self::SCALE_HOURLY); } public function getNextEvent($cursor) { while (true) { $event = $this->generateNextEvent(); if (!$event) { break; } $epoch = $event->getEpoch(); if ($this->minimumEpoch) { if ($epoch < $this->minimumEpoch) { continue; } } if ($epoch < $cursor) { continue; } break; } return $event; } private function generateNextEvent() { if ($this->activeSet) { return array_pop($this->activeSet); } $this->baseYear = $this->cursorYear; $by_setpos = $this->getBySetPosition(); if ($by_setpos) { $old_state = $this->getSetPositionState(); } while (!$this->activeSet) { $this->activeSet = $this->nextSet; $this->nextSet = array(); while (true) { if ($this->isAllDay) { $this->nextDay(); } else { $this->nextSecond(); } $result = id(new PhutilCalendarAbsoluteDateTime()) ->setViewerTimezone($this->getViewerTimezone()) ->setYear($this->stateYear) ->setMonth($this->stateMonth) ->setDay($this->stateDay); if ($this->isAllDay) { $result->setIsAllDay(true); } else { $result ->setHour($this->stateHour) ->setMinute($this->stateMinute) ->setSecond($this->stateSecond); } // If we don't have BYSETPOS, we're all done. We put this into the // set and will immediately return it. if (!$by_setpos) { $this->activeSet[] = $result; break; } // Otherwise, check if we've completed a set. The set is complete if // the state has moved past the span we were examining (for example, // with a YEARLY event, if the state is now in the next year). $new_state = $this->getSetPositionState(); if ($new_state == $old_state) { $this->activeSet[] = $result; continue; } $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos); $this->activeSet = array_reverse($this->activeSet); $this->nextSet[] = $result; $old_state = $new_state; break; } } return array_pop($this->activeSet); } protected function nextSecond() { if ($this->setSeconds) { $this->stateSecond = array_pop($this->setSeconds); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $is_secondly = ($frequency == self::FREQUENCY_SECONDLY); $by_second = $this->getBySecond(); while (!$this->setSeconds) { $this->nextMinute(); if ($is_secondly || $by_second) { $seconds = $this->newSecondsSet( ($is_secondly ? $interval : 1), $by_second); } else { $seconds = array( $this->cursorSecond, ); } $this->setSeconds = array_reverse($seconds); } $this->stateSecond = array_pop($this->setSeconds); } protected function nextMinute() { if ($this->setMinutes) { $this->stateMinute = array_pop($this->setMinutes); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_minutely = ($frequency === self::FREQUENCY_MINUTELY); $by_minute = $this->getByMinute(); while (!$this->setMinutes) { $this->nextHour(); if ($is_minutely || $by_minute) { $minutes = $this->newMinutesSet( ($is_minutely ? $interval : 1), $by_minute); } else if ($scale < self::SCALE_MINUTELY) { $minutes = $this->newMinutesSet( 1, array()); } else { $minutes = array( $this->cursorMinute, ); } $this->setMinutes = array_reverse($minutes); } $this->stateMinute = array_pop($this->setMinutes); } protected function nextHour() { if ($this->setHours) { $this->stateHour = array_pop($this->setHours); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_hourly = ($frequency === self::FREQUENCY_HOURLY); $by_hour = $this->getByHour(); while (!$this->setHours) { $this->nextDay(); - if ($is_hourly || $by_hour) { + $is_dynamic = $is_hourly + || $by_hour + || ($scale < self::SCALE_HOURLY); + + if ($is_dynamic) { $hours = $this->newHoursSet( ($is_hourly ? $interval : 1), $by_hour); - } else if ($scale < self::SCALE_HOURLY) { - $hours = $this->newHoursSet( - 1, - array()); } else { $hours = array( $this->cursorHour, ); } $this->setHours = array_reverse($hours); } $this->stateHour = array_pop($this->setHours); } protected function nextDay() { if ($this->setDays) { $this->stateDay = array_pop($this->setDays); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_daily = ($frequency === self::FREQUENCY_DAILY); $is_weekly = ($frequency === self::FREQUENCY_WEEKLY); $by_day = $this->getByDay(); $by_monthday = $this->getByMonthDay(); $by_yearday = $this->getByYearDay(); $by_weekno = $this->getByWeekNumber(); $by_month = $this->getByMonth(); $week_start = $this->getWeekStart(); while (!$this->setDays) { $this->nextMonth(); - $is_dyanmic = $is_daily + $is_dynamic = $is_daily || $by_day || $by_monthday || $by_yearday || $by_weekno || ($scale < self::SCALE_DAILY); - if ($is_dyanmic) { + if ($is_dynamic) { $weeks = $this->newDaysSet( ($is_daily ? $interval : 1), ($is_weekly ? $interval : null), $by_day, $by_monthday, $by_yearday, $by_weekno, $week_start); } else { // The cursor day may not actually exist in the current month, so // make sure the day is valid before we generate a set which contains // it. $year_map = $this->getYearMap($this->stateYear, $week_start); if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) { $weeks = array( array(), ); } else { $weeks = array( array($this->cursorDay), ); } } // Unpack the weeks into days. $days = array_mergev($weeks); $this->setDays = array_reverse($days); } $this->stateDay = array_pop($this->setDays); } protected function nextMonth() { if ($this->setMonths) { $this->stateMonth = array_pop($this->setMonths); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_monthly = ($frequency === self::FREQUENCY_MONTHLY); $by_month = $this->getByMonth(); // If we have a BYMONTHDAY, we consider that set of days in every month. // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every // month", so we need to expand the month set if the constraint is present. $by_monthday = $this->getByMonthDay(); // Likewise, we need to generate all months if we have BYYEARDAY or // BYWEEKNO or BYDAY. $by_yearday = $this->getByYearDay(); $by_weekno = $this->getByWeekNumber(); $by_day = $this->getByDay(); while (!$this->setMonths) { $this->nextYear(); $is_dynamic = $is_monthly || $by_month || $by_monthday || $by_yearday || $by_weekno || $by_day || ($scale < self::SCALE_MONTHLY); if ($is_dynamic) { $months = $this->newMonthsSet( ($is_monthly ? $interval : 1), $by_month); } else { $months = array( $this->cursorMonth, ); } $this->setMonths = array_reverse($months); } $this->stateMonth = array_pop($this->setMonths); } protected function nextYear() { $this->stateYear = $this->cursorYear; $frequency = $this->getFrequency(); $is_yearly = ($frequency === self::FREQUENCY_YEARLY); if ($is_yearly) { $interval = $this->getInterval(); } else { $interval = 1; } $this->cursorYear = $this->cursorYear + $interval; if ($this->cursorYear > ($this->baseYear + 100)) { throw new Exception( pht( 'RRULE evaluation failed to generate more events in the next 100 '. 'years. This RRULE is likely invalid or degenerate.')); } } private function newSecondsSet($interval, $set) { // TODO: This doesn't account for leap seconds. In theory, it probably // should, although this shouldn't impact any real events. $seconds_in_minute = 60; if ($this->cursorSecond >= $seconds_in_minute) { $this->cursorSecond -= $seconds_in_minute; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorSecond, $interval, $set, $seconds_in_minute); $this->cursorSecond = ($cursor - $seconds_in_minute); return $result; } private function newMinutesSet($interval, $set) { // NOTE: This value is legitimately a constant! Amazing! $minutes_in_hour = 60; if ($this->cursorMinute >= $minutes_in_hour) { $this->cursorMinute -= $minutes_in_hour; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorMinute, $interval, $set, $minutes_in_hour); $this->cursorMinute = ($cursor - $minutes_in_hour); return $result; } private function newHoursSet($interval, $set) { // TODO: This doesn't account for hours caused by daylight savings time. // It probably should, although this seems unlikely to impact any real // events. $hours_in_day = 24; - if ($this->cursorHour >= $hours_in_day) { - $this->cursorHour -= $hours_in_day; + // If the hour cursor is behind the current time, we need to forward it in + // INTERVAL increments so we end up with the right offset. + list($skip, $this->cursorHourState) = $this->advanceCursorState( + $this->cursorHourState, + self::SCALE_HOURLY, + $interval, + $this->getWeekStart()); + + if ($skip) { return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorHour, $interval, $set, $hours_in_day); $this->cursorHour = ($cursor - $hours_in_day); return $result; } private function newDaysSet( $interval_day, $interval_week, $by_day, $by_monthday, $by_yearday, $by_weekno, $week_start) { - $selection = array(); if ($interval_week) { $year_map = $this->getYearMap($this->stateYear, $week_start); while (true) { // TODO: This is all garbage? if ($this->cursorWeek > $year_map['weekCount']) { $this->cursorWeek -= $year_map['weekCount']; break; } foreach ($year_map['weeks'][$this->cursorWeek] as $key) { $selection[] = $year_map['info'][$key]; } $last = last($selection); if ($last['month'] > $this->stateMonth) { break; } $this->cursorWeek += $interval_week; } } else { if (!$interval_day) { $interval_day = 1; } // If the day cursor is behind the current year and month, we need to // forward it in INTERVAL increments so we end up with the right offset // in the current month. - $year_map = $this->getYearMap($this->cursorDayYear, $week_start); - while (($this->cursorDayYear < $this->stateYear) || - ($this->cursorDayYear == $this->stateYear && - $this->cursorDayMonth < $this->stateMonth)) { - $this->cursorDay += $interval_day; - if ($this->cursorDay > $year_map['monthDays'][$this->cursorDayMonth]) { - $this->cursorDay -= $year_map['monthDays'][$this->cursorDayMonth]; - $this->cursorDayMonth++; - if ($this->cursorDayMonth > 12) { - $this->cursorDayMonth = 1; - $this->cursorDayYear++; - $year_map = $this->getYearMap($this->cursorDayYear, $week_start); - } - } - } + list($skip, $this->cursorDayState) = $this->advanceCursorState( + $this->cursorDayState, + self::SCALE_DAILY, + $interval_day, + $week_start); - while (true) { - $month_idx = $this->stateMonth; - $month_days = $year_map['monthDays'][$month_idx]; - if ($this->cursorDay > $month_days) { - $this->cursorDay -= $month_days; - $this->cursorDayMonth++; - if ($this->cursorDayMonth > 12) { - $this->cursorDayMonth = 1; - $this->cursorDayYear++; + if (!$skip) { + $year_map = $this->getYearMap($this->stateYear, $week_start); + while (true) { + $month_idx = $this->stateMonth; + $month_days = $year_map['monthDays'][$month_idx]; + if ($this->cursorDay > $month_days) { // NOTE: The year map is now out of date, but we're about to break // out of the loop anyway so it doesn't matter. + break; } - break; - } - $day_idx = $this->cursorDay; + $day_idx = $this->cursorDay; - $key = "{$month_idx}M{$day_idx}D"; - $selection[] = $year_map['info'][$key]; + $key = "{$month_idx}M{$day_idx}D"; + $selection[] = $year_map['info'][$key]; - $this->cursorDay += $interval_day; + $this->cursorDay += $interval_day; + } } } $frequency = $this->getFrequency(); $is_yearly = ($frequency == self::FREQUENCY_YEARLY); $is_monthly = ($frequency == self::FREQUENCY_MONTHLY); // As a special case, BYDAY applies to relative month offsets if BYMONTH // is present in a YEARLY rule. if ($is_yearly) { if ($this->getByMonth()) { $is_yearly = false; $is_monthly = true; } } $weeks = array(); foreach ($selection as $key => $info) { if ($info['month'] != $this->stateMonth) { continue; } if ($by_day) { if (empty($by_day[$info['weekday']])) { if ($is_yearly) { if (empty($by_day[$info['weekday.yearly']]) && empty($by_day[$info['-weekday.yearly']])) { continue; } } else if ($is_monthly) { if (empty($by_day[$info['weekday.monthly']]) && empty($by_day[$info['-weekday.monthly']])) { continue; } } else { continue; } } } if ($by_monthday) { if (empty($by_monthday[$info['monthday']]) && empty($by_monthday[$info['-monthday']])) { continue; } } if ($by_yearday) { if (empty($by_yearday[$info['yearday']]) && empty($by_yearday[$info['-yearday']])) { continue; } } if ($by_weekno) { if (empty($by_weekno[$info['week']]) && empty($by_weekno[$info['-week']])) { continue; } } $weeks[$info['week']][] = $info['monthday']; } return array_values($weeks); } private function newMonthsSet($interval, $set) { // NOTE: This value is also a real constant! Wow! $months_in_year = 12; if ($this->cursorMonth > $months_in_year) { $this->cursorMonth -= $months_in_year; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorMonth, $interval, $set, $months_in_year + 1); $this->cursorMonth = ($cursor - $months_in_year); return $result; } public static function getYearMap($year, $week_start) { static $maps = array(); $key = "{$year}/{$week_start}"; if (isset($maps[$key])) { return $maps[$key]; } $map = self::newYearMap($year, $week_start); $maps[$key] = $map; return $maps[$key]; } private static function newYearMap($year, $weekday_start) { $weekday_index = self::getWeekdayIndex($weekday_start); $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) || ($year % 400 === 0); // There may be some clever way to figure out which day of the week a given // year starts on and avoid the cost of a DateTime construction, but I // wasn't able to turn it up and we only need to do this once per year. $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC')); $weekday = (int)$datetime->format('w'); if ($is_leap) { $max_day = 366; } else { $max_day = 365; } $month_days = array( 1 => 31, 2 => $is_leap ? 29 : 28, 3 => 31, 4 => 30, 5 => 31, 6 => 30, 7 => 31, 8 => 31, 9 => 30, 10 => 31, 11 => 30, 12 => 31, ); // Per the spec, the first week of the year must contain at least four // days. If the week starts on a Monday but the year starts on a Saturday, // the first couple of days don't count as a week. In this case, the first // week will begin on January 3. $first_week_size = 0; $first_weekday = $weekday; for ($year_day = 1; $year_day <= $max_day; $year_day++) { $first_weekday = ($first_weekday + 1) % 7; $first_week_size++; if ($first_weekday === $weekday_index) { break; } } if ($first_week_size >= 4) { $week_number = 1; } else { $week_number = 0; } $info_map = array(); $weekday_map = self::getWeekdayIndexMap(); $weekday_map = array_flip($weekday_map); $yearly_counts = array(); $monthly_counts = array(); $month_number = 1; $month_day = 1; for ($year_day = 1; $year_day <= $max_day; $year_day++) { $key = "{$month_number}M{$month_day}D"; $short_day = $weekday_map[$weekday]; if (empty($yearly_counts[$short_day])) { $yearly_counts[$short_day] = 0; } $yearly_counts[$short_day]++; if (empty($monthly_counts[$month_number][$short_day])) { $monthly_counts[$month_number][$short_day] = 0; } $monthly_counts[$month_number][$short_day]++; $info = array( 'year' => $year, 'key' => $key, 'month' => $month_number, 'monthday' => $month_day, '-monthday' => -$month_days[$month_number] + $month_day - 1, 'yearday' => $year_day, '-yearday' => -$max_day + $year_day - 1, 'week' => $week_number, 'weekday' => $short_day, 'weekday.yearly' => $yearly_counts[$short_day], 'weekday.monthly' => $monthly_counts[$month_number][$short_day], ); $info_map[$key] = $info; $weekday = ($weekday + 1) % 7; if ($weekday === $weekday_index) { $week_number++; } $month_day = ($month_day + 1); if ($month_day > $month_days[$month_number]) { $month_day = 1; $month_number++; } } // Check how long the final week is. If it doesn't have four days, this // is really the first week of the next year. $final_week = array(); foreach ($info_map as $key => $info) { if ($info['week'] == $week_number) { $final_week[] = $key; } } if (count($final_week) < 4) { $week_number = $week_number - 1; $next_year = self::getYearMap($year + 1, $weekday_start); $next_year_weeks = $next_year['weekCount']; } else { $next_year_weeks = null; } if ($first_week_size < 4) { $last_year = self::getYearMap($year - 1, $weekday_start); $last_year_weeks = $last_year['weekCount']; } else { $last_year_weeks = null; } // Now that we know how many weeks the year has, we can compute the // negative offsets. foreach ($info_map as $key => $info) { $week = $info['week']; if ($week === 0) { // If this day is part of the first partial week of the year, give // it the week number of the last week of the prior year instead. $info['week'] = $last_year_weeks; $info['-week'] = -1; } else if ($week > $week_number) { // If this day is part of the last partial week of the year, give // it week numbers from the next year. $info['week'] = 1; $info['-week'] = -$next_year_weeks; } else { $info['-week'] = -$week_number + $week - 1; } // Do all the arithmetic to figure out if this is the -19th Thursday // in the year and such. $month_number = $info['month']; $short_day = $info['weekday']; $monthly_count = $monthly_counts[$month_number][$short_day]; $monthly_index = $info['weekday.monthly']; $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1; $info['-weekday.monthly'] .= $short_day; $info['weekday.monthly'] .= $short_day; $yearly_count = $yearly_counts[$short_day]; $yearly_index = $info['weekday.yearly']; $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1; $info['-weekday.yearly'] .= $short_day; $info['weekday.yearly'] .= $short_day; $info_map[$key] = $info; } return array( 'info' => $info_map, 'weekCount' => $week_number, 'dayCount' => $max_day, 'monthDays' => $month_days, ); } private function newIteratorSet($cursor, $interval, $set, $limit) { if ($interval < 1) { throw new Exception( pht( 'Invalid iteration interval ("%d"), must be at least 1.', $interval)); } $result = array(); $seen = array(); $ii = $cursor; while (true) { if (!$set || isset($set[$ii])) { $result[] = $ii; } $ii = ($ii + $interval); if ($ii >= $limit) { break; } } sort($result); $result = array_values($result); return array($ii, $result); } private function applySetPos(array $values, array $setpos) { $select = array(); $count = count($values); foreach ($setpos as $pos) { if ($pos > 0 && $pos <= $count) { $select[] = ($pos - 1); } else if ($pos < 0 && $pos >= -$count) { $select[] = ($count + $pos); } } sort($select); $select = array_unique($select); return array_select_keys($values, $select); } private function assertByRange( $source, array $values, $min, $max, $allow_zero = true) { foreach ($values as $value) { if (!is_int($value)) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: values must be '. 'integers.', $value, $source)); } if ($value < $min || $value > $max) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: it must be '. 'between %s and %s.', $value, $source, $min, $max)); } if (!$value && !$allow_zero) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: it must not '. 'be zero.', $value, $source)); } } } private function getSetPositionState() { $scale = $this->getFrequencyScale(); $parts = array(); $parts[] = $this->stateYear; if ($scale < self::SCALE_YEARLY) { $parts[] = $this->stateMonth; } if ($scale < self::SCALE_MONTHLY) { $parts[] = $this->stateDay; } return implode('/', $parts); } private function rewindMonth() { $this->cursorYear--; $this->cursorMonth += 12; } + private function advanceCursorState( + array $cursor, + $scale, + $interval, + $week_start) { + + $state = array( + 'year' => $this->stateYear, + 'month' => $this->stateMonth, + 'day' => $this->stateDay, + 'hour' => $this->stateHour, + ); + + // In the common case when the interval is 1, we'll visit every possible + // value so we don't need to do any math and can just jump to the first + // hour, day, etc. + if ($interval == 1) { + if ($this->isCursorBehind($cursor, $state, $scale)) { + switch ($scale) { + case self::SCALE_DAILY: + $this->cursorDay = 1; + break; + case self::SCALE_HOURLY: + $this->cursorHour = 0; + break; + } + } + return array(false, $state); + } + + $year_map = $this->getYearMap($cursor['year'], $week_start); + while ($this->isCursorBehind($cursor, $state, $scale)) { + switch ($scale) { + case self::SCALE_DAILY: + $cursor['day'] += $interval; + break; + case self::SCALE_HOURLY: + $cursor['hour'] += $interval; + break; + } + + if ($scale <= self::SCALE_HOURLY) { + while ($cursor['hour'] >= 24) { + $cursor['hour'] -= 24; + $cursor['day']++; + } + } + + if ($scale <= self::SCALE_DAILY) { + while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) { + $cursor['day'] -= $year_map['monthDays'][$cursor['month']]; + $cursor['month']++; + if ($cursor['month'] > 12) { + $cursor['month'] -= 12; + $cursor['year']++; + $year_map = $this->getYearMap($cursor['year'], $week_start); + } + } + } + } + + switch ($scale) { + case self::SCALE_DAILY: + $this->cursorDay = $cursor['day']; + break; + case self::SCALE_HOURLY: + $this->cursorHour = $cursor['hour']; + break; + } + + $skip = $this->isCursorBehind($state, $cursor, $scale); + + return array($skip, $cursor); + } + + private function isCursorBehind(array $cursor, array $state, $scale) { + if ($cursor['year'] < $state['year']) { + return true; + } else if ($cursor['year'] > $state['year']) { + return false; + } + + if ($cursor['month'] < $state['month']) { + return true; + } else if ($cursor['month'] > $state['month']) { + return false; + } + + if ($scale >= self::SCALE_DAILY) { + return false; + } + + if ($cursor['day'] < $state['day']) { + return true; + } else if ($cursor['day'] > $state['day']) { + return false; + } + + if ($scale >= self::SCALE_HOURLY) { + return false; + } + + if ($cursor['hour'] < $state['hour']) { + return true; + } else if ($cursor['hour'] > $state['hour']) { + return false; + } + + return false; + } + + + } diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php index 96af876..65808db 100644 --- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php +++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php @@ -1,892 +1,940 @@ setStartDateTime($start) ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY); $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, 3); $expect = array( PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), ); $this->assertEqual( mpull($expect, 'getISO8601'), mpull($result, 'getISO8601'), pht('Simple daily event.')); $rrule = id(new PhutilCalendarRecurrenceRule()) ->setStartDateTime($start) ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY) ->setByHour(array(12, 13)); $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, 5); $expect = array( PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T130000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T130000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), ); $this->assertEqual( mpull($expect, 'getISO8601'), mpull($result, 'getISO8601'), pht('Hourly event with BYHOUR.')); $rrule = id(new PhutilCalendarRecurrenceRule()) ->setStartDateTime($start) ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY); $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, 2); $expect = array( PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), ); $this->assertEqual( mpull($expect, 'getISO8601'), mpull($result, 'getISO8601'), pht('Yearly event.')); // This is an efficiency test for bizarre rules: it defines a secondly // event which only occurs one a year, and generates 3 instances of it. // This implementation should be fast enough that this test doesn't take // a significant amount of time. $rrule = id(new PhutilCalendarRecurrenceRule()) ->setStartDateTime($start) ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY) ->setByMonth(array(1)) ->setByMonthDay(array(1)) ->setByHour(array(12)) ->setByMinute(array(0)) ->setBySecond(array(0)); $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, 3); $expect = array( PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), PhutilCalendarAbsoluteDateTime::newFromISO8601('20180101T120000Z'), ); $this->assertEqual( mpull($expect, 'getISO8601'), mpull($result, 'getISO8601'), pht('Secondly event with many constraints.')); } public function testYearlyRecurrenceRules() { $tests = array(); $expect = array(); $tests[] = array(); $expect[] = array( '19970902', '19980902', '19990902', ); $tests[] = array( 'INTERVAL' => 2, ); $expect[] = array( '19970902', '19990902', '20010902', ); $tests[] = array( 'DTSTART' => '20000229', ); $expect[] = array( '20000229', '20040229', '20080229', ); $tests[] = array( 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980102', '19980302', '19990102', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), ); $expect[] = array( '19970903', '19971001', '19971003', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYMONTHDAY' => array(5, 7), ); $expect[] = array( '19980105', '19980107', '19980305', ); $tests[] = array( 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19970902', '19970904', '19970909', ); $tests[] = array( 'BYDAY' => array('SU'), ); $expect[] = array( '19970907', '19970914', '19970921', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980106', '19980108', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980203', '19980303', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980101', '19980303', '20010301', ); $tests[] = array( 'BYDAY' => array('1TU', '-1TH'), ); $expect[] = array( '19971225', '19980106', '19981231', ); // Same test as above, just making sure the optional "+" syntax works. $tests[] = array( 'BYDAY' => array('+1TU', '-1TH'), ); $expect[] = array( '19971225', '19980106', '19981231', ); $tests[] = array( 'BYDAY' => array('3TU', '-3TH'), ); $expect[] = array( '19971211', '19980120', '19981217', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('1TU', '-1TH'), ); $expect[] = array( '19980106', '19980129', '19980303', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('3TU', '-3TH'), ); $expect[] = array( '19980115', '19980120', '19980312', ); $tests[] = array( 'BYYEARDAY' => array(1, 100, 200, 365), 'COUNT' => 4, ); $expect[] = array( '19971231', '19980101', '19980410', '19980719', ); $tests[] = array( 'BYYEARDAY' => array(-365, -266, -166, -1), 'COUNT' => 4, ); $expect[] = array( '19971231', '19980101', '19980410', '19980719', ); $tests[] = array( 'BYYEARDAY' => array(1, 100, 200, 365), 'BYMONTH' => array(4, 7), 'COUNT' => 4, ); $expect[] = array( '19980410', '19980719', '19990410', '19990719', ); $tests[] = array( 'BYYEARDAY' => array(-365, -266, -166, -1), 'BYMONTH' => array(4, 7), 'COUNT' => 4, ); $expect[] = array( '19980410', '19980719', '19990410', '19990719', ); $tests[] = array( 'BYWEEKNO' => array(20), ); $expect[] = array( '19980511', '19980512', '19980513', ); $tests[] = array( 'BYWEEKNO' => array(1), 'BYDAY' => array('MO'), ); $expect[] = array( '19971229', '19990104', '20000103', ); $tests[] = array( 'BYWEEKNO' => array(52), 'BYDAY' => array('SU'), ); $expect[] = array( '19971228', '19981227', '20000102', ); $tests[] = array( 'BYWEEKNO' => array(-1), 'BYDAY' => array('SU'), ); $expect[] = array( '19971228', '19990103', '20000102', ); $tests[] = array( 'BYWEEKNO' => array(53), 'BYDAY' => array('MO'), ); $expect[] = array( '19981228', '20041227', '20091228', ); $tests[] = array( 'BYHOUR' => array(6, 18), ); $expect[] = array( '19970902T060000Z', '19970902T180000Z', '19980902T060000Z', ); $tests[] = array( 'BYMINUTE' => array(15, 30), ); $expect[] = array( '19970902T001500Z', '19970902T003000Z', '19980902T001500Z', ); $tests[] = array( 'BYSECOND' => array(10, 20), ); $expect[] = array( '19970902T000010Z', '19970902T000020Z', '19980902T000010Z', ); $tests[] = array( 'BYHOUR' => array(6, 18), 'BYMINUTE' => array(15, 30), ); $expect[] = array( '19970902T061500Z', '19970902T063000Z', '19970902T181500Z', ); $tests[] = array( 'BYHOUR' => array(6, 18), 'BYSECOND' => array(10, 20), ); $expect[] = array( '19970902T060010Z', '19970902T060020Z', '19970902T180010Z', ); $tests[] = array( 'BYMINUTE' => array(15, 30), 'BYSECOND' => array(10, 20), ); $expect[] = array( '19970902T001510Z', '19970902T001520Z', '19970902T003010Z', ); $tests[] = array( 'BYHOUR' => array(6, 18), 'BYMINUTE' => array(15, 30), 'BYSECOND' => array(10, 20), ); $expect[] = array( '19970902T061510Z', '19970902T061520Z', '19970902T063010Z', ); $tests[] = array( 'BYMONTHDAY' => array(15), 'BYHOUR' => array(6, 18), 'BYSETPOS' => array(3, -3), ); $expect[] = array( '19971115T180000Z', '19980215T060000Z', '19981115T180000Z', ); $this->assertRules( array( 'FREQ' => 'YEARLY', 'COUNT' => 3, 'DTSTART' => '19970902', ), $tests, $expect); } public function testMonthlyRecurrenceRules() { $tests = array(); $expect = array(); $tests[] = array(); $expect[] = array( '19970902', '19971002', '19971102', ); $tests[] = array( 'INTERVAL' => 2, ); $expect[] = array( '19970902', '19971102', '19980102', ); $tests[] = array( 'INTERVAL' => 18, ); $expect[] = array( '19970902', '19990302', '20000902', ); $tests[] = array( 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980102', '19980302', '19990102', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), ); $expect[] = array( '19970903', '19971001', '19971003', ); $tests[] = array( 'BYMONTHDAY' => array(5, 7), 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980105', '19980107', '19980305', ); $tests[] = array( 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19970902', '19970904', '19970909', ); $tests[] = array( 'BYDAY' => array('3MO'), ); $expect[] = array( '19970915', '19971020', '19971117', ); $tests[] = array( 'BYDAY' => array('1TU', '-1TH'), ); $expect[] = array( '19970902', '19970925', '19971007', ); $tests[] = array( 'BYDAY' => array('3TU', '-3TH'), ); $expect[] = array( '19970911', '19970916', '19971016', ); $tests[] = array( 'BYDAY' => array('TU', 'TH'), 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980101', '19980106', '19980108', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('1TU', '-1TH'), ); $expect[] = array( '19980106', '19980129', '19980303', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('3TU', '-3TH'), ); $expect[] = array( '19980115', '19980120', '19980312', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980203', '19980303', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980303', '20010301', ); $tests[] = array( 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), 'BYSETPOS' => array(-1), ); $expect[] = array( '19970930', '19971031', '19971128', ); $tests[] = array( 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', '1FR', '-1FR'), 'BYMONTHDAY' => array(1, -1, -2), ); $expect[] = array( '19971001', '19971031', '19971201', ); $tests[] = array( 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', 'FR'), 'BYMONTHDAY' => array(1, -1, -2), ); $expect[] = array( '19971001', '19971031', '19971201', ); $tests[] = array( 'BYHOUR' => array(6, 18), ); $expect[] = array( '19970902T060000Z', '19970902T180000Z', '19971002T060000Z', ); $tests[] = array( 'BYMINUTE' => array(6, 18), ); $expect[] = array( '19970902T000600Z', '19970902T001800Z', '19971002T000600Z', ); $tests[] = array( 'BYSECOND' => array(6, 18), ); $expect[] = array( '19970902T000006Z', '19970902T000018Z', '19971002T000006Z', ); $tests[] = array( 'BYMONTHDAY' => array(13, 17), 'BYHOUR' => array(6, 18), 'BYSETPOS' => array(3, -3), ); $expect[] = array( '19970913T180000Z', '19970917T060000Z', '19971013T180000Z', ); $tests[] = array( 'BYMONTHDAY' => array(13, 17), 'BYHOUR' => array(6, 18), 'BYSETPOS' => array(3, 3, -3), ); $expect[] = array( '19970913T180000Z', '19970917T060000Z', '19971013T180000Z', ); $tests[] = array( 'BYMONTHDAY' => array(13, 17), 'BYHOUR' => array(6, 18), 'BYSETPOS' => array(4, -1), ); $expect[] = array( '19970917T180000Z', '19971017T180000Z', '19971117T180000Z', ); $this->assertRules( array( 'FREQ' => 'MONTHLY', 'COUNT' => 3, 'DTSTART' => '19970902', ), $tests, $expect); } public function testDailyRecurrenceRules() { $tests = array(); $expect = array(); $tests[] = array(); $expect[] = array( '19970902', '19970903', '19970904', ); $tests[] = array( 'INTERVAL' => 2, ); $expect[] = array( '19970902', '19970904', '19970906', ); $tests[] = array( 'INTERVAL' => 92, ); $expect[] = array( '19970902', '19971203', '19980305', ); $tests[] = array( 'BYMONTH' => array(1, 3), ); $expect[] = array( '19980101', '19980102', '19980103', ); // This is testing that INTERVAL is respected in the presence of a BYMONTH // filter which skips some months. $tests[] = array( 'BYMONTH' => array(12), 'INTERVAL' => 17, ); $expect[] = array( '19971213', '19971230', '19981205', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), ); $expect[] = array( '19970903', '19971001', '19971003', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYMONTHDAY' => array(5, 7), ); $expect[] = array( '19980105', '19980107', '19980305', ); $tests[] = array( 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19970902', '19970904', '19970909', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980106', '19980108', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980203', '19980303', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101', '19980303', '20010301', ); $tests[] = array( 'BYHOUR' => array(6, 18), 'BYMINUTE' => array(15, 45), 'BYSETPOS' => array(3, -3), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T181500Z', '19970903T064500Z', '19970903T181500Z', ); $this->assertRules( array( 'FREQ' => 'DAILY', 'COUNT' => 3, 'DTSTART' => '19970902', ), $tests, $expect); } + public function testHourlyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902T090000Z', + '19970902T100000Z', + '19970902T110000Z', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902T090000Z', + '19970902T110000Z', + '19970902T130000Z', + ); + + $tests[] = array( + 'INTERVAL' => 769, + ); + $expect[] = array( + '19970902T090000Z', + '19971004T100000Z', + '19971105T110000Z', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980101T000000Z', + '19980101T010000Z', + '19980101T020000Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'HOURLY', + 'COUNT' => 3, + 'DTSTART' => '19970902T090000Z', + ), + $tests, + $expect); + } + private function assertRules(array $defaults, array $tests, array $expect) { foreach ($tests as $key => $test) { $options = $test + $defaults; $start = PhutilCalendarAbsoluteDateTime::newFromISO8601( $options['DTSTART']); $rrule = id(new PhutilCalendarRecurrenceRule()) ->setStartDateTime($start) ->setFrequency($options['FREQ']); $interval = idx($options, 'INTERVAL'); if ($interval) { $rrule->setInterval($interval); } $by_day = idx($options, 'BYDAY'); if ($by_day) { $rrule->setByDay($by_day); } $by_month = idx($options, 'BYMONTH'); if ($by_month) { $rrule->setByMonth($by_month); } $by_monthday = idx($options, 'BYMONTHDAY'); if ($by_monthday) { $rrule->setByMonthDay($by_monthday); } $by_yearday = idx($options, 'BYYEARDAY'); if ($by_yearday) { $rrule->setByYearDay($by_yearday); } $by_weekno = idx($options, 'BYWEEKNO'); if ($by_weekno) { $rrule->setByWeekNumber($by_weekno); } $by_hour = idx($options, 'BYHOUR'); if ($by_hour) { $rrule->setByHour($by_hour); } $by_minute = idx($options, 'BYMINUTE'); if ($by_minute) { $rrule->setByMinute($by_minute); } $by_second = idx($options, 'BYSECOND'); if ($by_second) { $rrule->setBySecond($by_second); } $by_setpos = idx($options, 'BYSETPOS'); if ($by_setpos) { $rrule->setBySetPosition($by_setpos); } $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, $options['COUNT']); $this->assertEqual( $expect[$key], mpull($result, 'getISO8601')); } } }