diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -44,6 +44,9 @@ private $initialYear; private $baseYear; private $isAllDay; + private $activeSet = array(); + private $nextSet = array(); + private $minimumEpoch; const FREQUENCY_SECONDLY = 'SECONDLY'; const FREQUENCY_MINUTELY = 'MINUTELY'; @@ -336,6 +339,37 @@ $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->cursorYear--; + $this->cursorMonth += 12; + } + break; + default: + throw new Exception( + pht( + 'BYSETPOS not yet supported for FREQ "%s".', + $frequency)); + } + + $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); + } else { + $this->minimumEpoch = null; + } + + $this->initialMonth = $this->cursorMonth; $this->initialYear = $this->cursorYear; @@ -355,30 +389,92 @@ } 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; - if ($this->isAllDay) { - $this->nextDay(); - } else { - $this->nextSecond(); + $by_setpos = $this->getBySetPosition(); + if ($by_setpos) { + $old_state = $this->getSetPositionState(); } - $result = id(new PhutilCalendarAbsoluteDateTime()) - ->setViewerTimezone($this->getViewerTimezone()) - ->setYear($this->stateYear) - ->setMonth($this->stateMonth) - ->setDay($this->stateDay); + while (!$this->activeSet) { + $this->activeSet = $this->nextSet; + $this->nextSet = array(); - if ($this->isAllDay) { - $result->setIsAllDay(true); - } else { - $result - ->setHour($this->stateHour) - ->setMinute($this->stateMinute) - ->setSecond($this->stateSecond); + 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 $result; + return array_pop($this->activeSet); } @@ -392,7 +488,6 @@ $interval = $this->getInterval(); $is_secondly = ($frequency == self::FREQUENCY_SECONDLY); $by_second = $this->getBySecond(); - $by_setpos = $this->getBySetPosition(); while (!$this->setSeconds) { $this->nextMinute(); @@ -407,10 +502,6 @@ ); } - if ($is_secondly && $by_setpos) { - $seconds = $this->applySetPos($seconds, $by_setpos); - } - $this->setSeconds = array_reverse($seconds); } @@ -428,7 +519,6 @@ $scale = $this->getFrequencyScale(); $is_minutely = ($frequency === self::FREQUENCY_MINUTELY); $by_minute = $this->getByMinute(); - $by_setpos = $this->getBySetPosition(); while (!$this->setMinutes) { $this->nextHour(); @@ -447,10 +537,6 @@ ); } - if ($is_minutely && $by_setpos) { - $minutes = $this->applySetPos($minutes, $by_setpos); - } - $this->setMinutes = array_reverse($minutes); } @@ -468,7 +554,6 @@ $scale = $this->getFrequencyScale(); $is_hourly = ($frequency === self::FREQUENCY_HOURLY); $by_hour = $this->getByHour(); - $by_setpos = $this->getBySetPosition(); while (!$this->setHours) { $this->nextDay(); @@ -487,10 +572,6 @@ ); } - if ($is_hourly && $by_setpos) { - $hours = $this->applySetPos($hours, $by_setpos); - } - $this->setHours = array_reverse($hours); } @@ -513,7 +594,6 @@ $by_monthday = $this->getByMonthDay(); $by_yearday = $this->getByYearDay(); $by_weekno = $this->getByWeekNumber(); - $by_setpos = $this->getBySetPosition(); $week_start = $this->getWeekStart(); while (!$this->setDays) { @@ -553,19 +633,9 @@ } } - // 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); } @@ -584,7 +654,6 @@ $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 @@ -618,10 +687,6 @@ ); } - if ($is_monthly && $by_setpos) { - $months = $this->applySetPos($months, $by_setpos); - } - $this->setMonths = array_reverse($months); } @@ -1097,6 +1162,8 @@ } sort($select); + $select = array_unique($select); + return array_select_keys($values, $select); } @@ -1139,4 +1206,16 @@ } } + private function getSetPositionState() { + $scale = $this->getFrequencyScale(); + + $parts = array(); + $parts[] = $this->stateYear; + if ($scale < self::SCALE_YEARLY) { + $parts[] = $this->stateMonth; + } + + return implode('/', $parts); + } + } diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php --- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php +++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php @@ -419,19 +419,16 @@ '19970902T063010Z', ); - // TODO: This does not pass yet because BYSETPOS is not implemented - // properly for YEARLY rules. - - // $tests[] = array( - // 'BYMONTHDAY' => array(15), - // 'BYHOUR' => array(6, 18), - // 'BYSETPOS' => array(3, -3), - // ); - // $expect[] = array( - // '19971115T180000Z', - // '19980215T060000Z', - // '19981115T180000Z', - // ); + $tests[] = array( + 'BYMONTHDAY' => array(15), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, -3), + ); + $expect[] = array( + '19971115T180000Z', + '19980215T060000Z', + '19981115T180000Z', + ); $this->assertRules( array( @@ -587,18 +584,15 @@ '20010301', ); - // TODO: This test does not pass yet because BYSETPOS is not implemented - // correctly. - - // $tests[] = array( - // 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), - // 'BYSETPOS' => array(-1), - // ); - // $expect[] = array( - // '19970930', - // '19971031', - // '19971128', - // ); + $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'), @@ -647,40 +641,38 @@ '19971002T000006Z', ); - // TODO: These tests rely on BYSETPOS and do not work yet. - - // $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', - // ); + $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(