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))