Path: blob/master/src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php
12262 views
<?php12final class PhutilCalendarRecurrenceRule3extends PhutilCalendarRecurrenceSource {45private $startDateTime;6private $frequency;7private $frequencyScale;8private $interval = 1;9private $bySecond = array();10private $byMinute = array();11private $byHour = array();12private $byDay = array();13private $byMonthDay = array();14private $byYearDay = array();15private $byWeekNumber = array();16private $byMonth = array();17private $bySetPosition = array();18private $weekStart = self::WEEKDAY_MONDAY;19private $count;20private $until;2122private $cursorSecond;23private $cursorMinute;24private $cursorHour;25private $cursorHourState;26private $cursorWeek;27private $cursorWeekday;28private $cursorWeekState;29private $cursorDay;30private $cursorDayState;31private $cursorMonth;32private $cursorYear;3334private $setSeconds;35private $setMinutes;36private $setHours;37private $setDays;38private $setMonths;39private $setWeeks;40private $setYears;4142private $stateSecond;43private $stateMinute;44private $stateHour;45private $stateDay;46private $stateWeek;47private $stateMonth;48private $stateYear;4950private $baseYear;51private $isAllDay;52private $activeSet = array();53private $nextSet = array();54private $minimumEpoch;5556const FREQUENCY_SECONDLY = 'SECONDLY';57const FREQUENCY_MINUTELY = 'MINUTELY';58const FREQUENCY_HOURLY = 'HOURLY';59const FREQUENCY_DAILY = 'DAILY';60const FREQUENCY_WEEKLY = 'WEEKLY';61const FREQUENCY_MONTHLY = 'MONTHLY';62const FREQUENCY_YEARLY = 'YEARLY';6364const SCALE_SECONDLY = 1;65const SCALE_MINUTELY = 2;66const SCALE_HOURLY = 3;67const SCALE_DAILY = 4;68const SCALE_WEEKLY = 5;69const SCALE_MONTHLY = 6;70const SCALE_YEARLY = 7;7172const WEEKDAY_SUNDAY = 'SU';73const WEEKDAY_MONDAY = 'MO';74const WEEKDAY_TUESDAY = 'TU';75const WEEKDAY_WEDNESDAY = 'WE';76const WEEKDAY_THURSDAY = 'TH';77const WEEKDAY_FRIDAY = 'FR';78const WEEKDAY_SATURDAY = 'SA';7980const WEEKINDEX_SUNDAY = 0;81const WEEKINDEX_MONDAY = 1;82const WEEKINDEX_TUESDAY = 2;83const WEEKINDEX_WEDNESDAY = 3;84const WEEKINDEX_THURSDAY = 4;85const WEEKINDEX_FRIDAY = 5;86const WEEKINDEX_SATURDAY = 6;8788public function toDictionary() {89$parts = array();9091$parts['FREQ'] = $this->getFrequency();9293$interval = $this->getInterval();94if ($interval != 1) {95$parts['INTERVAL'] = $interval;96}9798$by_second = $this->getBySecond();99if ($by_second) {100$parts['BYSECOND'] = $by_second;101}102103$by_minute = $this->getByMinute();104if ($by_minute) {105$parts['BYMINUTE'] = $by_minute;106}107108$by_hour = $this->getByHour();109if ($by_hour) {110$parts['BYHOUR'] = $by_hour;111}112113$by_day = $this->getByDay();114if ($by_day) {115$parts['BYDAY'] = $by_day;116}117118$by_month = $this->getByMonth();119if ($by_month) {120$parts['BYMONTH'] = $by_month;121}122123$by_monthday = $this->getByMonthDay();124if ($by_monthday) {125$parts['BYMONTHDAY'] = $by_monthday;126}127128$by_yearday = $this->getByYearDay();129if ($by_yearday) {130$parts['BYYEARDAY'] = $by_yearday;131}132133$by_weekno = $this->getByWeekNumber();134if ($by_weekno) {135$parts['BYWEEKNO'] = $by_weekno;136}137138$by_setpos = $this->getBySetPosition();139if ($by_setpos) {140$parts['BYSETPOS'] = $by_setpos;141}142143$wkst = $this->getWeekStart();144if ($wkst != self::WEEKDAY_MONDAY) {145$parts['WKST'] = $wkst;146}147148$count = $this->getCount();149if ($count) {150$parts['COUNT'] = $count;151}152153$until = $this->getUntil();154if ($until) {155$parts['UNTIL'] = $until->getISO8601();156}157158return $parts;159}160161public static function newFromDictionary(array $dict) {162static $expect;163if ($expect === null) {164$expect = array_fuse(165array(166'FREQ',167'INTERVAL',168'BYSECOND',169'BYMINUTE',170'BYHOUR',171'BYDAY',172'BYMONTH',173'BYMONTHDAY',174'BYYEARDAY',175'BYWEEKNO',176'BYSETPOS',177'WKST',178'UNTIL',179'COUNT',180));181}182183foreach ($dict as $key => $value) {184if (empty($expect[$key])) {185throw new Exception(186pht(187'RRULE dictionary includes unknown key "%s". Expected keys '.188'are: %s.',189$key,190implode(', ', array_keys($expect))));191}192}193194$rrule = id(new self())195->setFrequency(idx($dict, 'FREQ'))196->setInterval(idx($dict, 'INTERVAL', 1))197->setBySecond(idx($dict, 'BYSECOND', array()))198->setByMinute(idx($dict, 'BYMINUTE', array()))199->setByHour(idx($dict, 'BYHOUR', array()))200->setByDay(idx($dict, 'BYDAY', array()))201->setByMonth(idx($dict, 'BYMONTH', array()))202->setByMonthDay(idx($dict, 'BYMONTHDAY', array()))203->setByYearDay(idx($dict, 'BYYEARDAY', array()))204->setByWeekNumber(idx($dict, 'BYWEEKNO', array()))205->setBySetPosition(idx($dict, 'BYSETPOS', array()))206->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY));207208$count = idx($dict, 'COUNT');209if ($count) {210$rrule->setCount($count);211}212213$until = idx($dict, 'UNTIL');214if ($until) {215$until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until);216$rrule->setUntil($until);217}218219return $rrule;220}221222public function toRRULE() {223$dict = $this->toDictionary();224225$parts = array();226foreach ($dict as $key => $value) {227if (is_array($value)) {228$value = implode(',', $value);229}230$parts[] = "{$key}={$value}";231}232233return implode(';', $parts);234}235236public static function newFromRRULE($rrule) {237$parts = explode(';', $rrule);238239$dict = array();240foreach ($parts as $part) {241list($key, $value) = explode('=', $part, 2);242switch ($key) {243case 'FREQ':244case 'INTERVAL':245case 'WKST':246case 'COUNT':247case 'UNTIL';248break;249default:250$value = explode(',', $value);251break;252}253$dict[$key] = $value;254}255256$int_lists = array_fuse(257array(258// NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE".259'BYSECOND',260'BYMINUTE',261'BYHOUR',262'BYMONTH',263'BYMONTHDAY',264'BYYEARDAY',265'BYWEEKNO',266'BYSETPOS',267));268269$int_values = array_fuse(270array(271'COUNT',272'INTERVAL',273));274275foreach ($dict as $key => $value) {276if (isset($int_values[$key])) {277// None of these values may be negative.278if (!preg_match('/^\d+\z/', $value)) {279throw new Exception(280pht(281'Unexpected value "%s" in "%s" RULE property: expected an '.282'integer.',283$value,284$key));285}286$dict[$key] = (int)$value;287}288289if (isset($int_lists[$key])) {290foreach ($value as $k => $v) {291if (!preg_match('/^-?\d+\z/', $v)) {292throw new Exception(293pht(294'Unexpected value "%s" in "%s" RRULE property: expected '.295'only integers.',296$v,297$key));298}299$value[$k] = (int)$v;300}301$dict[$key] = $value;302}303}304305return self::newFromDictionary($dict);306}307308private static function getAllWeekdayConstants() {309return array_keys(self::getWeekdayIndexMap());310}311312private static function getWeekdayIndexMap() {313static $map = array(314self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY,315self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY,316self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY,317self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY,318self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY,319self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY,320self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY,321);322323return $map;324}325326private static function getWeekdayIndex($weekday) {327$map = self::getWeekdayIndexMap();328if (!isset($map[$weekday])) {329$constants = array_keys($map);330throw new Exception(331pht(332'Weekday "%s" is not a valid weekday constant. Valid constants '.333'are: %s.',334$weekday,335implode(', ', $constants)));336}337338return $map[$weekday];339}340341public function setStartDateTime(PhutilCalendarDateTime $start) {342$this->startDateTime = $start;343return $this;344}345346public function getStartDateTime() {347return $this->startDateTime;348}349350public function setCount($count) {351if ($count < 1) {352throw new Exception(353pht(354'RRULE COUNT value "%s" is invalid: count must be at least 1.',355$count));356}357358$this->count = $count;359return $this;360}361362public function getCount() {363return $this->count;364}365366public function setUntil(PhutilCalendarDateTime $until) {367$this->until = $until;368return $this;369}370371public function getUntil() {372return $this->until;373}374375public function setFrequency($frequency) {376static $map = array(377self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY,378self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY,379self::FREQUENCY_HOURLY => self::SCALE_HOURLY,380self::FREQUENCY_DAILY => self::SCALE_DAILY,381self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY,382self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY,383self::FREQUENCY_YEARLY => self::SCALE_YEARLY,384);385386if (empty($map[$frequency])) {387throw new Exception(388pht(389'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.',390$frequency,391implode(', ', array_keys($map))));392}393394$this->frequency = $frequency;395$this->frequencyScale = $map[$frequency];396397return $this;398}399400public function getFrequency() {401return $this->frequency;402}403404public function getFrequencyScale() {405return $this->frequencyScale;406}407408public function setInterval($interval) {409if (!is_int($interval)) {410throw new Exception(411pht(412'RRULE INTERVAL "%s" is invalid: interval must be an integer.',413$interval));414}415416if ($interval < 1) {417throw new Exception(418pht(419'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',420$interval));421}422423$this->interval = $interval;424return $this;425}426427public function getInterval() {428return $this->interval;429}430431public function setBySecond(array $by_second) {432$this->assertByRange('BYSECOND', $by_second, 0, 60);433$this->bySecond = array_fuse($by_second);434return $this;435}436437public function getBySecond() {438return $this->bySecond;439}440441public function setByMinute(array $by_minute) {442$this->assertByRange('BYMINUTE', $by_minute, 0, 59);443$this->byMinute = array_fuse($by_minute);444return $this;445}446447public function getByMinute() {448return $this->byMinute;449}450451public function setByHour(array $by_hour) {452$this->assertByRange('BYHOUR', $by_hour, 0, 23);453$this->byHour = array_fuse($by_hour);454return $this;455}456457public function getByHour() {458return $this->byHour;459}460461public function setByDay(array $by_day) {462$constants = self::getAllWeekdayConstants();463$constants = implode('|', $constants);464465$pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/';466foreach ($by_day as $key => $value) {467$matches = null;468if (!preg_match($pattern, $value, $matches)) {469throw new Exception(470pht(471'RRULE BYDAY value "%s" is invalid: rule part must be in the '.472'expected form (like "MO", "-3TH", or "+2SU").',473$value));474}475476// The maximum allowed value is 53, which corresponds to "the 53rd477// Monday every year" or similar when evaluated against a YEARLY rule.478479$maximum = 53;480$magnitude = (int)$matches[1];481if ($magnitude > $maximum) {482throw new Exception(483pht(484'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '.485'the maximum permitted value is "%s".',486$value,487$magnitude,488$maximum));489}490491// Normalize "+3FR" into "3FR".492$by_day[$key] = ltrim($value, '+');493}494495$this->byDay = array_fuse($by_day);496return $this;497}498499public function getByDay() {500return $this->byDay;501}502503public function setByMonthDay(array $by_month_day) {504$this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false);505$this->byMonthDay = array_fuse($by_month_day);506return $this;507}508509public function getByMonthDay() {510return $this->byMonthDay;511}512513public function setByYearDay($by_year_day) {514$this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);515$this->byYearDay = array_fuse($by_year_day);516return $this;517}518519public function getByYearDay() {520return $this->byYearDay;521}522523public function setByMonth(array $by_month) {524$this->assertByRange('BYMONTH', $by_month, 1, 12);525$this->byMonth = array_fuse($by_month);526return $this;527}528529public function getByMonth() {530return $this->byMonth;531}532533public function setByWeekNumber(array $by_week_number) {534$this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);535$this->byWeekNumber = array_fuse($by_week_number);536return $this;537}538539public function getByWeekNumber() {540return $this->byWeekNumber;541}542543public function setBySetPosition(array $by_set_position) {544$this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);545$this->bySetPosition = $by_set_position;546return $this;547}548549public function getBySetPosition() {550return $this->bySetPosition;551}552553public function setWeekStart($week_start) {554// Make sure this is a valid weekday constant.555self::getWeekdayIndex($week_start);556557$this->weekStart = $week_start;558return $this;559}560561public function getWeekStart() {562return $this->weekStart;563}564565public function resetSource() {566$frequency = $this->getFrequency();567568if ($this->getByMonthDay()) {569switch ($frequency) {570case self::FREQUENCY_WEEKLY:571// RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the572// FREQ rule part is set to WEEKLY."573throw new Exception(574pht(575'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '.576'violates RFC5545.'));577break;578default:579break;580}581582}583584if ($this->getByYearDay()) {585switch ($frequency) {586case self::FREQUENCY_DAILY:587case self::FREQUENCY_WEEKLY:588case self::FREQUENCY_MONTHLY:589// RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the590// FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."591throw new Exception(592pht(593'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.594'MONTHLY, which violates RFC5545.'));595default:596break;597}598}599600// TODO601// RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric602// value when the FREQ rule part is not set to MONTHLY or YEARLY."603// RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a604// numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO605// rule part is specified."606607608$date = $this->getStartDateTime();609610$this->cursorSecond = $date->getSecond();611$this->cursorMinute = $date->getMinute();612$this->cursorHour = $date->getHour();613614$this->cursorDay = $date->getDay();615$this->cursorMonth = $date->getMonth();616$this->cursorYear = $date->getYear();617618$year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart());619$key = $this->cursorMonth.'M'.$this->cursorDay.'D';620$this->cursorWeek = $year_map['info'][$key]['week'];621$this->cursorWeekday = $year_map['info'][$key]['weekday'];622623$this->setSeconds = array();624$this->setMinutes = array();625$this->setHours = array();626$this->setDays = array();627$this->setMonths = array();628$this->setYears = array();629630$this->stateSecond = null;631$this->stateMinute = null;632$this->stateHour = null;633$this->stateDay = null;634$this->stateWeek = null;635$this->stateMonth = null;636$this->stateYear = null;637638// If we have a BYSETPOS, we need to generate the entire set before we639// can filter it and return results. Normally, we start generating at640// the start date, but we need to go back one interval to generate641// BYSETPOS events so we can make sure the entire set is generated.642if ($this->getBySetPosition()) {643$interval = $this->getInterval();644switch ($frequency) {645case self::FREQUENCY_YEARLY:646$this->cursorYear -= $interval;647break;648case self::FREQUENCY_MONTHLY:649$this->cursorMonth -= $interval;650$this->rewindMonth();651break;652case self::FREQUENCY_WEEKLY:653$this->cursorWeek -= $interval;654$this->rewindWeek();655break;656case self::FREQUENCY_DAILY:657$this->cursorDay -= $interval;658$this->rewindDay();659break;660case self::FREQUENCY_HOURLY:661$this->cursorHour -= $interval;662$this->rewindHour();663break;664case self::FREQUENCY_MINUTELY:665$this->cursorMinute -= $interval;666$this->rewindMinute();667break;668case self::FREQUENCY_SECONDLY:669default:670throw new Exception(671pht(672'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',673$frequency));674}675}676677// We can generate events from before the cursor when evaluating rules678// with BYSETPOS or FREQ=WEEKLY.679$this->minimumEpoch = $this->getStartDateTime()->getEpoch();680681$cursor_state = array(682'year' => $this->cursorYear,683'month' => $this->cursorMonth,684'week' => $this->cursorWeek,685'day' => $this->cursorDay,686'hour' => $this->cursorHour,687);688689$this->cursorDayState = $cursor_state;690$this->cursorWeekState = $cursor_state;691$this->cursorHourState = $cursor_state;692693$by_hour = $this->getByHour();694$by_minute = $this->getByMinute();695$by_second = $this->getBySecond();696697$scale = $this->getFrequencyScale();698699// We return all-day events if the start date is an all-day event and we700// don't have more granular selectors or a more granular frequency.701$this->isAllDay = $date->getIsAllDay()702&& !$by_hour703&& !$by_minute704&& !$by_second705&& ($scale > self::SCALE_HOURLY);706}707708public function getNextEvent($cursor) {709while (true) {710$event = $this->generateNextEvent();711if (!$event) {712break;713}714715$epoch = $event->getEpoch();716if ($this->minimumEpoch) {717if ($epoch < $this->minimumEpoch) {718continue;719}720}721722if ($epoch < $cursor) {723continue;724}725726break;727}728729return $event;730}731732private function generateNextEvent() {733if ($this->activeSet) {734return array_pop($this->activeSet);735}736737$this->baseYear = $this->cursorYear;738739$by_setpos = $this->getBySetPosition();740if ($by_setpos) {741$old_state = $this->getSetPositionState();742}743744while (!$this->activeSet) {745$this->activeSet = $this->nextSet;746$this->nextSet = array();747748while (true) {749if ($this->isAllDay) {750$this->nextDay();751} else {752$this->nextSecond();753}754755$result = id(new PhutilCalendarAbsoluteDateTime())756->setTimezone($this->getStartDateTime()->getTimezone())757->setViewerTimezone($this->getViewerTimezone())758->setYear($this->stateYear)759->setMonth($this->stateMonth)760->setDay($this->stateDay);761762if ($this->isAllDay) {763$result->setIsAllDay(true);764} else {765$result766->setHour($this->stateHour)767->setMinute($this->stateMinute)768->setSecond($this->stateSecond);769}770771// If we don't have BYSETPOS, we're all done. We put this into the772// set and will immediately return it.773if (!$by_setpos) {774$this->activeSet[] = $result;775break;776}777778// Otherwise, check if we've completed a set. The set is complete if779// the state has moved past the span we were examining (for example,780// with a YEARLY event, if the state is now in the next year).781$new_state = $this->getSetPositionState();782if ($new_state == $old_state) {783$this->activeSet[] = $result;784continue;785}786787$this->activeSet = $this->applySetPos($this->activeSet, $by_setpos);788$this->activeSet = array_reverse($this->activeSet);789$this->nextSet[] = $result;790$old_state = $new_state;791break;792}793}794795return array_pop($this->activeSet);796}797798799protected function nextSecond() {800if ($this->setSeconds) {801$this->stateSecond = array_pop($this->setSeconds);802return;803}804805$frequency = $this->getFrequency();806$interval = $this->getInterval();807$is_secondly = ($frequency == self::FREQUENCY_SECONDLY);808$by_second = $this->getBySecond();809810while (!$this->setSeconds) {811$this->nextMinute();812813if ($is_secondly || $by_second) {814$seconds = $this->newSecondsSet(815($is_secondly ? $interval : 1),816$by_second);817} else {818$seconds = array(819$this->cursorSecond,820);821}822823$this->setSeconds = array_reverse($seconds);824}825826$this->stateSecond = array_pop($this->setSeconds);827}828829protected function nextMinute() {830if ($this->setMinutes) {831$this->stateMinute = array_pop($this->setMinutes);832return;833}834835$frequency = $this->getFrequency();836$interval = $this->getInterval();837$scale = $this->getFrequencyScale();838$is_minutely = ($frequency === self::FREQUENCY_MINUTELY);839$by_minute = $this->getByMinute();840841while (!$this->setMinutes) {842$this->nextHour();843844if ($is_minutely || $by_minute) {845$minutes = $this->newMinutesSet(846($is_minutely ? $interval : 1),847$by_minute);848} else if ($scale < self::SCALE_MINUTELY) {849$minutes = $this->newMinutesSet(8501,851array());852} else {853$minutes = array(854$this->cursorMinute,855);856}857858$this->setMinutes = array_reverse($minutes);859}860861$this->stateMinute = array_pop($this->setMinutes);862}863864protected function nextHour() {865if ($this->setHours) {866$this->stateHour = array_pop($this->setHours);867return;868}869870$frequency = $this->getFrequency();871$interval = $this->getInterval();872$scale = $this->getFrequencyScale();873$is_hourly = ($frequency === self::FREQUENCY_HOURLY);874$by_hour = $this->getByHour();875876while (!$this->setHours) {877$this->nextDay();878879$is_dynamic = $is_hourly880|| $by_hour881|| ($scale < self::SCALE_HOURLY);882883if ($is_dynamic) {884$hours = $this->newHoursSet(885($is_hourly ? $interval : 1),886$by_hour);887} else {888$hours = array(889$this->cursorHour,890);891}892893$this->setHours = array_reverse($hours);894}895896$this->stateHour = array_pop($this->setHours);897}898899protected function nextDay() {900if ($this->setDays) {901$info = array_pop($this->setDays);902$this->setDayState($info);903return;904}905906$frequency = $this->getFrequency();907$interval = $this->getInterval();908$scale = $this->getFrequencyScale();909$is_daily = ($frequency === self::FREQUENCY_DAILY);910$is_weekly = ($frequency === self::FREQUENCY_WEEKLY);911912$by_day = $this->getByDay();913$by_monthday = $this->getByMonthDay();914$by_yearday = $this->getByYearDay();915$by_weekno = $this->getByWeekNumber();916$by_month = $this->getByMonth();917$week_start = $this->getWeekStart();918919while (!$this->setDays) {920if ($is_weekly) {921$this->nextWeek();922} else {923$this->nextMonth();924}925926// NOTE: We normally handle BYMONTH when iterating months, but it acts927// like a filter if FREQ=WEEKLY.928929$is_dynamic = $is_daily930|| $is_weekly931|| $by_day932|| $by_monthday933|| $by_yearday934|| $by_weekno935|| ($by_month && $is_weekly)936|| ($scale < self::SCALE_DAILY);937938if ($is_dynamic) {939$weeks = $this->newDaysSet(940($is_daily ? $interval : 1),941$by_day,942$by_monthday,943$by_yearday,944$by_weekno,945$by_month,946$week_start);947} else {948// The cursor day may not actually exist in the current month, so949// make sure the day is valid before we generate a set which contains950// it.951$year_map = $this->getYearMap($this->stateYear, $week_start);952if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) {953$weeks = array(954array(),955);956} else {957$key = $this->stateMonth.'M'.$this->cursorDay.'D';958$weeks = array(959array($year_map['info'][$key]),960);961}962}963964// Unpack the weeks into days.965$days = array_mergev($weeks);966967$this->setDays = array_reverse($days);968}969970$info = array_pop($this->setDays);971$this->setDayState($info);972}973974private function setDayState(array $info) {975$this->stateDay = $info['monthday'];976$this->stateWeek = $info['week'];977$this->stateMonth = $info['month'];978}979980protected function nextMonth() {981if ($this->setMonths) {982$this->stateMonth = array_pop($this->setMonths);983return;984}985986$frequency = $this->getFrequency();987$interval = $this->getInterval();988$scale = $this->getFrequencyScale();989$is_monthly = ($frequency === self::FREQUENCY_MONTHLY);990991$by_month = $this->getByMonth();992993// If we have a BYMONTHDAY, we consider that set of days in every month.994// For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every995// month", so we need to expand the month set if the constraint is present.996$by_monthday = $this->getByMonthDay();997998// Likewise, we need to generate all months if we have BYYEARDAY or999// BYWEEKNO or BYDAY.1000$by_yearday = $this->getByYearDay();1001$by_weekno = $this->getByWeekNumber();1002$by_day = $this->getByDay();10031004while (!$this->setMonths) {1005$this->nextYear();10061007$is_dynamic = $is_monthly1008|| $by_month1009|| $by_monthday1010|| $by_yearday1011|| $by_weekno1012|| $by_day1013|| ($scale < self::SCALE_MONTHLY);10141015if ($is_dynamic) {1016$months = $this->newMonthsSet(1017($is_monthly ? $interval : 1),1018$by_month);1019} else {1020$months = array(1021$this->cursorMonth,1022);1023}10241025$this->setMonths = array_reverse($months);1026}10271028$this->stateMonth = array_pop($this->setMonths);1029}10301031protected function nextWeek() {1032if ($this->setWeeks) {1033$this->stateWeek = array_pop($this->setWeeks);1034return;1035}10361037$frequency = $this->getFrequency();1038$interval = $this->getInterval();1039$scale = $this->getFrequencyScale();1040$by_weekno = $this->getByWeekNumber();10411042while (!$this->setWeeks) {1043$this->nextYear();10441045$weeks = $this->newWeeksSet(1046$interval,1047$by_weekno);10481049$this->setWeeks = array_reverse($weeks);1050}10511052$this->stateWeek = array_pop($this->setWeeks);1053}10541055protected function nextYear() {1056$this->stateYear = $this->cursorYear;10571058$frequency = $this->getFrequency();1059$is_yearly = ($frequency === self::FREQUENCY_YEARLY);10601061if ($is_yearly) {1062$interval = $this->getInterval();1063} else {1064$interval = 1;1065}10661067$this->cursorYear = $this->cursorYear + $interval;10681069if ($this->cursorYear > ($this->baseYear + 100)) {1070throw new Exception(1071pht(1072'RRULE evaluation failed to generate more events in the next 100 '.1073'years. This RRULE is likely invalid or degenerate.'));1074}10751076}10771078private function newSecondsSet($interval, $set) {1079// TODO: This doesn't account for leap seconds. In theory, it probably1080// should, although this shouldn't impact any real events.1081$seconds_in_minute = 60;10821083if ($this->cursorSecond >= $seconds_in_minute) {1084$this->cursorSecond -= $seconds_in_minute;1085return array();1086}10871088list($cursor, $result) = $this->newIteratorSet(1089$this->cursorSecond,1090$interval,1091$set,1092$seconds_in_minute);10931094$this->cursorSecond = ($cursor - $seconds_in_minute);10951096return $result;1097}10981099private function newMinutesSet($interval, $set) {1100// NOTE: This value is legitimately a constant! Amazing!1101$minutes_in_hour = 60;11021103if ($this->cursorMinute >= $minutes_in_hour) {1104$this->cursorMinute -= $minutes_in_hour;1105return array();1106}11071108list($cursor, $result) = $this->newIteratorSet(1109$this->cursorMinute,1110$interval,1111$set,1112$minutes_in_hour);11131114$this->cursorMinute = ($cursor - $minutes_in_hour);11151116return $result;1117}11181119private function newHoursSet($interval, $set) {1120// TODO: This doesn't account for hours caused by daylight savings time.1121// It probably should, although this seems unlikely to impact any real1122// events.1123$hours_in_day = 24;11241125// If the hour cursor is behind the current time, we need to forward it in1126// INTERVAL increments so we end up with the right offset.1127list($skip, $this->cursorHourState) = $this->advanceCursorState(1128$this->cursorHourState,1129self::SCALE_HOURLY,1130$interval,1131$this->getWeekStart());11321133if ($skip) {1134return array();1135}11361137list($cursor, $result) = $this->newIteratorSet(1138$this->cursorHour,1139$interval,1140$set,1141$hours_in_day);11421143$this->cursorHour = ($cursor - $hours_in_day);11441145return $result;1146}11471148private function newWeeksSet($interval, $set) {1149$week_start = $this->getWeekStart();11501151list($skip, $this->cursorWeekState) = $this->advanceCursorState(1152$this->cursorWeekState,1153self::SCALE_WEEKLY,1154$interval,1155$week_start);11561157if ($skip) {1158return array();1159}11601161$year_map = $this->getYearMap($this->stateYear, $week_start);11621163$result = array();1164while (true) {1165if (!isset($year_map['weekMap'][$this->cursorWeek])) {1166break;1167}1168$result[] = $this->cursorWeek;1169$this->cursorWeek += $interval;1170}11711172$this->cursorWeek -= $year_map['weekCount'];11731174return $result;1175}11761177private function newDaysSet(1178$interval_day,1179$by_day,1180$by_monthday,1181$by_yearday,1182$by_weekno,1183$by_month,1184$week_start) {11851186$frequency = $this->getFrequency();1187$is_yearly = ($frequency == self::FREQUENCY_YEARLY);1188$is_monthly = ($frequency == self::FREQUENCY_MONTHLY);1189$is_weekly = ($frequency == self::FREQUENCY_WEEKLY);11901191$selection = array();1192if ($is_weekly) {1193$year_map = $this->getYearMap($this->stateYear, $week_start);11941195if (isset($year_map['weekMap'][$this->stateWeek])) {1196foreach ($year_map['weekMap'][$this->stateWeek] as $key) {1197$selection[] = $year_map['info'][$key];1198}1199}1200} else {1201// If the day cursor is behind the current year and month, we need to1202// forward it in INTERVAL increments so we end up with the right offset1203// in the current month.1204list($skip, $this->cursorDayState) = $this->advanceCursorState(1205$this->cursorDayState,1206self::SCALE_DAILY,1207$interval_day,1208$week_start);12091210if (!$skip) {1211$year_map = $this->getYearMap($this->stateYear, $week_start);1212while (true) {1213$month_idx = $this->stateMonth;1214$month_days = $year_map['monthDays'][$month_idx];1215if ($this->cursorDay > $month_days) {1216// NOTE: The year map is now out of date, but we're about to break1217// out of the loop anyway so it doesn't matter.1218break;1219}12201221$day_idx = $this->cursorDay;12221223$key = "{$month_idx}M{$day_idx}D";1224$selection[] = $year_map['info'][$key];12251226$this->cursorDay += $interval_day;1227}1228}1229}12301231// As a special case, BYDAY applies to relative month offsets if BYMONTH1232// is present in a YEARLY rule.1233if ($is_yearly) {1234if ($this->getByMonth()) {1235$is_yearly = false;1236$is_monthly = true;1237}1238}12391240// As a special case, BYDAY makes us examine all week days. This doesn't1241// check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY.1242$filter_weekday = true;1243if ($is_weekly) {1244if ($by_day) {1245$filter_weekday = false;1246}1247}12481249$weeks = array();1250foreach ($selection as $key => $info) {1251if ($is_weekly) {1252if ($filter_weekday) {1253if ($info['weekday'] != $this->cursorWeekday) {1254continue;1255}1256}1257} else {1258if ($info['month'] != $this->stateMonth) {1259continue;1260}1261}12621263if ($by_day) {1264if (empty($by_day[$info['weekday']])) {1265if ($is_yearly) {1266if (empty($by_day[$info['weekday.yearly']]) &&1267empty($by_day[$info['-weekday.yearly']])) {1268continue;1269}1270} else if ($is_monthly) {1271if (empty($by_day[$info['weekday.monthly']]) &&1272empty($by_day[$info['-weekday.monthly']])) {1273continue;1274}1275} else {1276continue;1277}1278}1279}12801281if ($by_monthday) {1282if (empty($by_monthday[$info['monthday']]) &&1283empty($by_monthday[$info['-monthday']])) {1284continue;1285}1286}12871288if ($by_yearday) {1289if (empty($by_yearday[$info['yearday']]) &&1290empty($by_yearday[$info['-yearday']])) {1291continue;1292}1293}12941295if ($by_weekno) {1296if (empty($by_weekno[$info['week']]) &&1297empty($by_weekno[$info['-week']])) {1298continue;1299}1300}13011302if ($by_month) {1303if (empty($by_month[$info['month']])) {1304continue;1305}1306}13071308$weeks[$info['week']][] = $info;1309}13101311return array_values($weeks);1312}13131314private function newMonthsSet($interval, $set) {1315// NOTE: This value is also a real constant! Wow!1316$months_in_year = 12;13171318if ($this->cursorMonth > $months_in_year) {1319$this->cursorMonth -= $months_in_year;1320return array();1321}13221323list($cursor, $result) = $this->newIteratorSet(1324$this->cursorMonth,1325$interval,1326$set,1327$months_in_year + 1);13281329$this->cursorMonth = ($cursor - $months_in_year);13301331return $result;1332}13331334public static function getYearMap($year, $week_start) {1335static $maps = array();13361337$key = "{$year}/{$week_start}";1338if (isset($maps[$key])) {1339return $maps[$key];1340}13411342$map = self::newYearMap($year, $week_start);1343$maps[$key] = $map;13441345return $maps[$key];1346}13471348private static function newYearMap($year, $weekday_start) {1349$weekday_index = self::getWeekdayIndex($weekday_start);13501351$is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||1352($year % 400 === 0);13531354// There may be some clever way to figure out which day of the week a given1355// year starts on and avoid the cost of a DateTime construction, but I1356// wasn't able to turn it up and we only need to do this once per year.1357$datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));1358$weekday = (int)$datetime->format('w');13591360if ($is_leap) {1361$max_day = 366;1362} else {1363$max_day = 365;1364}13651366$month_days = array(13671 => 31,13682 => $is_leap ? 29 : 28,13693 => 31,13704 => 30,13715 => 31,13726 => 30,13737 => 31,13748 => 31,13759 => 30,137610 => 31,137711 => 30,137812 => 31,1379);13801381// Per the spec, the first week of the year must contain at least four1382// days. If the week starts on a Monday but the year starts on a Saturday,1383// the first couple of days don't count as a week. In this case, the first1384// week will begin on January 3.1385$first_week_size = 0;1386$first_weekday = $weekday;1387for ($year_day = 1; $year_day <= $max_day; $year_day++) {1388$first_weekday = ($first_weekday + 1) % 7;1389$first_week_size++;1390if ($first_weekday === $weekday_index) {1391break;1392}1393}13941395if ($first_week_size >= 4) {1396$week_number = 1;1397} else {1398$week_number = 0;1399}14001401$info_map = array();14021403$weekday_map = self::getWeekdayIndexMap();1404$weekday_map = array_flip($weekday_map);14051406$yearly_counts = array();1407$monthly_counts = array();14081409$month_number = 1;1410$month_day = 1;1411for ($year_day = 1; $year_day <= $max_day; $year_day++) {1412$key = "{$month_number}M{$month_day}D";14131414$short_day = $weekday_map[$weekday];1415if (empty($yearly_counts[$short_day])) {1416$yearly_counts[$short_day] = 0;1417}1418$yearly_counts[$short_day]++;14191420if (empty($monthly_counts[$month_number][$short_day])) {1421$monthly_counts[$month_number][$short_day] = 0;1422}1423$monthly_counts[$month_number][$short_day]++;14241425$info = array(1426'year' => $year,1427'key' => $key,1428'month' => $month_number,1429'monthday' => $month_day,1430'-monthday' => -$month_days[$month_number] + $month_day - 1,1431'yearday' => $year_day,1432'-yearday' => -$max_day + $year_day - 1,1433'week' => $week_number,1434'weekday' => $short_day,1435'weekday.yearly' => $yearly_counts[$short_day],1436'weekday.monthly' => $monthly_counts[$month_number][$short_day],1437);14381439$info_map[$key] = $info;14401441$weekday = ($weekday + 1) % 7;1442if ($weekday === $weekday_index) {1443$week_number++;1444}14451446$month_day = ($month_day + 1);1447if ($month_day > $month_days[$month_number]) {1448$month_day = 1;1449$month_number++;1450}1451}14521453// Check how long the final week is. If it doesn't have four days, this1454// is really the first week of the next year.1455$final_week = array();1456foreach ($info_map as $key => $info) {1457if ($info['week'] == $week_number) {1458$final_week[] = $key;1459}1460}14611462if (count($final_week) < 4) {1463$week_number = $week_number - 1;1464$next_year = self::getYearMap($year + 1, $weekday_start);1465$next_year_weeks = $next_year['weekCount'];1466} else {1467$next_year_weeks = null;1468}14691470if ($first_week_size < 4) {1471$last_year = self::getYearMap($year - 1, $weekday_start);1472$last_year_weeks = $last_year['weekCount'];1473} else {1474$last_year_weeks = null;1475}14761477// Now that we know how many weeks the year has, we can compute the1478// negative offsets.1479foreach ($info_map as $key => $info) {1480$week = $info['week'];14811482if ($week === 0) {1483// If this day is part of the first partial week of the year, give1484// it the week number of the last week of the prior year instead.1485$info['week'] = $last_year_weeks;1486$info['-week'] = -1;1487} else if ($week > $week_number) {1488// If this day is part of the last partial week of the year, give1489// it week numbers from the next year.1490$info['week'] = 1;1491$info['-week'] = -$next_year_weeks;1492} else {1493$info['-week'] = -$week_number + $week - 1;1494}14951496// Do all the arithmetic to figure out if this is the -19th Thursday1497// in the year and such.1498$month_number = $info['month'];1499$short_day = $info['weekday'];1500$monthly_count = $monthly_counts[$month_number][$short_day];1501$monthly_index = $info['weekday.monthly'];1502$info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1;1503$info['-weekday.monthly'] .= $short_day;1504$info['weekday.monthly'] .= $short_day;15051506$yearly_count = $yearly_counts[$short_day];1507$yearly_index = $info['weekday.yearly'];1508$info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1;1509$info['-weekday.yearly'] .= $short_day;1510$info['weekday.yearly'] .= $short_day;15111512$info_map[$key] = $info;1513}15141515$week_map = array();1516foreach ($info_map as $key => $info) {1517$week_map[$info['week']][] = $key;1518}15191520return array(1521'info' => $info_map,1522'weekCount' => $week_number,1523'dayCount' => $max_day,1524'monthDays' => $month_days,1525'weekMap' => $week_map,1526);1527}15281529private function newIteratorSet($cursor, $interval, $set, $limit) {1530if ($interval < 1) {1531throw new Exception(1532pht(1533'Invalid iteration interval ("%d"), must be at least 1.',1534$interval));1535}15361537$result = array();1538$seen = array();15391540$ii = $cursor;1541while (true) {1542if (!$set || isset($set[$ii])) {1543$result[] = $ii;1544}15451546$ii = ($ii + $interval);15471548if ($ii >= $limit) {1549break;1550}1551}15521553sort($result);1554$result = array_values($result);15551556return array($ii, $result);1557}15581559private function applySetPos(array $values, array $setpos) {1560$select = array();15611562$count = count($values);1563foreach ($setpos as $pos) {1564if ($pos > 0 && $pos <= $count) {1565$select[] = ($pos - 1);1566} else if ($pos < 0 && $pos >= -$count) {1567$select[] = ($count + $pos);1568}1569}15701571sort($select);1572$select = array_unique($select);15731574return array_select_keys($values, $select);1575}15761577private function assertByRange(1578$source,1579array $values,1580$min,1581$max,1582$allow_zero = true) {15831584foreach ($values as $value) {1585if (!is_int($value)) {1586throw new Exception(1587pht(1588'Value "%s" in RRULE "%s" parameter is invalid: values must be '.1589'integers.',1590$value,1591$source));1592}15931594if ($value < $min || $value > $max) {1595throw new Exception(1596pht(1597'Value "%s" in RRULE "%s" parameter is invalid: it must be '.1598'between %s and %s.',1599$value,1600$source,1601$min,1602$max));1603}16041605if (!$value && !$allow_zero) {1606throw new Exception(1607pht(1608'Value "%s" in RRULE "%s" parameter is invalid: it must not '.1609'be zero.',1610$value,1611$source));1612}1613}1614}16151616private function getSetPositionState() {1617$scale = $this->getFrequencyScale();16181619$parts = array();1620$parts[] = $this->stateYear;16211622if ($scale == self::SCALE_WEEKLY) {1623$parts[] = $this->stateWeek;1624} else {1625if ($scale < self::SCALE_YEARLY) {1626$parts[] = $this->stateMonth;1627}1628if ($scale < self::SCALE_MONTHLY) {1629$parts[] = $this->stateDay;1630}1631if ($scale < self::SCALE_DAILY) {1632$parts[] = $this->stateHour;1633}1634if ($scale < self::SCALE_HOURLY) {1635$parts[] = $this->stateMinute;1636}1637}16381639return implode('/', $parts);1640}16411642private function rewindMonth() {1643while ($this->cursorMonth < 1) {1644$this->cursorYear--;1645$this->cursorMonth += 12;1646}1647}16481649private function rewindWeek() {1650$week_start = $this->getWeekStart();1651while ($this->cursorWeek < 1) {1652$this->cursorYear--;1653$year_map = $this->getYearMap($this->cursorYear, $week_start);1654$this->cursorWeek += $year_map['weekCount'];1655}1656}16571658private function rewindDay() {1659$week_start = $this->getWeekStart();1660while ($this->cursorDay < 1) {1661$year_map = $this->getYearMap($this->cursorYear, $week_start);1662$this->cursorDay += $year_map['monthDays'][$this->cursorMonth];1663$this->cursorMonth--;1664$this->rewindMonth();1665}1666}16671668private function rewindHour() {1669while ($this->cursorHour < 0) {1670$this->cursorHour += 24;1671$this->cursorDay--;1672$this->rewindDay();1673}1674}16751676private function rewindMinute() {1677while ($this->cursorMinute < 0) {1678$this->cursorMinute += 60;1679$this->cursorHour--;1680$this->rewindHour();1681}1682}16831684private function advanceCursorState(1685array $cursor,1686$scale,1687$interval,1688$week_start) {16891690$state = array(1691'year' => $this->stateYear,1692'month' => $this->stateMonth,1693'week' => $this->stateWeek,1694'day' => $this->stateDay,1695'hour' => $this->stateHour,1696);16971698// In the common case when the interval is 1, we'll visit every possible1699// value so we don't need to do any math and can just jump to the first1700// hour, day, etc.1701if ($interval == 1) {1702if ($this->isCursorBehind($cursor, $state, $scale)) {1703switch ($scale) {1704case self::SCALE_DAILY:1705$this->cursorDay = 1;1706break;1707case self::SCALE_HOURLY:1708$this->cursorHour = 0;1709break;1710case self::SCALE_WEEKLY:1711$this->cursorWeek = 1;1712break;1713}1714}17151716return array(false, $state);1717}17181719$year_map = $this->getYearMap($cursor['year'], $week_start);1720while ($this->isCursorBehind($cursor, $state, $scale)) {1721switch ($scale) {1722case self::SCALE_DAILY:1723$cursor['day'] += $interval;1724break;1725case self::SCALE_HOURLY:1726$cursor['hour'] += $interval;1727break;1728case self::SCALE_WEEKLY:1729$cursor['week'] += $interval;1730break;1731}17321733if ($scale <= self::SCALE_HOURLY) {1734while ($cursor['hour'] >= 24) {1735$cursor['hour'] -= 24;1736$cursor['day']++;1737}1738}17391740if ($scale == self::SCALE_WEEKLY) {1741while ($cursor['week'] > $year_map['weekCount']) {1742$cursor['week'] -= $year_map['weekCount'];1743$cursor['year']++;1744$year_map = $this->getYearMap($cursor['year'], $week_start);1745}1746}17471748if ($scale <= self::SCALE_DAILY) {1749while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {1750$cursor['day'] -= $year_map['monthDays'][$cursor['month']];1751$cursor['month']++;1752if ($cursor['month'] > 12) {1753$cursor['month'] -= 12;1754$cursor['year']++;1755$year_map = $this->getYearMap($cursor['year'], $week_start);1756}1757}1758}1759}17601761switch ($scale) {1762case self::SCALE_DAILY:1763$this->cursorDay = $cursor['day'];1764break;1765case self::SCALE_HOURLY:1766$this->cursorHour = $cursor['hour'];1767break;1768case self::SCALE_WEEKLY:1769$this->cursorWeek = $cursor['week'];1770break;1771}17721773$skip = $this->isCursorBehind($state, $cursor, $scale);17741775return array($skip, $cursor);1776}17771778private function isCursorBehind(array $cursor, array $state, $scale) {1779if ($cursor['year'] < $state['year']) {1780return true;1781} else if ($cursor['year'] > $state['year']) {1782return false;1783}17841785if ($scale == self::SCALE_WEEKLY) {1786return false;1787}17881789if ($cursor['month'] < $state['month']) {1790return true;1791} else if ($cursor['month'] > $state['month']) {1792return false;1793}17941795if ($scale >= self::SCALE_DAILY) {1796return false;1797}17981799if ($cursor['day'] < $state['day']) {1800return true;1801} else if ($cursor['day'] > $state['day']) {1802return false;1803}18041805if ($scale >= self::SCALE_HOURLY) {1806return false;1807}18081809if ($cursor['hour'] < $state['hour']) {1810return true;1811} else if ($cursor['hour'] > $state['hour']) {1812return false;1813}18141815return false;1816}181718181819}182018211822