Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15360820
D16603.id39973.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
20 KB
Referenced Files
None
Subscribers
None
D16603.id39973.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Wed, Mar 12, 10:25 AM (6 d, 21 h ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7395290
Default Alt Text
D16603.id39973.diff (20 KB)
Attached To
Mode
D16603: Validate various RRULE components and simplify logic slightly
Attached
Detach File
Event Timeline
Log In to Comment