Page Menu
Home
GnuPG
Search
Configure Global Search
Log In
Files
F34624985
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Size
48 KB
Subscribers
None
View Options
diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
index c2aae5b..504f7d8 100644
--- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
+++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
@@ -1,1801 +1,1820 @@
<?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 $count;
private $until;
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;
public function toDictionary() {
$parts = array();
$parts['FREQ'] = $this->getFrequency();
$interval = $this->getInterval();
if ($interval != 1) {
$parts['INTERVAL'] = $interval;
}
$by_second = $this->getBySecond();
if ($by_second) {
$parts['BYSECOND'] = $by_second;
}
$by_minute = $this->getByMinute();
if ($by_minute) {
$parts['BYMINUTE'] = $by_minute;
}
$by_hour = $this->getByHour();
if ($by_hour) {
$parts['BYHOUR'] = $by_hour;
}
$by_day = $this->getByDay();
if ($by_day) {
$parts['BYDAY'] = $by_day;
}
$by_month = $this->getByMonth();
if ($by_month) {
$parts['BYMONTH'] = $by_month;
}
$by_monthday = $this->getByMonthDay();
if ($by_monthday) {
$parts['BYMONTHDAY'] = $by_monthday;
}
$by_yearday = $this->getByYearDay();
if ($by_yearday) {
$parts['BYYEARDAY'] = $by_yearday;
}
$by_weekno = $this->getByWeekNumber();
if ($by_weekno) {
$parts['BYWEEKNO'] = $by_weekno;
}
$by_setpos = $this->getBySetPosition();
if ($by_setpos) {
$parts['BYSETPOS'] = $by_setpos;
}
$wkst = $this->getWeekStart();
if ($wkst != self::WEEKDAY_MONDAY) {
$parts['WKST'] = $wkst;
}
$count = $this->getCount();
if ($count) {
$parts['COUNT'] = $count;
}
$until = $this->getUntil();
if ($until) {
$parts['UNTIL'] = $until->getISO8601();
}
return $parts;
}
public static function newFromDictionary(array $dict) {
static $expect;
if ($expect === null) {
$expect = array_fuse(
array(
'FREQ',
'INTERVAL',
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYDAY',
'BYMONTH',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYSETPOS',
'WKST',
'UNTIL',
'COUNT',
));
}
foreach ($dict as $key => $value) {
if (empty($expect[$key])) {
throw new Exception(
pht(
'RRULE dictionary includes unknown key "%s". Expected keys '.
'are: %s.',
$key,
implode(', ', array_keys($expect))));
}
}
$rrule = id(new self())
->setFrequency(idx($dict, 'FREQ'))
->setInterval(idx($dict, 'INTERVAL', 1))
->setBySecond(idx($dict, 'BYSECOND', array()))
->setByMinute(idx($dict, 'BYMINUTE', array()))
->setByHour(idx($dict, 'BYHOUR', array()))
->setByDay(idx($dict, 'BYDAY', array()))
->setByMonth(idx($dict, 'BYMONTH', array()))
->setByMonthDay(idx($dict, 'BYMONTHDAY', array()))
->setByYearDay(idx($dict, 'BYYEARDAY', array()))
->setByWeekNumber(idx($dict, 'BYWEEKNO', array()))
->setBySetPosition(idx($dict, 'BYSETPOS', array()))
->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY));
$count = idx($dict, 'COUNT');
if ($count) {
$rrule->setCount($count);
}
$until = idx($dict, 'UNTIL');
if ($until) {
$until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until);
$rrule->setUntil($until);
}
return $rrule;
}
public function toRRULE() {
$dict = $this->toDictionary();
$parts = array();
foreach ($dict as $key => $value) {
if (is_array($value)) {
$value = implode(',', $value);
}
$parts[] = "{$key}={$value}";
}
return implode(';', $parts);
}
public static function newFromRRULE($rrule) {
$parts = explode(';', $rrule);
$dict = array();
foreach ($parts as $part) {
list($key, $value) = explode('=', $part, 2);
switch ($key) {
case 'FREQ':
case 'INTERVAL':
case 'WKST':
case 'COUNT':
case 'UNTIL';
break;
default:
$value = explode(',', $value);
break;
}
$dict[$key] = $value;
}
$int_lists = array_fuse(
array(
// NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE".
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYMONTH',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYSETPOS',
));
+ $int_values = array_fuse(
+ array(
+ 'COUNT',
+ 'INTERVAL',
+ ));
+
foreach ($dict as $key => $value) {
+ if (isset($int_values[$key])) {
+ // None of these values may be negative.
+ if (!preg_match('/^\d+\z/', $value)) {
+ throw new Exception(
+ pht(
+ 'Unexpected value "%s" in "%s" RULE property: expected an '.
+ 'integer.',
+ $value,
+ $key));
+ }
+ $dict[$key] = (int)$value;
+ }
+
if (isset($int_lists[$key])) {
foreach ($value as $k => $v) {
if (!preg_match('/^-?\d+\z/', $v)) {
throw new Exception(
pht(
'Unexpected value "%s" in "%s" RRULE property: expected '.
'only integers.',
$v,
$key));
}
$value[$k] = (int)$v;
}
$dict[$key] = $value;
}
}
return self::newFromDictionary($dict);
}
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 setCount($count) {
if ($count < 1) {
throw new Exception(
pht(
'RRULE COUNT value "%s" is invalid: count must be at least 1.',
$count));
}
$this->count = $count;
return $this;
}
public function getCount() {
return $this->count;
}
public function setUntil(PhutilCalendarDateTime $until) {
$this->until = $until;
return $this;
}
public function getUntil() {
return $this->until;
}
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())
->setTimezone($this->getStartDateTime()->getTimezone())
->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),
$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,
$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;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Tue, Jan 20, 6:36 AM (22 h, 55 m)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
99/b1/eca844660a17fe4eef7f148c4485
Attached To
rPHUTIL libphutil
Event Timeline
Log In to Comment