Page MenuHomePhabricator

D16603.diff
No OneTemporary

D16603.diff

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
@@ -5,19 +5,18 @@
private $startDateTime;
private $frequency;
- private $until;
- private $count;
+ private $frequencyScale;
private $interval = 1;
- private $bySecond;
- private $byMinute;
- private $byHour;
- private $byDay;
- private $byMonthDay;
- private $byYearDay;
- private $byWeekNumber;
- private $byMonth;
- private $bySetPosition;
- private $weekStart = 'MO';
+ 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;
@@ -41,7 +40,69 @@
private $stateMonth;
private $stateYear;
- private $maps = array();
+ 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.',
+ implode(', ', $constants)));
+ }
+
+ return $map[$weekday];
+ }
public function setStartDateTime(PhutilCalendarDateTime $start) {
$this->startDateTime = $start;
@@ -53,7 +114,27 @@
}
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 FREQUENCY "%s" is invalid. Valid frequencies are: %s.',
+ $frequency,
+ implode(', ', array_keys($map))));
+ }
+
$this->frequency = $frequency;
+ $this->frequencyScale = $map[$frequency];
+
return $this;
}
@@ -61,25 +142,25 @@
return $this->frequency;
}
- public function setUntil(PhutilCalendarDateTime $until) {
- $this->until = $until;
- return $this;
- }
-
- public function getUntil() {
- return $this->until;
+ public function getFrequencyScale() {
+ return $this->frequencyScale;
}
- public function setCount($count) {
- $this->count = $count;
- return $this;
- }
+ public function setInterval($interval) {
+ if (!is_int($interval)) {
+ throw new Exception(
+ pht(
+ 'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
+ $interval));
+ }
- public function getCount() {
- return $this->count;
- }
+ if ($interval < 1) {
+ throw new Exception(
+ pht(
+ 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
+ $interval));
+ }
- public function setInterval($interval) {
$this->interval = $interval;
return $this;
}
@@ -88,8 +169,9 @@
return $this->interval;
}
- public function setBySecond($by_second) {
- $this->bySecond = $by_second;
+ public function setBySecond(array $by_second) {
+ $this->assertByRange('BYSECOND', $by_second, 0, 60);
+ $this->bySecond = array_fuse($by_second);
return $this;
}
@@ -97,8 +179,9 @@
return $this->bySecond;
}
- public function setByMinute($by_minute) {
- $this->byMinute = $by_minute;
+ public function setByMinute(array $by_minute) {
+ $this->assertByRange('BYMINUTE', $by_minute, 0, 59);
+ $this->byMinute = array_fuse($by_minute);
return $this;
}
@@ -106,8 +189,9 @@
return $this->byMinute;
}
- public function setByHour($by_hour) {
- $this->byHour = $by_hour;
+ public function setByHour(array $by_hour) {
+ $this->assertByRange('BYHOUR', $by_hour, 0, 23);
+ $this->byHour = array_fuse($by_hour);
return $this;
}
@@ -115,8 +199,38 @@
return $this->byHour;
}
- public function setByDay($by_day) {
- $this->byDay = $by_day;
+ 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;
}
@@ -124,8 +238,9 @@
return $this->byDay;
}
- public function setByMonthDay($by_month_day) {
- $this->byMonthDay = $by_month_day;
+ 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;
}
@@ -134,7 +249,8 @@
}
public function setByYearDay($by_year_day) {
- $this->byYearDay = $by_year_day;
+ $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);
+ $this->byYearDay = array_fuse($by_year_day);
return $this;
}
@@ -142,8 +258,9 @@
return $this->byYearDay;
}
- public function setByMonth($by_month) {
- $this->byMonth = $by_month;
+ public function setByMonth(array $by_month) {
+ $this->assertByRange('BYMONTH', $by_month, 1, 12);
+ $this->byMonth = array_fuse($by_month);
return $this;
}
@@ -151,7 +268,8 @@
return $this->byMonth;
}
- public function setByWeekNumber($by_week_number) {
+ public function setByWeekNumber(array $by_week_number) {
+ $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);
$this->byWeekNumber = $by_week_number;
return $this;
}
@@ -160,7 +278,8 @@
return $this->byWeekNumber;
}
- public function setBySetPosition($by_set_position) {
+ public function setBySetPosition(array $by_set_position) {
+ $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);
$this->bySetPosition = $by_set_position;
return $this;
}
@@ -170,6 +289,9 @@
}
public function setWeekStart($week_start) {
+ // Make sure this is a valid weekday constant.
+ self::getWeekdayIndex($week_start);
+
$this->weekStart = $week_start;
return $this;
}
@@ -178,7 +300,6 @@
return $this->weekStart;
}
-
public function resetSource() {
$date = $this->getStartDateTime();
@@ -244,7 +365,7 @@
$frequency = $this->getFrequency();
$interval = $this->getInterval();
- $is_secondly = ($frequency == 'SECONDLY');
+ $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
$by_second = $this->getBySecond();
$by_setpos = $this->getBySetPosition();
@@ -279,8 +400,8 @@
$frequency = $this->getFrequency();
$interval = $this->getInterval();
- $is_secondly = ($frequency === 'SECONDLY');
- $is_minutely = ($frequency === 'MINUTELY');
+ $scale = $this->getFrequencyScale();
+ $is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
$by_minute = $this->getByMinute();
$by_setpos = $this->getBySetPosition();
@@ -291,7 +412,7 @@
$minutes = $this->newMinutesSet(
($is_minutely ? $interval : 1),
$by_minute);
- } else if ($is_secondly) {
+ } else if ($scale < self::SCALE_MINUTELY) {
$minutes = $this->newMinutesSet(
1,
array());
@@ -319,20 +440,19 @@
$frequency = $this->getFrequency();
$interval = $this->getInterval();
- $is_secondly = ($frequency === 'SECONDLY');
- $is_minutely = ($frequency === 'MINUTELY');
- $is_hourly = ($frequency === 'HOURLY');
+ $scale = $this->getFrequencyScale();
+ $is_hourly = ($frequency === self::FREQUENCY_HOURLY);
$by_hour = $this->getByHour();
$by_setpos = $this->getBySetPosition();
while (!$this->setHours) {
$this->nextDay();
- if ($is_minutely || $by_hour) {
+ if ($is_hourly || $by_hour) {
$hours = $this->newHoursSet(
($is_hourly ? $interval : 1),
$by_hour);
- } else if ($is_secondly || $is_minutely) {
+ } else if ($scale < self::SCALE_HOURLY) {
$hours = $this->newHoursSet(
1,
array());
@@ -360,11 +480,9 @@
$frequency = $this->getFrequency();
$interval = $this->getInterval();
- $is_secondly = ($frequency === 'SECONDLY');
- $is_minutely = ($frequency === 'MINUTELY');
- $is_hourly = ($frequency === 'HOURLY');
- $is_daily = ($frequency === 'DAILY');
- $is_weekly = ($frequency === 'WEEKLY');
+ $scale = $this->getFrequencyScale();
+ $is_daily = ($frequency === self::FREQUENCY_DAILY);
+ $is_weekly = ($frequency === self::FREQUENCY_WEEKLY);
$by_day = $this->getByDay();
$by_monthday = $this->getByMonthDay();
@@ -384,8 +502,7 @@
$by_yearday,
$by_weekno,
$this->getWeekStart());
- } else if ($is_secondly || $is_minutely || $is_hourly) {
- $all_values = true;
+ } else if ($scale < self::SCALE_DAILY) {
$weeks = $this->newDaysSet(
1,
null,
@@ -427,12 +544,8 @@
$frequency = $this->getFrequency();
$interval = $this->getInterval();
- $is_secondly = ($frequency === 'SECONDLY');
- $is_minutely = ($frequency === 'MINUTELY');
- $is_hourly = ($frequency === 'HOURLY');
- $is_daily = ($frequency === 'DAILY');
- $is_weekly = ($frequency === 'WEEKLY');
- $is_monthly = ($frequency === 'MONTHLY');
+ $scale = $this->getFrequencyScale();
+ $is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
$by_month = $this->getByMonth();
$by_setpos = $this->getBySetPosition();
@@ -444,9 +557,7 @@
$months = $this->newMonthsSet(
($is_monthly ? $interval : 1),
$by_month);
- } else if (
- $is_secondly || $is_minutely || $is_hourly ||
- $is_daily || $is_weekly) {
+ } else if ($scale < self::SCALE_MONTHLY) {
$months = $this->newMonthsSet(
1,
array());
@@ -470,7 +581,7 @@
$this->stateYear = $this->cursorYear;
$frequency = $this->getFrequency();
- $is_yearly = ($frequency === 'YEARLY');
+ $is_yearly = ($frequency === self::FREQUENCY_YEARLY);
if ($is_yearly) {
$interval = $this->getInterval();
@@ -482,7 +593,7 @@
}
private function newSecondsSet($interval, $set) {
- // TODO: This doesn't account for leap sections. In theory, it probably
+ // 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;
@@ -599,22 +710,6 @@
}
}
- if ($by_day) {
- $by_day = array_fuse($by_day);
- }
-
- if ($by_monthday) {
- $by_monthday = array_fuse($by_monthday);
- }
-
- if ($by_yearday) {
- $by_yearday = array_fuse($by_yearday);
- }
-
- if ($by_weekno) {
- $by_weekno = array_fuse($by_weekno);
- }
-
$weeks = array();
foreach ($selection as $key => $info) {
if ($info['month'] != $this->cursorMonth) {
@@ -622,7 +717,12 @@
}
if ($by_day) {
- // TODO: Implement weekday stuff.
+ // 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) {
@@ -640,7 +740,10 @@
}
if ($by_weekno) {
- // TODO: Implement week number stuff.
+ if (empty($by_weekno[$info['week']]) &&
+ empty($by_weekno[$info['-week']])) {
+ continue;
+ }
}
$weeks[$info['week']][] = $info['monthday'];
@@ -669,19 +772,23 @@
return $result;
}
- private function getYearMap($year, $week_start) {
+ public static function getYearMap($year, $week_start) {
+ static $maps = array();
+
+ $weekday_index = self::getWeekdayIndex($week_start);
+
$key = "{$year}/{$week_start}";
- if (isset($this->maps[$key])) {
- return $this->maps[$key];
+ if (isset($maps[$key])) {
+ return $maps[$key];
}
- $map = self::newYearMap($year, $week_start);
- $this->maps[$key] = $map;
+ $map = self::newYearMap($year, $weekday_index);
+ $maps[$key] = $map;
- return $this->maps[$key];
+ return $maps[$key];
}
- public static function newYearMap($year, $week_start) {
+ private static function newYearMap($year, $weekday_index) {
$is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||
($year % 400 === 0);
@@ -691,8 +798,6 @@
$datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
$weekday = $datetime->format('w');
- // TODO: Week 1 must contain at least 4 days!
-
if ($is_leap) {
$max_day = 366;
} else {
@@ -714,16 +819,38 @@
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;
+ if ($first_weekday === $weekday_index) {
+ break;
+ }
+ $first_week_size++;
+ }
+
+ if ($first_week_size >= 4) {
+ $week_number = 1;
+ } else {
+ $week_number = 0;
+ }
+
$info_map = array();
$calendar_map = array();
$week_map = array();
$yearday_map = array();
+ $weekday_map = self::getWeekdayIndexMap();
+ $weekday_map = array_flip($weekday_map);
+
$month_number = 1;
$month_day = 1;
- $week_number = 1;
for ($year_day = 1; $year_day <= $max_day; $year_day++) {
- $key = $month_number.'/'.$month_day;
+ $key = "{$month_number}M{$month_day}D";
$info = array(
'key' => $key,
@@ -733,6 +860,7 @@
'yearday' => $year_day,
'-yearday' => -$max_day + $year_day - 1,
'week' => $week_number,
+ 'weekday' => $weekday_map[$weekday],
);
$info_map[$key] = $info;
@@ -741,7 +869,7 @@
$yearday_map[$year_day] = $info;
$weekday = ($weekday + 1) % 7;
- if ($weekday === $week_start) {
+ if ($weekday === $weekday_index) {
$week_number++;
}
@@ -752,6 +880,23 @@
}
}
+ // 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;
+ }
+
return array(
'info' => $info_map,
'calendar' => $calendar_map,
@@ -771,12 +916,6 @@
$interval));
}
- if ($set) {
- $set = array_fuse($set);
- } else {
- $set = array();
- }
-
$result = array();
$seen = array();
@@ -815,5 +954,43 @@
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
--- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
+++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
@@ -7,7 +7,7 @@
$rrule = id(new PhutilCalendarRecurrenceRule())
->setStartDateTime($start)
- ->setFrequency('DAILY');
+ ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($rrule);
@@ -29,7 +29,7 @@
$rrule = id(new PhutilCalendarRecurrenceRule())
->setStartDateTime($start)
- ->setFrequency('HOURLY')
+ ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY)
->setByHour(array(12, 13));
$set = id(new PhutilCalendarRecurrenceSet())
@@ -53,7 +53,7 @@
$rrule = id(new PhutilCalendarRecurrenceRule())
->setStartDateTime($start)
- ->setFrequency('YEARLY');
+ ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($rrule);
@@ -78,7 +78,7 @@
$rrule = id(new PhutilCalendarRecurrenceRule())
->setStartDateTime($start)
- ->setFrequency('SECONDLY')
+ ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY)
->setByMonth(array(1))
->setByMonthDay(array(1))
->setByHour(array(12))

File Metadata

Mime Type
text/plain
Expires
Wed, May 8, 8:23 PM (1 w, 4 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6272837
Default Alt Text
D16603.diff (20 KB)

Event Timeline