Page MenuHomec4science

PhutilCalendarRecurrenceRule.php
No OneTemporary

File Metadata

Created
Sun, Dec 22, 08:02

PhutilCalendarRecurrenceRule.php

<?php
final class PhutilCalendarRecurrenceRule
extends PhutilCalendarRecurrenceSource {
private $startDateTime;
private $frequency;
private $frequencyScale;
private $interval = 1;
private $bySecond = array();
private $byMinute = array();
private $byHour = array();
private $byDay = array();
private $byMonthDay = array();
private $byYearDay = array();
private $byWeekNumber = array();
private $byMonth = array();
private $bySetPosition = array();
private $weekStart = self::WEEKDAY_MONDAY;
private $cursorSecond;
private $cursorMinute;
private $cursorHour;
private $cursorHourState;
private $cursorWeek;
private $cursorWeekday;
private $cursorWeekState;
private $cursorDay;
private $cursorDayState;
private $cursorMonth;
private $cursorYear;
private $setSeconds;
private $setMinutes;
private $setHours;
private $setDays;
private $setMonths;
private $setWeeks;
private $setYears;
private $stateSecond;
private $stateMinute;
private $stateHour;
private $stateDay;
private $stateWeek;
private $stateMonth;
private $stateYear;
private $baseYear;
private $isAllDay;
private $activeSet = array();
private $nextSet = array();
private $minimumEpoch;
const FREQUENCY_SECONDLY = 'SECONDLY';
const FREQUENCY_MINUTELY = 'MINUTELY';
const FREQUENCY_HOURLY = 'HOURLY';
const FREQUENCY_DAILY = 'DAILY';
const FREQUENCY_WEEKLY = 'WEEKLY';
const FREQUENCY_MONTHLY = 'MONTHLY';
const FREQUENCY_YEARLY = 'YEARLY';
const SCALE_SECONDLY = 1;
const SCALE_MINUTELY = 2;
const SCALE_HOURLY = 3;
const SCALE_DAILY = 4;
const SCALE_WEEKLY = 5;
const SCALE_MONTHLY = 6;
const SCALE_YEARLY = 7;
const WEEKDAY_SUNDAY = 'SU';
const WEEKDAY_MONDAY = 'MO';
const WEEKDAY_TUESDAY = 'TU';
const WEEKDAY_WEDNESDAY = 'WE';
const WEEKDAY_THURSDAY = 'TH';
const WEEKDAY_FRIDAY = 'FR';
const WEEKDAY_SATURDAY = 'SA';
const WEEKINDEX_SUNDAY = 0;
const WEEKINDEX_MONDAY = 1;
const WEEKINDEX_TUESDAY = 2;
const WEEKINDEX_WEDNESDAY = 3;
const WEEKINDEX_THURSDAY = 4;
const WEEKINDEX_FRIDAY = 5;
const WEEKINDEX_SATURDAY = 6;
private static function getAllWeekdayConstants() {
return array_keys(self::getWeekdayIndexMap());
}
private static function getWeekdayIndexMap() {
static $map = array(
self::WEEKDAY_SUNDAY => 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_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();
}
$is_dynamic = $is_daily
|| $is_weekly
|| $by_day
|| $by_monthday
|| $by_yearday
|| $by_weekno
|| ($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,
$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,
$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;
}
}
$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 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;
}
}

Event Timeline