diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index c8d2e08..f94dcba 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,1549 +1,1572 @@ 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() { $frequency = $this->getFrequency(); if ($this->getByMonthDay()) { switch ($frequency) { case self::FREQUENCY_WEEKLY: // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the // FREQ rule part is set to WEEKLY." throw new Exception( pht( 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '. 'violates RFC5545.')); break; default: break; } } if ($this->getByYearDay()) { switch ($frequency) { case self::FREQUENCY_DAILY: case self::FREQUENCY_WEEKLY: case self::FREQUENCY_MONTHLY: // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY." throw new Exception( pht( 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '. 'MONTHLY, which violates RFC5545.')); default: break; } } // TODO // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric // value when the FREQ rule part is not set to MONTHLY or YEARLY." // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO // rule part is specified." $date = $this->getStartDateTime(); $this->cursorSecond = $date->getSecond(); $this->cursorMinute = $date->getMinute(); $this->cursorHour = $date->getHour(); $this->cursorDay = $date->getDay(); $this->cursorMonth = $date->getMonth(); $this->cursorYear = $date->getYear(); $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart()); $key = $this->cursorMonth.'M'.$this->cursorDay.'D'; $this->cursorWeek = $year_map['info'][$key]['week']; $this->cursorWeekday = $year_map['info'][$key]['weekday']; $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->stateWeek = 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()) { $interval = $this->getInterval(); switch ($frequency) { case self::FREQUENCY_YEARLY: $this->cursorYear -= $interval; break; case self::FREQUENCY_MONTHLY: $this->cursorMonth -= $interval; $this->rewindMonth(); break; + case self::FREQUENCY_WEEKLY: + $this->cursorWeek -= $interval; + $this->rewindWeek(); + break; case self::FREQUENCY_DAILY: $this->cursorDay -= $interval; $this->rewindDay(); break; case self::FREQUENCY_HOURLY: $this->cursorHour -= $interval; $this->rewindHour(); break; case self::FREQUENCY_MINUTELY: $this->cursorMinute -= $interval; $this->rewindMinute(); break; case self::FREQUENCY_SECONDLY: default: throw new Exception( pht( 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.', $frequency)); } } // We can generate events from before the cursor when evaluating rules // with BYSETPOS or FREQ=WEEKLY. $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); $cursor_state = array( 'year' => $this->cursorYear, 'month' => $this->cursorMonth, 'week' => $this->cursorWeek, 'day' => $this->cursorDay, 'hour' => $this->cursorHour, ); $this->cursorDayState = $cursor_state; $this->cursorWeekState = $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(); $is_dynamic = $is_hourly || $by_hour || ($scale < self::SCALE_HOURLY); if ($is_dynamic) { $hours = $this->newHoursSet( ($is_hourly ? $interval : 1), $by_hour); } else { $hours = array( $this->cursorHour, ); } $this->setHours = array_reverse($hours); } $this->stateHour = array_pop($this->setHours); } protected function nextDay() { if ($this->setDays) { $info = array_pop($this->setDays); $this->setDayState($info); 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) { if ($is_weekly) { $this->nextWeek(); } else { $this->nextMonth(); } + // NOTE: We normally handle BYMONTH when iterating months, but it acts + // like a filter if FREQ=WEEKLY. + $is_dynamic = $is_daily || $is_weekly || $by_day || $by_monthday || $by_yearday || $by_weekno + || ($by_month && $is_weekly) || ($scale < self::SCALE_DAILY); if ($is_dynamic) { $weeks = $this->newDaysSet( ($is_daily ? $interval : 1), - ($is_weekly ? $interval : 1), $by_day, $by_monthday, $by_yearday, $by_weekno, + $by_month, $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 { $key = $this->stateMonth.'M'.$this->cursorDay.'D'; $weeks = array( array($year_map['info'][$key]), ); } } // Unpack the weeks into days. $days = array_mergev($weeks); $this->setDays = array_reverse($days); } $info = array_pop($this->setDays); $this->setDayState($info); } private function setDayState(array $info) { $this->stateDay = $info['monthday']; $this->stateWeek = $info['week']; $this->stateMonth = $info['month']; } 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 nextWeek() { if ($this->setWeeks) { $this->stateWeek = array_pop($this->setWeeks); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $by_weekno = $this->getByWeekNumber(); while (!$this->setWeeks) { $this->nextYear(); $weeks = $this->newWeeksSet( $interval, $by_weekno); $this->setWeeks = array_reverse($weeks); } $this->stateWeek = array_pop($this->setWeeks); } 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 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 newWeeksSet($interval, $set) { $week_start = $this->getWeekStart(); list($skip, $this->cursorWeekState) = $this->advanceCursorState( $this->cursorWeekState, self::SCALE_WEEKLY, $interval, $week_start); if ($skip) { return array(); } $year_map = $this->getYearMap($this->stateYear, $week_start); $result = array(); while (true) { if (!isset($year_map['weekMap'][$this->cursorWeek])) { break; } $result[] = $this->cursorWeek; $this->cursorWeek += $interval; } $this->cursorWeek -= $year_map['weekCount']; return $result; } private function newDaysSet( $interval_day, - $interval_week, $by_day, $by_monthday, $by_yearday, $by_weekno, + $by_month, $week_start) { $frequency = $this->getFrequency(); $is_yearly = ($frequency == self::FREQUENCY_YEARLY); $is_monthly = ($frequency == self::FREQUENCY_MONTHLY); $is_weekly = ($frequency == self::FREQUENCY_WEEKLY); $selection = array(); if ($is_weekly) { $year_map = $this->getYearMap($this->stateYear, $week_start); if (isset($year_map['weekMap'][$this->stateWeek])) { foreach ($year_map['weekMap'][$this->stateWeek] as $key) { $selection[] = $year_map['info'][$key]; } } } else { // 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. list($skip, $this->cursorDayState) = $this->advanceCursorState( $this->cursorDayState, self::SCALE_DAILY, $interval_day, $week_start); 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; } $day_idx = $this->cursorDay; $key = "{$month_idx}M{$day_idx}D"; $selection[] = $year_map['info'][$key]; $this->cursorDay += $interval_day; } } } // 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; } } // As a special case, BYDAY makes us examine all week days. This doesn't // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY. $filter_weekday = true; if ($is_weekly) { if ($by_day) { $filter_weekday = false; } } $weeks = array(); foreach ($selection as $key => $info) { if ($is_weekly) { if ($filter_weekday) { if ($info['weekday'] != $this->cursorWeekday) { continue; } } } else { 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; } } + if ($by_month) { + if (empty($by_month[$info['month']])) { + continue; + } + } + $weeks[$info['week']][] = $info; } 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; } $week_map = array(); foreach ($info_map as $key => $info) { $week_map[$info['week']][] = $key; } return array( 'info' => $info_map, 'weekCount' => $week_number, 'dayCount' => $max_day, 'monthDays' => $month_days, 'weekMap' => $week_map, ); } 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_WEEKLY) { $parts[] = $this->stateWeek; } else { if ($scale < self::SCALE_YEARLY) { $parts[] = $this->stateMonth; } if ($scale < self::SCALE_MONTHLY) { $parts[] = $this->stateDay; } if ($scale < self::SCALE_DAILY) { $parts[] = $this->stateHour; } if ($scale < self::SCALE_HOURLY) { $parts[] = $this->stateMinute; } } return implode('/', $parts); } private function rewindMonth() { while ($this->cursorMonth < 1) { $this->cursorYear--; $this->cursorMonth += 12; } } + private function rewindWeek() { + $week_start = $this->getWeekStart(); + while ($this->cursorWeek < 1) { + $this->cursorYear--; + $year_map = $this->getYearMap($this->cursorYear, $week_start); + $this->cursorWeek += $year_map['weekCount']; + } + } + private function rewindDay() { $week_start = $this->getWeekStart(); while ($this->cursorDay < 1) { $year_map = $this->getYearMap($this->cursorYear, $week_start); $this->cursorDay += $year_map['monthDays'][$this->cursorMonth]; $this->cursorMonth--; $this->rewindMonth(); } } private function rewindHour() { while ($this->cursorHour < 0) { $this->cursorHour += 24; $this->cursorDay--; $this->rewindDay(); } } private function rewindMinute() { while ($this->cursorMinute < 0) { $this->cursorMinute += 60; $this->cursorHour--; $this->rewindHour(); } } private function advanceCursorState( array $cursor, $scale, $interval, $week_start) { $state = array( 'year' => $this->stateYear, 'month' => $this->stateMonth, 'week' => $this->stateWeek, '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; case self::SCALE_WEEKLY: $this->cursorWeek = 1; 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; case self::SCALE_WEEKLY: $cursor['week'] += $interval; break; } if ($scale <= self::SCALE_HOURLY) { while ($cursor['hour'] >= 24) { $cursor['hour'] -= 24; $cursor['day']++; } } if ($scale == self::SCALE_WEEKLY) { while ($cursor['week'] > $year_map['weekCount']) { $cursor['week'] -= $year_map['weekCount']; $cursor['year']++; $year_map = $this->getYearMap($cursor['year'], $week_start); } } 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; case self::SCALE_WEEKLY: $this->cursorWeek = $cursor['week']; 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 ($scale == self::SCALE_WEEKLY) { 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 e903b33..228a921 100644 --- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php +++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php @@ -1,1662 +1,1750 @@ 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 testWeeklyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902', + '19970909', + '19970916', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902', + '19970916', + '19970930', + ); + + $tests[] = array( + 'INTERVAL' => 20, + ); + $expect[] = array( + '19970902', + '19980120', + '19980609', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980106', + '19980113', + '19980120', + ); + + $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( + 'BYHOUR' => array(6, 18), + ); + $expect[] = array( + '19970902T060000Z', + '19970902T180000Z', + '19970909T060000Z', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, -3), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T180000Z', + '19970904T060000Z', + '19970909T180000Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'WEEKLY', + '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', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), ); $expect[] = array( '19970903T000000Z', '19970903T010000Z', '19970903T020000Z', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYMONTHDAY' => array(5, 7), ); $expect[] = array( '19980105T000000Z', '19980105T010000Z', '19980105T020000Z', ); $tests[] = array( 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19970902T090000Z', '19970902T100000Z', '19970902T110000Z', ); $tests[] = array( 'BYMONTH' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101T000000Z', '19980101T010000Z', '19980101T020000Z', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101T000000Z', '19980101T010000Z', '19980101T020000Z', ); $tests[] = array( 'BYMONTHDAY' => array(1, 3), 'BYMONTH' => array(1, 3), 'BYDAY' => array('TU', 'TH'), ); $expect[] = array( '19980101T000000Z', '19980101T010000Z', '19980101T020000Z', ); $tests[] = array( 'COUNT' => 4, 'BYYEARDAY' => array(1, 100, 200, 365), ); $expect[] = array( '19971231T000000Z', '19971231T010000Z', '19971231T020000Z', '19971231T030000Z', ); $tests[] = array( 'COUNT' => 4, 'BYYEARDAY' => array(-365, -266, -166, -1), ); $expect[] = array( '19971231T000000Z', '19971231T010000Z', '19971231T020000Z', '19971231T030000Z', ); $tests[] = array( 'COUNT' => 4, 'BYMONTH' => array(4, 7), 'BYYEARDAY' => array(1, 100, 200, 365), ); $expect[] = array( '19980410T000000Z', '19980410T010000Z', '19980410T020000Z', '19980410T030000Z', ); $tests[] = array( 'COUNT' => 4, 'BYMONTH' => array(4, 7), 'BYYEARDAY' => array(-365, -266, -166, -1), ); $expect[] = array( '19980410T000000Z', '19980410T010000Z', '19980410T020000Z', '19980410T030000Z', ); $tests[] = array( 'BYHOUR' => array(6, 18), ); $expect[] = array( '19970902T180000Z', '19970903T060000Z', '19970903T180000Z', ); $tests[] = array( 'BYMINUTE' => array(15, 45), 'BYSECOND' => array(15, 45), 'BYSETPOS' => array(3, -3), ); $expect[] = array( '19970902T091545Z', '19970902T094515Z', '19970902T101545Z', ); $this->assertRules( array( 'FREQ' => 'HOURLY', 'COUNT' => 3, 'DTSTART' => '19970902T090000Z', ), $tests, $expect); } public function testMinutelyRecurrenceRules() { $tests = array(); $expect = array(); $tests[] = array( ); $expect[] = array( '19970902T090000Z', '19970902T090100Z', '19970902T090200Z', ); $tests[] = array( 'INTERVAL' => 2, ); $expect[] = array( '19970902T090000Z', '19970902T090200Z', '19970902T090400Z', ); $tests[] = array( 'BYHOUR' => array(6, 18), 'BYMINUTE' => array(6, 18), 'BYSECOND' => array(6, 18), ); $expect[] = array( '19970902T180606Z', '19970902T180618Z', '19970902T181806Z', ); $tests[] = array( 'BYSECOND' => array(15, 30, 45), 'BYSETPOS' => array(3, -3), ); $expect[] = array( '19970902T090015Z', '19970902T090045Z', '19970902T090115Z', ); $this->assertRules( array( 'FREQ' => 'MINUTELY', 'COUNT' => 3, 'DTSTART' => '19970902T090000Z', ), $tests, $expect); } public function testSecondlyRecurrenceRules() { $tests = array(); $expect = array(); $tests[] = array(); $expect[] = array( '19970902T090000Z', '19970902T090001Z', '19970902T090002Z', ); $tests[] = array( 'INTERVAL' => 2, ); $expect[] = array( '19970902T090000Z', '19970902T090002Z', '19970902T090004Z', ); $tests[] = array( 'INTERVAL' => 90061, ); $expect[] = array( '19970902T090000Z', '19970903T100101Z', '19970904T110202Z', ); $tests[] = array( 'BYSECOND' => array(0), 'BYMINUTE' => array(1), 'DTSTART' => '20100322T120100Z', ); $expect[] = array( '20100322T120100Z', '20100322T130100Z', '20100322T140100Z', ); $this->assertRules( array( 'FREQ' => 'SECONDLY', 'COUNT' => 3, 'DTSTART' => '19970902T090000Z', ), $tests, $expect); } public function testRFC5545RecurrenceRules() { // These tests are derived from the examples in RFC5545. $tests = array(); $expect = array(); $tests[] = array( 'FREQ' => 'DAILY', 'COUNT' => 10, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970903T090000Z', '19970904T090000Z', '19970905T090000Z', '19970906T090000Z', '19970907T090000Z', '19970908T090000Z', '19970909T090000Z', '19970910T090000Z', '19970911T090000Z', ); $tests[] = array( 'FREQ' => 'DAILY', 'INTERVAL' => 2, 'DTSTART' => '19970902T090000Z', 'COUNT' => 5, ); $expect[] = array( '19970902T090000Z', '19970904T090000Z', '19970906T090000Z', '19970908T090000Z', '19970910T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'BYMONTH' => array(1), 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'), 'DTSTART' => '19970902T090000Z', 'COUNT' => 3, ); $expect[] = array( '19980101T090000Z', '19980102T090000Z', '19980103T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 3, 'BYDAY' => array('1FR'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970905T090000Z', '19971003T090000Z', '19971107T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'INTERVAL' => 2, 'COUNT' => 5, 'BYDAY' => array('1SU', '-1SU'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970907T090000Z', '19970928T090000Z', '19971102T090000Z', '19971130T090000Z', '19980104T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 6, 'BYDAY' => array('-2MO'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970922T090000Z', '19971020T090000Z', '19971117T090000Z', '19971222T090000Z', '19980119T090000Z', '19980216T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 6, 'BYMONTHDAY' => array(-3), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970928T090000Z', '19971029T090000Z', '19971128T090000Z', '19971229T090000Z', '19980129T090000Z', '19980226T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 5, 'BYMONTHDAY' => array(2, 15), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970915T090000Z', '19971002T090000Z', '19971015T090000Z', '19971102T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 5, 'BYMONTHDAY' => array(-1, 1), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970930T090000Z', '19971001T090000Z', '19971031T090000Z', '19971101T090000Z', '19971130T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 7, 'INTERVAL' => 18, 'BYMONTHDAY' => array(10, 11, 12, 13, 14, 15), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970910T090000Z', '19970911T090000Z', '19970912T090000Z', '19970913T090000Z', '19970914T090000Z', '19970915T090000Z', '19990310T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'COUNT' => 6, 'INTERVAL' => 2, 'BYDAY' => array('TU'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970909T090000Z', '19970916T090000Z', '19970923T090000Z', '19970930T090000Z', '19971104T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'COUNT' => 10, 'BYMONTH' => array(6, 7), 'DTSTART' => '19970610T090000Z', ); $expect[] = array( '19970610T090000Z', '19970710T090000Z', '19980610T090000Z', '19980710T090000Z', '19990610T090000Z', '19990710T090000Z', '20000610T090000Z', '20000710T090000Z', '20010610T090000Z', '20010710T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'COUNT' => 4, 'INTERVAL' => 3, 'BYYEARDAY' => array(1, 100, 200), 'DTSTART' => '19970101T090000Z', ); $expect[] = array( '19970101T090000Z', '19970410T090000Z', '19970719T090000Z', '20000101T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'COUNT' => 3, 'BYDAY' => array('20MO'), 'DTSTART' => '19970519T090000Z', ); $expect[] = array( '19970519T090000Z', '19980518T090000Z', '19990517T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'COUNT' => 3, 'BYWEEKNO' => array(20), 'BYDAY' => array('MO'), 'DTSTART' => '19970512T090000Z', ); $expect[] = array( '19970512T090000Z', '19980511T090000Z', '19990517T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'BYDAY' => array('TH'), 'BYMONTH' => array(3), 'DTSTART' => '19970313T090000Z', 'COUNT' => 5, ); $expect[] = array( '19970313T090000Z', '19970320T090000Z', '19970327T090000Z', '19980305T090000Z', '19980312T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'BYDAY' => array('TH'), 'BYMONTH' => array(6, 7, 8), 'DTSTART' => '19970101T090000Z', 'COUNT' => 15, ); $expect[] = array( '19970605T090000Z', '19970612T090000Z', '19970619T090000Z', '19970626T090000Z', '19970703T090000Z', '19970710T090000Z', '19970717T090000Z', '19970724T090000Z', '19970731T090000Z', '19970807T090000Z', '19970814T090000Z', '19970821T090000Z', '19970828T090000Z', '19980604T090000Z', '19980611T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'BYDAY' => array('FR'), 'BYMONTHDAY' => array(13), 'COUNT' => 4, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19980213T090000Z', '19980313T090000Z', '19981113T090000Z', '19990813T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'BYDAY' => array('SA'), 'BYMONTHDAY' => array(7, 8, 9, 10, 11, 12, 13), 'COUNT' => 10, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970913T090000Z', '19971011T090000Z', '19971108T090000Z', '19971213T090000Z', '19980110T090000Z', '19980207T090000Z', '19980307T090000Z', '19980411T090000Z', '19980509T090000Z', '19980613T090000Z', ); $tests[] = array( 'FREQ' => 'YEARLY', 'INTERVAL' => 4, 'BYMONTH' => array(11), 'BYDAY' => array('TU'), 'BYMONTHDAY' => array(2, 3, 4, 5, 6, 7, 8), 'COUNT' => 6, 'DTSTART' => '19961105T090000Z', ); $expect[] = array( '19961105T090000Z', '20001107T090000Z', '20041102T090000Z', '20081104T090000Z', '20121106T090000Z', '20161108T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'BYDAY' => array('TU', 'WE', 'TH'), 'BYSETPOS' => array(3), 'COUNT' => 3, 'DTSTART' => '19970904T090000Z', ); $expect[] = array( '19970904T090000Z', '19971007T090000Z', '19971106T090000Z', ); $tests[] = array( 'FREQ' => 'MONTHLY', 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), 'BYSETPOS' => array(-2), 'COUNT' => 3, 'DTSTART' => '19970929T090000Z', ); $expect[] = array( '19970929T090000Z', '19971030T090000Z', '19971127T090000Z', ); $tests[] = array( 'FREQ' => 'HOURLY', 'INTERVAL' => 3, 'DTSTART' => '19970929T090000Z', 'COUNT' => 3, ); $expect[] = array( '19970929T090000Z', '19970929T120000Z', '19970929T150000Z', ); $tests[] = array( 'FREQ' => 'MINUTELY', 'INTERVAL' => 15, 'COUNT' => 6, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970902T091500Z', '19970902T093000Z', '19970902T094500Z', '19970902T100000Z', '19970902T101500Z', ); $tests[] = array( 'FREQ' => 'MINUTELY', 'INTERVAL' => 90, 'COUNT' => 4, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970902T103000Z', '19970902T120000Z', '19970902T133000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'COUNT' => 10, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970909T090000Z', '19970916T090000Z', '19970923T090000Z', '19970930T090000Z', '19971007T090000Z', '19971014T090000Z', '19971021T090000Z', '19971028T090000Z', '19971104T090000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'INTERVAL' => 2, 'COUNT' => 6, 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970916T090000Z', '19970930T090000Z', '19971014T090000Z', '19971028T090000Z', '19971111T090000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'COUNT' => 10, 'WKST' => 'SU', 'BYDAY' => array('TU', 'TH'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970904T090000Z', '19970909T090000Z', '19970911T090000Z', '19970916T090000Z', '19970918T090000Z', '19970923T090000Z', '19970925T090000Z', '19970930T090000Z', '19971002T090000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'INTERVAL' => 2, 'COUNT' => 8, 'WKST' => 'SU', 'BYDAY' => array('TU', 'TH'), 'DTSTART' => '19970902T090000Z', ); $expect[] = array( '19970902T090000Z', '19970904T090000Z', '19970916T090000Z', '19970918T090000Z', '19970930T090000Z', '19971002T090000Z', '19971014T090000Z', '19971016T090000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'INTERVAL' => 2, 'COUNT' => 4, 'BYDAY' => array('TU', 'SU'), 'WKST' => 'MO', 'DTSTART' => '19970805T090000Z', ); $expect[] = array( '19970805T090000Z', '19970810T090000Z', '19970819T090000Z', '19970824T090000Z', ); $tests[] = array( 'FREQ' => 'WEEKLY', 'INTERVAL' => 2, 'COUNT' => 4, 'BYDAY' => array('TU', 'SU'), 'WKST' => 'SU', 'DTSTART' => '19970805T090000Z', ); $expect[] = array( '19970805T090000Z', '19970817T090000Z', '19970819T090000Z', '19970831T090000Z', ); $this->assertRules(array(), $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); } $week_start = idx($options, 'WKST'); if ($week_start) { $rrule->setWeekStart($week_start); } $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, $options['COUNT']); $this->assertEqual( $expect[$key], mpull($result, 'getISO8601')); } } }