diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index f3a281c..61427be 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,1028 +1,1031 @@ 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.', 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 $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)); } } $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 = $by_week_number; + $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; $this->initialMonth = $this->cursorMonth; $this->initialYear = $this->cursorYear; } public function getNextEvent($cursor) { $date = $this->getStartDateTime(); $all_day = $date->getIsAllDay(); if ($all_day) { $this->nextDay(); } else { $this->nextSecond(); } $result = id(new PhutilCalendarAbsoluteDateTime()) ->setViewerTimezone($this->getViewerTimezone()) ->setYear($this->stateYear) ->setMonth($this->stateMonth) ->setDay($this->stateDay); if ($all_day) { $result->setIsAllDay(true); } else { $result ->setHour($this->stateHour) ->setMinute($this->stateMinute) ->setSecond($this->stateSecond); } return $result; } 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(); $by_setpos = $this->getBySetPosition(); while (!$this->setSeconds) { $this->nextMinute(); if ($is_secondly || $by_second) { $seconds = $this->newSecondsSet( ($is_secondly ? $interval : 1), $by_second); } else { $seconds = array( $this->cursorSecond, ); } if ($is_secondly && $by_setpos) { $seconds = $this->applySetPos($seconds, $by_setpos); } $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(); $by_setpos = $this->getBySetPosition(); 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, ); } if ($is_minutely && $by_setpos) { $minutes = $this->applySetPos($minutes, $by_setpos); } $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(); $by_setpos = $this->getBySetPosition(); while (!$this->setHours) { $this->nextDay(); if ($is_hourly || $by_hour) { $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, ); } if ($is_hourly && $by_setpos) { $hours = $this->applySetPos($hours, $by_setpos); } $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_setpos = $this->getBySetPosition(); $week_start = $this->getWeekStart(); while (!$this->setDays) { $this->nextMonth(); if ($is_daily || $by_day || $by_monthday || $by_yearday || $by_weekno) { $weeks = $this->newDaysSet( ($is_daily ? $interval : null), ($is_weekly ? $interval : null), $by_day, $by_monthday, $by_yearday, $by_weekno, $week_start); } else if ($scale < self::SCALE_DAILY) { $weeks = $this->newDaysSet( 1, null, array(), array(), array(), array(), $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), ); } } // Apply weekly BYSETPOS, if one exists. if ($is_weekly && $by_setpos) { $weeks = $this->applySetPos($weeks, $by_setpos); } // Unpack the weeks into days. $days = array_mergev($weeks); // Apply daily BYSETPOS, if one exists. if ($is_daily && $by_setpos) { $days = $this->applySetPos($days, $by_setpos); } $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(); $by_setpos = $this->getBySetPosition(); // 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. + // Likewise, we need to generate all months if we have BYYEARDAY or + // BYWEEKNO. $by_yearday = $this->getByYearDay(); + $by_weekno = $this->getByWeekNumber(); while (!$this->setMonths) { $this->nextYear(); $is_dynamic = $is_monthly || $by_month || $by_monthday || $by_yearday + || $by_weekno || ($scale < self::SCALE_MONTHLY); if ($is_dynamic) { $months = $this->newMonthsSet( ($is_monthly ? $interval : 1), $by_month); } else { $months = array( $this->cursorMonth, ); } if ($is_monthly && $by_setpos) { $months = $this->applySetPos($months, $by_setpos); } $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; } 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; 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) { $year_map = $this->getYearMap($this->stateYear, $week_start); $selection = array(); if ($interval_week) { 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 { $month_idx = $this->stateMonth; if (!$interval_day) { $interval_day = 1; } // If we have a BYDAY, BYMONTHDAY, BYYEARDAY or BYWEEKNO selector and // this isn't the initial month, reset the day cursor to the first of the // month to make sure we examine the entire month. If we don't do this, // we can have a situation where an event occurs "every Monday in // October", but has a start date on the 19th of August, and misses // Mondays in October prior to the 19th. if ($by_day || $by_monthday || $by_yearday || $by_weekno) { if ($this->stateYear !== $this->initialYear || $this->stateMonth !== $this->initialMonth) { $this->cursorDay = 1; } } while (true) { $month_days = $year_map['monthDays'][$month_idx]; if ($this->cursorDay > $month_days) { $this->cursorDay -= $month_days; break; } $day_idx = $this->cursorDay; $key = "{$month_idx}M{$day_idx}D"; $selection[] = $year_map['info'][$key]; $this->cursorDay += $interval_day; } } $weeks = array(); foreach ($selection as $key => $info) { if ($info['month'] != $this->stateMonth) { continue; } if ($by_day) { // TODO: This only handles "BYDAY=MO,TU". It does not yet properly // handle "BYDAY=+1FR" (e.g., the first Friday in the month). if (empty($by_day[$info['weekday']])) { 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(); $weekday_index = self::getWeekdayIndex($week_start); $key = "{$year}/{$week_start}"; if (isset($maps[$key])) { return $maps[$key]; } $map = self::newYearMap($year, $weekday_index); $maps[$key] = $map; return $maps[$key]; } private static function newYearMap($year, $weekday_index) { $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; } - $first_week_size++; } 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); $month_number = 1; $month_day = 1; for ($year_day = 1; $year_day <= $max_day; $year_day++) { $key = "{$month_number}M{$month_day}D"; $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' => $weekday_map[$weekday], ); $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++; } } // 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) { // If this day is part of the "zeroth" week of the year, it does not // get a reverse index. In particular, it is not week "-53" (ethe // 53rd week from the end of the year) in a 52-week year. $week_value = 0; } else { $week_value = -$week_number + $week - 1; } - $info['-week'] = $week_value; + $info_map[$key]['-week'] = $week_value; } 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); 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)); } } } } diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php index 89661cf..b420e2d 100644 --- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php +++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php @@ -1,280 +1,330 @@ 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( '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', + ); + + $this->assertRules( array( 'FREQ' => 'YEARLY', 'COUNT' => 3, 'DTSTART' => '19970902', ), $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); + } + $set = id(new PhutilCalendarRecurrenceSet()) ->addSource($rrule); $result = $set->getEventsBetween(null, null, $options['COUNT']); $this->assertEqual( $expect[$key], mpull($result, 'getISO8601')); } } }