diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index 1541c19..c2aae5b 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,1769 +1,1801 @@ getFrequency(); $interval = $this->getInterval(); if ($interval != 1) { $parts['INTERVAL'] = $interval; } $by_second = $this->getBySecond(); if ($by_second) { $parts['BYSECOND'] = $by_second; } $by_minute = $this->getByMinute(); if ($by_minute) { $parts['BYMINUTE'] = $by_minute; } $by_hour = $this->getByHour(); if ($by_hour) { $parts['BYHOUR'] = $by_hour; } $by_day = $this->getByDay(); if ($by_day) { $parts['BYDAY'] = $by_day; } $by_month = $this->getByMonth(); if ($by_month) { $parts['BYMONTH'] = $by_month; } $by_monthday = $this->getByMonthDay(); if ($by_monthday) { $parts['BYMONTHDAY'] = $by_monthday; } $by_yearday = $this->getByYearDay(); if ($by_yearday) { $parts['BYYEARDAY'] = $by_yearday; } $by_weekno = $this->getByWeekNumber(); if ($by_weekno) { $parts['BYWEEKNO'] = $by_weekno; } $by_setpos = $this->getBySetPosition(); if ($by_setpos) { $parts['BYSETPOS'] = $by_setpos; } $wkst = $this->getWeekStart(); if ($wkst != self::WEEKDAY_MONDAY) { $parts['WKST'] = $wkst; } $count = $this->getCount(); if ($count) { $parts['COUNT'] = $count; } $until = $this->getUntil(); if ($until) { $parts['UNTIL'] = $until->getISO8601(); } return $parts; } public static function newFromDictionary(array $dict) { static $expect; if ($expect === null) { $expect = array_fuse( array( 'FREQ', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', + 'BYMONTH', 'BYMONTHDAY', 'BYYEARDAY', 'BYWEEKNO', 'BYSETPOS', 'WKST', 'UNTIL', 'COUNT', )); } foreach ($dict as $key => $value) { if (empty($expect[$key])) { throw new Exception( pht( 'RRULE dictionary includes unknown key "%s". Expected keys '. 'are: %s.', $key, implode(', ', array_keys($expect)))); } } $rrule = id(new self()) ->setFrequency(idx($dict, 'FREQ')) ->setInterval(idx($dict, 'INTERVAL', 1)) ->setBySecond(idx($dict, 'BYSECOND', array())) ->setByMinute(idx($dict, 'BYMINUTE', array())) ->setByHour(idx($dict, 'BYHOUR', array())) ->setByDay(idx($dict, 'BYDAY', array())) + ->setByMonth(idx($dict, 'BYMONTH', array())) ->setByMonthDay(idx($dict, 'BYMONTHDAY', array())) ->setByYearDay(idx($dict, 'BYYEARDAY', array())) ->setByWeekNumber(idx($dict, 'BYWEEKNO', array())) ->setBySetPosition(idx($dict, 'BYSETPOS', array())) ->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY)); $count = idx($dict, 'COUNT'); if ($count) { $rrule->setCount($count); } $until = idx($dict, 'UNTIL'); if ($until) { $until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until); $rrule->setUntil($until); } return $rrule; } public function toRRULE() { $dict = $this->toDictionary(); $parts = array(); foreach ($dict as $key => $value) { if (is_array($value)) { $value = implode(',', $value); } $parts[] = "{$key}={$value}"; } return implode(';', $parts); } public static function newFromRRULE($rrule) { $parts = explode(';', $rrule); $dict = array(); foreach ($parts as $part) { list($key, $value) = explode('=', $part, 2); switch ($key) { case 'FREQ': case 'INTERVAL': case 'WKST': case 'COUNT': case 'UNTIL'; break; default: $value = explode(',', $value); break; } $dict[$key] = $value; } + $int_lists = array_fuse( + array( + // NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE". + 'BYSECOND', + 'BYMINUTE', + 'BYHOUR', + 'BYMONTH', + 'BYMONTHDAY', + 'BYYEARDAY', + 'BYWEEKNO', + 'BYSETPOS', + )); + + foreach ($dict as $key => $value) { + if (isset($int_lists[$key])) { + foreach ($value as $k => $v) { + if (!preg_match('/^-?\d+\z/', $v)) { + throw new Exception( + pht( + 'Unexpected value "%s" in "%s" RRULE property: expected '. + 'only integers.', + $v, + $key)); + } + $value[$k] = (int)$v; + } + $dict[$key] = $value; + } + } + return self::newFromDictionary($dict); } private static function getAllWeekdayConstants() { return array_keys(self::getWeekdayIndexMap()); } private static function getWeekdayIndexMap() { static $map = array( self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY, self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY, self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY, self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY, self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY, self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY, self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY, ); return $map; } private static function getWeekdayIndex($weekday) { $map = self::getWeekdayIndexMap(); if (!isset($map[$weekday])) { $constants = array_keys($map); throw new Exception( pht( 'Weekday "%s" is not a valid weekday constant. Valid constants '. 'are: %s.', $weekday, implode(', ', $constants))); } return $map[$weekday]; } public function setStartDateTime(PhutilCalendarDateTime $start) { $this->startDateTime = $start; return $this; } public function getStartDateTime() { return $this->startDateTime; } public function setCount($count) { if ($count < 1) { throw new Exception( pht( 'RRULE COUNT value "%s" is invalid: count must be at least 1.', $count)); } $this->count = $count; return $this; } public function getCount() { return $this->count; } public function setUntil(PhutilCalendarDateTime $until) { $this->until = $until; return $this; } public function getUntil() { return $this->until; } public function setFrequency($frequency) { static $map = array( self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY, self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY, self::FREQUENCY_HOURLY => self::SCALE_HOURLY, self::FREQUENCY_DAILY => self::SCALE_DAILY, self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY, self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY, self::FREQUENCY_YEARLY => self::SCALE_YEARLY, ); if (empty($map[$frequency])) { throw new Exception( pht( 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.', $frequency, implode(', ', array_keys($map)))); } $this->frequency = $frequency; $this->frequencyScale = $map[$frequency]; return $this; } public function getFrequency() { return $this->frequency; } public function getFrequencyScale() { return $this->frequencyScale; } public function setInterval($interval) { if (!is_int($interval)) { throw new Exception( pht( 'RRULE INTERVAL "%s" is invalid: interval must be an integer.', $interval)); } if ($interval < 1) { throw new Exception( pht( 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.', $interval)); } $this->interval = $interval; return $this; } public function getInterval() { return $this->interval; } public function setBySecond(array $by_second) { $this->assertByRange('BYSECOND', $by_second, 0, 60); $this->bySecond = array_fuse($by_second); return $this; } public function getBySecond() { return $this->bySecond; } public function setByMinute(array $by_minute) { $this->assertByRange('BYMINUTE', $by_minute, 0, 59); $this->byMinute = array_fuse($by_minute); return $this; } public function getByMinute() { return $this->byMinute; } public function setByHour(array $by_hour) { $this->assertByRange('BYHOUR', $by_hour, 0, 23); $this->byHour = array_fuse($by_hour); return $this; } public function getByHour() { return $this->byHour; } public function setByDay(array $by_day) { $constants = self::getAllWeekdayConstants(); $constants = implode('|', $constants); $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/'; foreach ($by_day as $key => $value) { $matches = null; if (!preg_match($pattern, $value, $matches)) { throw new Exception( pht( 'RRULE BYDAY value "%s" is invalid: rule part must be in the '. 'expected form (like "MO", "-3TH", or "+2SU").', $value)); } // The maximum allowed value is 53, which corresponds to "the 53rd // Monday every year" or similar when evaluated against a YEARLY rule. $maximum = 53; $magnitude = (int)$matches[1]; if ($magnitude > $maximum) { throw new Exception( pht( 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '. 'the maximum permitted value is "%s".', $value, $magnitude, $maximum)); } // Normalize "+3FR" into "3FR". $by_day[$key] = ltrim($value, '+'); } $this->byDay = array_fuse($by_day); return $this; } public function getByDay() { return $this->byDay; } public function setByMonthDay(array $by_month_day) { $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false); $this->byMonthDay = array_fuse($by_month_day); return $this; } public function getByMonthDay() { return $this->byMonthDay; } public function setByYearDay($by_year_day) { $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false); $this->byYearDay = array_fuse($by_year_day); return $this; } public function getByYearDay() { return $this->byYearDay; } public function setByMonth(array $by_month) { $this->assertByRange('BYMONTH', $by_month, 1, 12); $this->byMonth = array_fuse($by_month); return $this; } public function getByMonth() { return $this->byMonth; } public function setByWeekNumber(array $by_week_number) { $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false); $this->byWeekNumber = array_fuse($by_week_number); return $this; } public function getByWeekNumber() { return $this->byWeekNumber; } public function setBySetPosition(array $by_set_position) { $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false); $this->bySetPosition = $by_set_position; return $this; } public function getBySetPosition() { return $this->bySetPosition; } public function setWeekStart($week_start) { // Make sure this is a valid weekday constant. self::getWeekdayIndex($week_start); $this->weekStart = $week_start; return $this; } public function getWeekStart() { return $this->weekStart; } public function resetSource() { $frequency = $this->getFrequency(); if ($this->getByMonthDay()) { switch ($frequency) { case self::FREQUENCY_WEEKLY: // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the // FREQ rule part is set to WEEKLY." throw new Exception( pht( 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '. 'violates RFC5545.')); break; default: break; } } if ($this->getByYearDay()) { switch ($frequency) { case self::FREQUENCY_DAILY: case self::FREQUENCY_WEEKLY: case self::FREQUENCY_MONTHLY: // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY." throw new Exception( pht( 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '. 'MONTHLY, which violates RFC5545.')); default: break; } } // TODO // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric // value when the FREQ rule part is not set to MONTHLY or YEARLY." // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO // rule part is specified." $date = $this->getStartDateTime(); $this->cursorSecond = $date->getSecond(); $this->cursorMinute = $date->getMinute(); $this->cursorHour = $date->getHour(); $this->cursorDay = $date->getDay(); $this->cursorMonth = $date->getMonth(); $this->cursorYear = $date->getYear(); $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart()); $key = $this->cursorMonth.'M'.$this->cursorDay.'D'; $this->cursorWeek = $year_map['info'][$key]['week']; $this->cursorWeekday = $year_map['info'][$key]['weekday']; $this->setSeconds = array(); $this->setMinutes = array(); $this->setHours = array(); $this->setDays = array(); $this->setMonths = array(); $this->setYears = array(); $this->stateSecond = null; $this->stateMinute = null; $this->stateHour = null; $this->stateDay = null; $this->stateWeek = null; $this->stateMonth = null; $this->stateYear = null; // If we have a BYSETPOS, we need to generate the entire set before we // can filter it and return results. Normally, we start generating at // the start date, but we need to go back one interval to generate // BYSETPOS events so we can make sure the entire set is generated. if ($this->getBySetPosition()) { $interval = $this->getInterval(); switch ($frequency) { case self::FREQUENCY_YEARLY: $this->cursorYear -= $interval; break; case self::FREQUENCY_MONTHLY: $this->cursorMonth -= $interval; $this->rewindMonth(); break; case self::FREQUENCY_WEEKLY: $this->cursorWeek -= $interval; $this->rewindWeek(); break; case self::FREQUENCY_DAILY: $this->cursorDay -= $interval; $this->rewindDay(); break; case self::FREQUENCY_HOURLY: $this->cursorHour -= $interval; $this->rewindHour(); break; case self::FREQUENCY_MINUTELY: $this->cursorMinute -= $interval; $this->rewindMinute(); break; case self::FREQUENCY_SECONDLY: default: throw new Exception( pht( 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.', $frequency)); } } // We can generate events from before the cursor when evaluating rules // with BYSETPOS or FREQ=WEEKLY. $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); $cursor_state = array( 'year' => $this->cursorYear, 'month' => $this->cursorMonth, 'week' => $this->cursorWeek, 'day' => $this->cursorDay, 'hour' => $this->cursorHour, ); $this->cursorDayState = $cursor_state; $this->cursorWeekState = $cursor_state; $this->cursorHourState = $cursor_state; $by_hour = $this->getByHour(); $by_minute = $this->getByMinute(); $by_second = $this->getBySecond(); $scale = $this->getFrequencyScale(); // We return all-day events if the start date is an all-day event and we // don't have more granular selectors or a more granular frequency. $this->isAllDay = $date->getIsAllDay() && !$by_hour && !$by_minute && !$by_second && ($scale > self::SCALE_HOURLY); } public function getNextEvent($cursor) { while (true) { $event = $this->generateNextEvent(); if (!$event) { break; } $epoch = $event->getEpoch(); if ($this->minimumEpoch) { if ($epoch < $this->minimumEpoch) { continue; } } if ($epoch < $cursor) { continue; } break; } return $event; } private function generateNextEvent() { if ($this->activeSet) { return array_pop($this->activeSet); } $this->baseYear = $this->cursorYear; $by_setpos = $this->getBySetPosition(); if ($by_setpos) { $old_state = $this->getSetPositionState(); } while (!$this->activeSet) { $this->activeSet = $this->nextSet; $this->nextSet = array(); while (true) { if ($this->isAllDay) { $this->nextDay(); } else { $this->nextSecond(); } $result = id(new PhutilCalendarAbsoluteDateTime()) ->setTimezone($this->getStartDateTime()->getTimezone()) ->setViewerTimezone($this->getViewerTimezone()) ->setYear($this->stateYear) ->setMonth($this->stateMonth) ->setDay($this->stateDay); if ($this->isAllDay) { $result->setIsAllDay(true); } else { $result ->setHour($this->stateHour) ->setMinute($this->stateMinute) ->setSecond($this->stateSecond); } // If we don't have BYSETPOS, we're all done. We put this into the // set and will immediately return it. if (!$by_setpos) { $this->activeSet[] = $result; break; } // Otherwise, check if we've completed a set. The set is complete if // the state has moved past the span we were examining (for example, // with a YEARLY event, if the state is now in the next year). $new_state = $this->getSetPositionState(); if ($new_state == $old_state) { $this->activeSet[] = $result; continue; } $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos); $this->activeSet = array_reverse($this->activeSet); $this->nextSet[] = $result; $old_state = $new_state; break; } } return array_pop($this->activeSet); } protected function nextSecond() { if ($this->setSeconds) { $this->stateSecond = array_pop($this->setSeconds); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $is_secondly = ($frequency == self::FREQUENCY_SECONDLY); $by_second = $this->getBySecond(); while (!$this->setSeconds) { $this->nextMinute(); if ($is_secondly || $by_second) { $seconds = $this->newSecondsSet( ($is_secondly ? $interval : 1), $by_second); } else { $seconds = array( $this->cursorSecond, ); } $this->setSeconds = array_reverse($seconds); } $this->stateSecond = array_pop($this->setSeconds); } protected function nextMinute() { if ($this->setMinutes) { $this->stateMinute = array_pop($this->setMinutes); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_minutely = ($frequency === self::FREQUENCY_MINUTELY); $by_minute = $this->getByMinute(); while (!$this->setMinutes) { $this->nextHour(); if ($is_minutely || $by_minute) { $minutes = $this->newMinutesSet( ($is_minutely ? $interval : 1), $by_minute); } else if ($scale < self::SCALE_MINUTELY) { $minutes = $this->newMinutesSet( 1, array()); } else { $minutes = array( $this->cursorMinute, ); } $this->setMinutes = array_reverse($minutes); } $this->stateMinute = array_pop($this->setMinutes); } protected function nextHour() { if ($this->setHours) { $this->stateHour = array_pop($this->setHours); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_hourly = ($frequency === self::FREQUENCY_HOURLY); $by_hour = $this->getByHour(); while (!$this->setHours) { $this->nextDay(); $is_dynamic = $is_hourly || $by_hour || ($scale < self::SCALE_HOURLY); if ($is_dynamic) { $hours = $this->newHoursSet( ($is_hourly ? $interval : 1), $by_hour); } else { $hours = array( $this->cursorHour, ); } $this->setHours = array_reverse($hours); } $this->stateHour = array_pop($this->setHours); } protected function nextDay() { if ($this->setDays) { $info = array_pop($this->setDays); $this->setDayState($info); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_daily = ($frequency === self::FREQUENCY_DAILY); $is_weekly = ($frequency === self::FREQUENCY_WEEKLY); $by_day = $this->getByDay(); $by_monthday = $this->getByMonthDay(); $by_yearday = $this->getByYearDay(); $by_weekno = $this->getByWeekNumber(); $by_month = $this->getByMonth(); $week_start = $this->getWeekStart(); while (!$this->setDays) { if ($is_weekly) { $this->nextWeek(); } else { $this->nextMonth(); } // NOTE: We normally handle BYMONTH when iterating months, but it acts // like a filter if FREQ=WEEKLY. $is_dynamic = $is_daily || $is_weekly || $by_day || $by_monthday || $by_yearday || $by_weekno || ($by_month && $is_weekly) || ($scale < self::SCALE_DAILY); if ($is_dynamic) { $weeks = $this->newDaysSet( ($is_daily ? $interval : 1), $by_day, $by_monthday, $by_yearday, $by_weekno, $by_month, $week_start); } else { // The cursor day may not actually exist in the current month, so // make sure the day is valid before we generate a set which contains // it. $year_map = $this->getYearMap($this->stateYear, $week_start); if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) { $weeks = array( array(), ); } else { $key = $this->stateMonth.'M'.$this->cursorDay.'D'; $weeks = array( array($year_map['info'][$key]), ); } } // Unpack the weeks into days. $days = array_mergev($weeks); $this->setDays = array_reverse($days); } $info = array_pop($this->setDays); $this->setDayState($info); } private function setDayState(array $info) { $this->stateDay = $info['monthday']; $this->stateWeek = $info['week']; $this->stateMonth = $info['month']; } protected function nextMonth() { if ($this->setMonths) { $this->stateMonth = array_pop($this->setMonths); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $is_monthly = ($frequency === self::FREQUENCY_MONTHLY); $by_month = $this->getByMonth(); // If we have a BYMONTHDAY, we consider that set of days in every month. // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every // month", so we need to expand the month set if the constraint is present. $by_monthday = $this->getByMonthDay(); // Likewise, we need to generate all months if we have BYYEARDAY or // BYWEEKNO or BYDAY. $by_yearday = $this->getByYearDay(); $by_weekno = $this->getByWeekNumber(); $by_day = $this->getByDay(); while (!$this->setMonths) { $this->nextYear(); $is_dynamic = $is_monthly || $by_month || $by_monthday || $by_yearday || $by_weekno || $by_day || ($scale < self::SCALE_MONTHLY); if ($is_dynamic) { $months = $this->newMonthsSet( ($is_monthly ? $interval : 1), $by_month); } else { $months = array( $this->cursorMonth, ); } $this->setMonths = array_reverse($months); } $this->stateMonth = array_pop($this->setMonths); } protected function nextWeek() { if ($this->setWeeks) { $this->stateWeek = array_pop($this->setWeeks); return; } $frequency = $this->getFrequency(); $interval = $this->getInterval(); $scale = $this->getFrequencyScale(); $by_weekno = $this->getByWeekNumber(); while (!$this->setWeeks) { $this->nextYear(); $weeks = $this->newWeeksSet( $interval, $by_weekno); $this->setWeeks = array_reverse($weeks); } $this->stateWeek = array_pop($this->setWeeks); } protected function nextYear() { $this->stateYear = $this->cursorYear; $frequency = $this->getFrequency(); $is_yearly = ($frequency === self::FREQUENCY_YEARLY); if ($is_yearly) { $interval = $this->getInterval(); } else { $interval = 1; } $this->cursorYear = $this->cursorYear + $interval; if ($this->cursorYear > ($this->baseYear + 100)) { throw new Exception( pht( 'RRULE evaluation failed to generate more events in the next 100 '. 'years. This RRULE is likely invalid or degenerate.')); } } private function newSecondsSet($interval, $set) { // TODO: This doesn't account for leap seconds. In theory, it probably // should, although this shouldn't impact any real events. $seconds_in_minute = 60; if ($this->cursorSecond >= $seconds_in_minute) { $this->cursorSecond -= $seconds_in_minute; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorSecond, $interval, $set, $seconds_in_minute); $this->cursorSecond = ($cursor - $seconds_in_minute); return $result; } private function newMinutesSet($interval, $set) { // NOTE: This value is legitimately a constant! Amazing! $minutes_in_hour = 60; if ($this->cursorMinute >= $minutes_in_hour) { $this->cursorMinute -= $minutes_in_hour; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorMinute, $interval, $set, $minutes_in_hour); $this->cursorMinute = ($cursor - $minutes_in_hour); return $result; } private function newHoursSet($interval, $set) { // TODO: This doesn't account for hours caused by daylight savings time. // It probably should, although this seems unlikely to impact any real // events. $hours_in_day = 24; // If the hour cursor is behind the current time, we need to forward it in // INTERVAL increments so we end up with the right offset. list($skip, $this->cursorHourState) = $this->advanceCursorState( $this->cursorHourState, self::SCALE_HOURLY, $interval, $this->getWeekStart()); if ($skip) { return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorHour, $interval, $set, $hours_in_day); $this->cursorHour = ($cursor - $hours_in_day); return $result; } private function newWeeksSet($interval, $set) { $week_start = $this->getWeekStart(); list($skip, $this->cursorWeekState) = $this->advanceCursorState( $this->cursorWeekState, self::SCALE_WEEKLY, $interval, $week_start); if ($skip) { return array(); } $year_map = $this->getYearMap($this->stateYear, $week_start); $result = array(); while (true) { if (!isset($year_map['weekMap'][$this->cursorWeek])) { break; } $result[] = $this->cursorWeek; $this->cursorWeek += $interval; } $this->cursorWeek -= $year_map['weekCount']; return $result; } private function newDaysSet( $interval_day, $by_day, $by_monthday, $by_yearday, $by_weekno, $by_month, $week_start) { $frequency = $this->getFrequency(); $is_yearly = ($frequency == self::FREQUENCY_YEARLY); $is_monthly = ($frequency == self::FREQUENCY_MONTHLY); $is_weekly = ($frequency == self::FREQUENCY_WEEKLY); $selection = array(); if ($is_weekly) { $year_map = $this->getYearMap($this->stateYear, $week_start); if (isset($year_map['weekMap'][$this->stateWeek])) { foreach ($year_map['weekMap'][$this->stateWeek] as $key) { $selection[] = $year_map['info'][$key]; } } } else { // If the day cursor is behind the current year and month, we need to // forward it in INTERVAL increments so we end up with the right offset // in the current month. list($skip, $this->cursorDayState) = $this->advanceCursorState( $this->cursorDayState, self::SCALE_DAILY, $interval_day, $week_start); if (!$skip) { $year_map = $this->getYearMap($this->stateYear, $week_start); while (true) { $month_idx = $this->stateMonth; $month_days = $year_map['monthDays'][$month_idx]; if ($this->cursorDay > $month_days) { // NOTE: The year map is now out of date, but we're about to break // out of the loop anyway so it doesn't matter. break; } $day_idx = $this->cursorDay; $key = "{$month_idx}M{$day_idx}D"; $selection[] = $year_map['info'][$key]; $this->cursorDay += $interval_day; } } } // As a special case, BYDAY applies to relative month offsets if BYMONTH // is present in a YEARLY rule. if ($is_yearly) { if ($this->getByMonth()) { $is_yearly = false; $is_monthly = true; } } // As a special case, BYDAY makes us examine all week days. This doesn't // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY. $filter_weekday = true; if ($is_weekly) { if ($by_day) { $filter_weekday = false; } } $weeks = array(); foreach ($selection as $key => $info) { if ($is_weekly) { if ($filter_weekday) { if ($info['weekday'] != $this->cursorWeekday) { continue; } } } else { if ($info['month'] != $this->stateMonth) { continue; } } if ($by_day) { if (empty($by_day[$info['weekday']])) { if ($is_yearly) { if (empty($by_day[$info['weekday.yearly']]) && empty($by_day[$info['-weekday.yearly']])) { continue; } } else if ($is_monthly) { if (empty($by_day[$info['weekday.monthly']]) && empty($by_day[$info['-weekday.monthly']])) { continue; } } else { continue; } } } if ($by_monthday) { if (empty($by_monthday[$info['monthday']]) && empty($by_monthday[$info['-monthday']])) { continue; } } if ($by_yearday) { if (empty($by_yearday[$info['yearday']]) && empty($by_yearday[$info['-yearday']])) { continue; } } if ($by_weekno) { if (empty($by_weekno[$info['week']]) && empty($by_weekno[$info['-week']])) { continue; } } if ($by_month) { if (empty($by_month[$info['month']])) { continue; } } $weeks[$info['week']][] = $info; } return array_values($weeks); } private function newMonthsSet($interval, $set) { // NOTE: This value is also a real constant! Wow! $months_in_year = 12; if ($this->cursorMonth > $months_in_year) { $this->cursorMonth -= $months_in_year; return array(); } list($cursor, $result) = $this->newIteratorSet( $this->cursorMonth, $interval, $set, $months_in_year + 1); $this->cursorMonth = ($cursor - $months_in_year); return $result; } public static function getYearMap($year, $week_start) { static $maps = array(); $key = "{$year}/{$week_start}"; if (isset($maps[$key])) { return $maps[$key]; } $map = self::newYearMap($year, $week_start); $maps[$key] = $map; return $maps[$key]; } private static function newYearMap($year, $weekday_start) { $weekday_index = self::getWeekdayIndex($weekday_start); $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) || ($year % 400 === 0); // There may be some clever way to figure out which day of the week a given // year starts on and avoid the cost of a DateTime construction, but I // wasn't able to turn it up and we only need to do this once per year. $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC')); $weekday = (int)$datetime->format('w'); if ($is_leap) { $max_day = 366; } else { $max_day = 365; } $month_days = array( 1 => 31, 2 => $is_leap ? 29 : 28, 3 => 31, 4 => 30, 5 => 31, 6 => 30, 7 => 31, 8 => 31, 9 => 30, 10 => 31, 11 => 30, 12 => 31, ); // Per the spec, the first week of the year must contain at least four // days. If the week starts on a Monday but the year starts on a Saturday, // the first couple of days don't count as a week. In this case, the first // week will begin on January 3. $first_week_size = 0; $first_weekday = $weekday; for ($year_day = 1; $year_day <= $max_day; $year_day++) { $first_weekday = ($first_weekday + 1) % 7; $first_week_size++; if ($first_weekday === $weekday_index) { break; } } if ($first_week_size >= 4) { $week_number = 1; } else { $week_number = 0; } $info_map = array(); $weekday_map = self::getWeekdayIndexMap(); $weekday_map = array_flip($weekday_map); $yearly_counts = array(); $monthly_counts = array(); $month_number = 1; $month_day = 1; for ($year_day = 1; $year_day <= $max_day; $year_day++) { $key = "{$month_number}M{$month_day}D"; $short_day = $weekday_map[$weekday]; if (empty($yearly_counts[$short_day])) { $yearly_counts[$short_day] = 0; } $yearly_counts[$short_day]++; if (empty($monthly_counts[$month_number][$short_day])) { $monthly_counts[$month_number][$short_day] = 0; } $monthly_counts[$month_number][$short_day]++; $info = array( 'year' => $year, 'key' => $key, 'month' => $month_number, 'monthday' => $month_day, '-monthday' => -$month_days[$month_number] + $month_day - 1, 'yearday' => $year_day, '-yearday' => -$max_day + $year_day - 1, 'week' => $week_number, 'weekday' => $short_day, 'weekday.yearly' => $yearly_counts[$short_day], 'weekday.monthly' => $monthly_counts[$month_number][$short_day], ); $info_map[$key] = $info; $weekday = ($weekday + 1) % 7; if ($weekday === $weekday_index) { $week_number++; } $month_day = ($month_day + 1); if ($month_day > $month_days[$month_number]) { $month_day = 1; $month_number++; } } // Check how long the final week is. If it doesn't have four days, this // is really the first week of the next year. $final_week = array(); foreach ($info_map as $key => $info) { if ($info['week'] == $week_number) { $final_week[] = $key; } } if (count($final_week) < 4) { $week_number = $week_number - 1; $next_year = self::getYearMap($year + 1, $weekday_start); $next_year_weeks = $next_year['weekCount']; } else { $next_year_weeks = null; } if ($first_week_size < 4) { $last_year = self::getYearMap($year - 1, $weekday_start); $last_year_weeks = $last_year['weekCount']; } else { $last_year_weeks = null; } // Now that we know how many weeks the year has, we can compute the // negative offsets. foreach ($info_map as $key => $info) { $week = $info['week']; if ($week === 0) { // If this day is part of the first partial week of the year, give // it the week number of the last week of the prior year instead. $info['week'] = $last_year_weeks; $info['-week'] = -1; } else if ($week > $week_number) { // If this day is part of the last partial week of the year, give // it week numbers from the next year. $info['week'] = 1; $info['-week'] = -$next_year_weeks; } else { $info['-week'] = -$week_number + $week - 1; } // Do all the arithmetic to figure out if this is the -19th Thursday // in the year and such. $month_number = $info['month']; $short_day = $info['weekday']; $monthly_count = $monthly_counts[$month_number][$short_day]; $monthly_index = $info['weekday.monthly']; $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1; $info['-weekday.monthly'] .= $short_day; $info['weekday.monthly'] .= $short_day; $yearly_count = $yearly_counts[$short_day]; $yearly_index = $info['weekday.yearly']; $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1; $info['-weekday.yearly'] .= $short_day; $info['weekday.yearly'] .= $short_day; $info_map[$key] = $info; } $week_map = array(); foreach ($info_map as $key => $info) { $week_map[$info['week']][] = $key; } return array( 'info' => $info_map, 'weekCount' => $week_number, 'dayCount' => $max_day, 'monthDays' => $month_days, 'weekMap' => $week_map, ); } private function newIteratorSet($cursor, $interval, $set, $limit) { if ($interval < 1) { throw new Exception( pht( 'Invalid iteration interval ("%d"), must be at least 1.', $interval)); } $result = array(); $seen = array(); $ii = $cursor; while (true) { if (!$set || isset($set[$ii])) { $result[] = $ii; } $ii = ($ii + $interval); if ($ii >= $limit) { break; } } sort($result); $result = array_values($result); return array($ii, $result); } private function applySetPos(array $values, array $setpos) { $select = array(); $count = count($values); foreach ($setpos as $pos) { if ($pos > 0 && $pos <= $count) { $select[] = ($pos - 1); } else if ($pos < 0 && $pos >= -$count) { $select[] = ($count + $pos); } } sort($select); $select = array_unique($select); return array_select_keys($values, $select); } private function assertByRange( $source, array $values, $min, $max, $allow_zero = true) { foreach ($values as $value) { if (!is_int($value)) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: values must be '. 'integers.', $value, $source)); } if ($value < $min || $value > $max) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: it must be '. 'between %s and %s.', $value, $source, $min, $max)); } if (!$value && !$allow_zero) { throw new Exception( pht( 'Value "%s" in RRULE "%s" parameter is invalid: it must not '. 'be zero.', $value, $source)); } } } private function getSetPositionState() { $scale = $this->getFrequencyScale(); $parts = array(); $parts[] = $this->stateYear; if ($scale == self::SCALE_WEEKLY) { $parts[] = $this->stateWeek; } else { if ($scale < self::SCALE_YEARLY) { $parts[] = $this->stateMonth; } if ($scale < self::SCALE_MONTHLY) { $parts[] = $this->stateDay; } if ($scale < self::SCALE_DAILY) { $parts[] = $this->stateHour; } if ($scale < self::SCALE_HOURLY) { $parts[] = $this->stateMinute; } } return implode('/', $parts); } private function rewindMonth() { while ($this->cursorMonth < 1) { $this->cursorYear--; $this->cursorMonth += 12; } } private function rewindWeek() { $week_start = $this->getWeekStart(); while ($this->cursorWeek < 1) { $this->cursorYear--; $year_map = $this->getYearMap($this->cursorYear, $week_start); $this->cursorWeek += $year_map['weekCount']; } } private function rewindDay() { $week_start = $this->getWeekStart(); while ($this->cursorDay < 1) { $year_map = $this->getYearMap($this->cursorYear, $week_start); $this->cursorDay += $year_map['monthDays'][$this->cursorMonth]; $this->cursorMonth--; $this->rewindMonth(); } } private function rewindHour() { while ($this->cursorHour < 0) { $this->cursorHour += 24; $this->cursorDay--; $this->rewindDay(); } } private function rewindMinute() { while ($this->cursorMinute < 0) { $this->cursorMinute += 60; $this->cursorHour--; $this->rewindHour(); } } private function advanceCursorState( array $cursor, $scale, $interval, $week_start) { $state = array( 'year' => $this->stateYear, 'month' => $this->stateMonth, 'week' => $this->stateWeek, 'day' => $this->stateDay, 'hour' => $this->stateHour, ); // In the common case when the interval is 1, we'll visit every possible // value so we don't need to do any math and can just jump to the first // hour, day, etc. if ($interval == 1) { if ($this->isCursorBehind($cursor, $state, $scale)) { switch ($scale) { case self::SCALE_DAILY: $this->cursorDay = 1; break; case self::SCALE_HOURLY: $this->cursorHour = 0; break; case self::SCALE_WEEKLY: $this->cursorWeek = 1; break; } } return array(false, $state); } $year_map = $this->getYearMap($cursor['year'], $week_start); while ($this->isCursorBehind($cursor, $state, $scale)) { switch ($scale) { case self::SCALE_DAILY: $cursor['day'] += $interval; break; case self::SCALE_HOURLY: $cursor['hour'] += $interval; break; case self::SCALE_WEEKLY: $cursor['week'] += $interval; break; } if ($scale <= self::SCALE_HOURLY) { while ($cursor['hour'] >= 24) { $cursor['hour'] -= 24; $cursor['day']++; } } if ($scale == self::SCALE_WEEKLY) { while ($cursor['week'] > $year_map['weekCount']) { $cursor['week'] -= $year_map['weekCount']; $cursor['year']++; $year_map = $this->getYearMap($cursor['year'], $week_start); } } if ($scale <= self::SCALE_DAILY) { while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) { $cursor['day'] -= $year_map['monthDays'][$cursor['month']]; $cursor['month']++; if ($cursor['month'] > 12) { $cursor['month'] -= 12; $cursor['year']++; $year_map = $this->getYearMap($cursor['year'], $week_start); } } } } switch ($scale) { case self::SCALE_DAILY: $this->cursorDay = $cursor['day']; break; case self::SCALE_HOURLY: $this->cursorHour = $cursor['hour']; break; case self::SCALE_WEEKLY: $this->cursorWeek = $cursor['week']; break; } $skip = $this->isCursorBehind($state, $cursor, $scale); return array($skip, $cursor); } private function isCursorBehind(array $cursor, array $state, $scale) { if ($cursor['year'] < $state['year']) { return true; } else if ($cursor['year'] > $state['year']) { return false; } if ($scale == self::SCALE_WEEKLY) { return false; } if ($cursor['month'] < $state['month']) { return true; } else if ($cursor['month'] > $state['month']) { return false; } if ($scale >= self::SCALE_DAILY) { return false; } if ($cursor['day'] < $state['day']) { return true; } else if ($cursor['day'] > $state['day']) { return false; } if ($scale >= self::SCALE_HOURLY) { return false; } if ($cursor['hour'] < $state['hour']) { return true; } else if ($cursor['hour'] > $state['hour']) { return false; } return false; } } diff --git a/src/parser/calendar/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php index f4a6074..8e0ffec 100644 --- a/src/parser/calendar/ics/PhutilICSParser.php +++ b/src/parser/calendar/ics/PhutilICSParser.php @@ -1,807 +1,811 @@ stack = array(); $this->node = null; $this->cursor = null; $this->warnings = array(); $lines = $this->unfoldICSLines($data); $this->lines = $lines; $root = $this->newICSNode(''); $this->stack[] = $root; $this->node = $root; foreach ($lines as $key => $line) { $this->cursor = $key; $matches = null; if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) { $this->beginParsingNode($matches[1]); } else if (preg_match('(^END:(.*)\z)', $line, $matches)) { $this->endParsingNode($matches[1]); } else { if (count($this->stack) < 2) { $this->raiseParseFailure( self::PARSE_ROOT_PROPERTY, pht( 'Found unexpected property at ICS document root.')); } $this->parseICSProperty($line); } } if (count($this->stack) > 1) { $this->raiseParseFailure( self::PARSE_MISSING_END, pht( 'Expected all "BEGIN:" sections in ICS document to have '. 'corresponding "END:" sections.')); } $this->node = null; $this->lines = null; $this->cursor = null; return $root; } private function getNode() { return $this->node; } private function unfoldICSLines($data) { $lines = phutil_split_lines($data, $retain_endings = false); $this->lines = $lines; // ICS files are wrapped at 75 characters, with overlong lines continued // on the following line with an initial space or tab. Unwrap all of the // lines in the file. // This unwrapping is specifically byte-oriented, not character oriented, // and RFC5545 anticipates that simple implementations may even split UTF8 // characters in the middle. $last = null; foreach ($lines as $idx => $line) { $this->cursor = $idx; if (!preg_match('/^[ \t]/', $line)) { $last = $idx; continue; } if ($last === null) { $this->raiseParseFailure( self::PARSE_INITIAL_UNFOLD, pht( 'First line of ICS file begins with a space or tab, but this '. 'marks a line which should be unfolded.')); } $lines[$last] = $lines[$last].substr($line, 1); unset($lines[$idx]); } return $lines; } private function beginParsingNode($type) { $node = $this->getNode(); $new_node = $this->newICSNode($type); if ($node instanceof PhutilCalendarContainerNode) { $node->appendChild($new_node); } else { $this->raiseParseFailure( self::PARSE_UNEXPECTED_CHILD, pht( 'Found unexpected node "%s" inside node "%s".', $new_node->getAttribute('ics.type'), $node->getAttribute('ics.type'))); } $this->stack[] = $new_node; $this->node = $new_node; return $this; } private function newICSNode($type) { switch ($type) { case '': $node = new PhutilCalendarRootNode(); break; case 'VCALENDAR': $node = new PhutilCalendarDocumentNode(); break; case 'VEVENT': $node = new PhutilCalendarEventNode(); break; default: $node = new PhutilCalendarRawNode(); break; } $node->setAttribute('ics.type', $type); return $node; } private function endParsingNode($type) { $node = $this->getNode(); if ($node instanceof PhutilCalendarRootNode) { $this->raiseParseFailure( self::PARSE_EXTRA_END, pht( 'Found unexpected "END" without a "BEGIN".')); } $old_type = $node->getAttribute('ics.type'); if ($old_type != $type) { $this->raiseParseFailure( self::PARSE_MISMATCHED_SECTIONS, pht( 'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.', $old_type, $type)); } array_pop($this->stack); $this->node = last($this->stack); return $this; } private function parseICSProperty($line) { $matches = null; // Properties begin with an alphanumeric name with no escaping, followed // by either a ";" (to begin a list of parameters) or a ":" (to begin // the actual field body). $ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_PROPERTY, pht( 'Found malformed property in ICS document.')); } $name = $matches[1]; $body = $matches[3]; $has_parameters = ($matches[2] == ';'); $parameters = array(); if ($has_parameters) { // Parameters are a sensible name, a literal "=", a pile of magic, // and then maybe a comma and another parameter. while (true) { // We're going to get the first couple of parts first. $ok = preg_match('(^([^=]+)=)', $body, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_PARAMETER_NAME, pht( 'Found malformed property in ICS document: %s', $body)); } $param_name = $matches[1]; $body = substr($body, strlen($matches[0])); // Now we're going to match zero or more values. $param_values = array(); while (true) { // The value can either be a double-quoted string or an unquoted // string, with some characters forbidden. if (strlen($body) && $body[0] == '"') { $is_quoted = true; $ok = preg_match( '(^"([^\x00-\x08\x10-\x19"]*)")', $body, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_DOUBLE_QUOTE, pht( 'Found malformed double-quoted string in ICS document '. 'parameter value.')); } } else { $is_quoted = false; // It's impossible for this not to match since it can match // nothing, and it's valid for it to match nothing. preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches); } // NOTE: RFC5545 says "Property parameter values that are not in // quoted-strings are case-insensitive." -- that is, the quoted and // unquoted representations are not equivalent. Thus, preserve the // original formatting in case we ever need to respect this. $param_values[] = array( 'value' => $this->unescapeParameterValue($matches[1]), 'quoted' => $is_quoted, ); $body = substr($body, strlen($matches[0])); if (!strlen($body)) { $this->raiseParseFailure( self::PARSE_MISSING_VALUE, pht( 'Expected ":" after parameters in ICS document property.')); } // If we have a comma now, we're going to read another value. Strip // it off and keep going. if ($body[0] == ',') { $body = substr($body, 1); continue; } // If we have a semicolon, we're going to read another parameter. if ($body[0] == ';') { break; } // If we have a colon, this is the last value and also the last // property. Break, then handle the colon below. if ($body[0] == ':') { break; } $short_body = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(32) ->truncateString($body); // We aren't expecting anything else. $this->raiseParseFailure( self::PARSE_UNEXPECTED_TEXT, pht( 'Found unexpected text ("%s") after reading parameter value.', $short_body)); } $parameters[] = array( 'name' => $param_name, 'values' => $param_values, ); if ($body[0] == ';') { $body = substr($body, 1); continue; } if ($body[0] == ':') { $body = substr($body, 1); break; } } } $value = $this->unescapeFieldValue($name, $parameters, $body); $node = $this->getNode(); $raw = $node->getAttribute('ics.properties', array()); $raw[] = array( 'name' => $name, 'parameters' => $parameters, 'value' => $value, ); $node->setAttribute('ics.properties', $raw); switch ($node->getAttribute('ics.type')) { case 'VEVENT': $this->didParseEventProperty($node, $name, $parameters, $value); break; } } private function unescapeParameterValue($data) { // The parameter grammar is adjusted by RFC6868 to permit escaping with // carets. Remove that escaping. // This escaping is a bit weird because it's trying to be backwards // compatible and the original spec didn't think about this and didn't // provide much room to fix things. $out = ''; $esc = false; foreach (phutil_utf8v($data) as $c) { if (!$esc) { if ($c != '^') { $out .= $c; } else { $esc = true; } } else { switch ($c) { case 'n': $out .= "\n"; break; case '^': $out .= '^'; break; case "'": // NOTE: This is " " being decoded into a // double quote! $out .= '"'; break; default: // NOTE: The caret is NOT an escape for any other characters. // This is a "MUST" requirement of RFC6868. $out .= '^'.$c; break; } } } // NOTE: Because caret on its own just means "caret" for backward // compatibility, we don't warn if we're still in escaped mode once we // reach the end of the string. return $out; } private function unescapeFieldValue($name, array $parameters, $data) { // NOTE: The encoding of the field value data is dependent on the field // name (which defines a default encoding) and the parameters (which may // include "VALUE", specifying a type of the data. $default_types = array( 'CALSCALE' => 'TEXT', 'METHOD' => 'TEXT', 'PRODID' => 'TEXT', 'VERSION' => 'TEXT', 'ATTACH' => 'URI', 'CATEGORIES' => 'TEXT', 'CLASS' => 'TEXT', 'COMMENT' => 'TEXT', 'DESCRIPTION' => 'TEXT', // TODO: The spec appears to contradict itself: it says that the value // type is FLOAT, but it also says that this property value is actually // two semicolon-separated values, which is not what FLOAT is defined as. 'GEO' => 'TEXT', 'LOCATION' => 'TEXT', 'PERCENT-COMPLETE' => 'INTEGER', 'PRIORITY' => 'INTEGER', 'RESOURCES' => 'TEXT', 'STATUS' => 'TEXT', 'SUMMARY' => 'TEXT', 'COMPLETED' => 'DATE-TIME', 'DTEND' => 'DATE-TIME', 'DUE' => 'DATE-TIME', 'DTSTART' => 'DATE-TIME', 'DURATION' => 'DURATION', 'FREEBUSY' => 'PERIOD', 'TRANSP' => 'TEXT', 'TZID' => 'TEXT', 'TZNAME' => 'TEXT', 'TZOFFSETFROM' => 'UTC-OFFSET', 'TZOFFSETTO' => 'UTC-OFFSET', 'TZURL' => 'URI', 'ATTENDEE' => 'CAL-ADDRESS', 'CONTACT' => 'TEXT', 'ORGANIZER' => 'CAL-ADDRESS', 'RECURRENCE-ID' => 'DATE-TIME', 'RELATED-TO' => 'TEXT', 'URL' => 'URI', 'UID' => 'TEXT', 'EXDATE' => 'DATE-TIME', 'RDATE' => 'DATE-TIME', 'RRULE' => 'RECUR', 'ACTION' => 'TEXT', 'REPEAT' => 'INTEGER', 'TRIGGER' => 'DURATION', 'CREATED' => 'DATE-TIME', 'DTSTAMP' => 'DATE-TIME', 'LAST-MODIFIED' => 'DATE-TIME', 'SEQUENCE' => 'INTEGER', 'REQUEST-STATUS' => 'TEXT', ); $value_type = idx($default_types, $name, 'TEXT'); foreach ($parameters as $parameter) { if ($parameter['name'] == 'VALUE') { $value_type = idx(head($parameter['values']), 'value'); } } switch ($value_type) { case 'BINARY': $result = base64_decode($data, true); if ($result === false) { $this->raiseParseFailure( self::PARSE_BAD_BASE64, pht( 'Unable to decode base64 data: %s', $data)); } break; case 'BOOLEAN': $map = array( 'true' => true, 'false' => false, ); $result = phutil_utf8_strtolower($data); if (!isset($map[$result])) { $this->raiseParseFailure( self::PARSE_BAD_BOOLEAN, pht( 'Unexpected BOOLEAN value "%s".', $data)); } $result = $map[$result]; break; case 'CAL-ADDRESS': $result = $data; break; case 'DATE': // This is a comma-separated list of "YYYYMMDD" values. $result = explode(',', $data); break; case 'DATE-TIME': if (!strlen($data)) { $result = array(); } else { $result = explode(',', $data); } break; case 'DURATION': if (!strlen($data)) { $result = array(); } else { $result = explode(',', $data); } break; case 'FLOAT': $result = explode(',', $data); foreach ($result as $k => $v) { $result[$k] = (float)$v; } break; case 'INTEGER': $result = explode(',', $data); foreach ($result as $k => $v) { $result[$k] = (int)$v; } break; case 'PERIOD': $result = explode(',', $data); break; case 'RECUR': $result = $data; break; case 'TEXT': $result = $this->unescapeTextValue($data); break; case 'TIME': $result = explode(',', $data); break; case 'URI': $result = $data; break; case 'UTC-OFFSET': $result = $data; break; default: // RFC5545 says we MUST preserve the data for any types we don't // recognize. $result = $data; break; } return array( 'type' => $value_type, 'value' => $result, 'raw' => $data, ); } private function unescapeTextValue($data) { $result = array(); $buf = ''; $esc = false; foreach (phutil_utf8v($data) as $c) { if (!$esc) { if ($c == '\\') { $esc = true; } else if ($c == ',') { $result[] = $buf; $buf = ''; } else { $buf .= $c; } } else { switch ($c) { case 'n': case 'N': $buf .= "\n"; break; default: $buf .= $c; break; } $esc = false; } } if ($esc) { $this->raiseParseFailure( self::PARSE_UNESCAPED_BACKSLASH, pht( 'ICS document contains TEXT value ending with unescaped '. 'backslash.')); } $result[] = $buf; return $result; } private function raiseParseFailure($code, $message) { if ($this->lines && isset($this->lines[$this->cursor])) { $message = pht( "ICS Parse Error near line %s:\n\n>>> %s\n\n%s", $this->cursor + 1, $this->lines[$this->cursor], $message); } else { $message = pht( 'ICS Parse Error: %s', $message); } throw id(new PhutilICSParserException($message)) ->setParserFailureCode($code); } private function raiseWarning($code, $message) { $this->warnings[] = array( 'code' => $code, 'line' => $this->cursor, 'text' => $this->lines[$this->cursor], 'message' => $message, ); return $this; } private function didParseEventProperty( PhutilCalendarEventNode $node, $name, array $parameters, array $value) { switch ($name) { case 'UID': $text = $this->newTextFromProperty($parameters, $value); $node->setUID($text); break; case 'CREATED': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setCreatedDateTime($datetime); break; case 'DTSTAMP': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setModifiedDateTime($datetime); break; case 'SUMMARY': $text = $this->newTextFromProperty($parameters, $value); $node->setName($text); break; case 'DESCRIPTION': $text = $this->newTextFromProperty($parameters, $value); $node->setDescription($text); break; case 'DTSTART': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setStartDateTime($datetime); break; case 'DTEND': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setEndDateTime($datetime); break; case 'DURATION': $duration = $this->newDurationFromProperty($parameters, $value); $node->setDuration($duration); break; case 'RRULE': $rrule = $this->newRecurrenceRuleFromProperty($parameters, $value); $node->setRecurrenceRule($rrule); break; + case 'RECURRENCE-ID': + $text = $this->newTextFromProperty($parameters, $value); + $node->setRecurrenceID($text); + break; } } private function newTextFromProperty(array $parameters, array $value) { $value = $value['value']; return implode("\n\n", $value); } private function newDateTimeFromProperty(array $parameters, array $value) { $value = $value['value']; if (!$value) { $this->raiseParseFailure( self::PARSE_EMPTY_DATETIME, pht( 'Expected DATE-TIME to have exactly one value, found none.')); } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MANY_DATETIME, pht( 'Expected DATE-TIME to have exactly one value, found more than '. 'one.')); } $value = head($value); $tzid = $this->getScalarParameterValue($parameters, 'TZID'); if (preg_match('/Z\z/', $value)) { if ($tzid) { $this->raiseWarning( self::WARN_TZID_UTC, pht( 'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '. 'parameter with value "%s". This violates RFC5545. The TZID '. 'will be ignored, and the value will be interpreted as UTC.', $value, $tzid)); } $tzid = 'UTC'; } else if ($tzid !== null) { $map = DateTimeZone::listIdentifiers(); $map = array_fuse($map); if (empty($map[$tzid])) { $this->raiseParseFailure( self::PARSE_BAD_TZID, pht( 'Timezone "%s" is not a recognized timezone.', $tzid)); } } try { $datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601( $value, $tzid); } catch (Exception $ex) { $this->raiseParseFailure( self::PARSE_BAD_DATETIME, pht( 'Error parsing DATE-TIME: %s', $ex->getMessage())); } return $datetime; } private function newDurationFromProperty(array $parameters, array $value) { $value = $value['value']; if (!$value) { $this->raiseParseFailure( self::PARSE_EMPTY_DURATION, pht( 'Expected DURATION to have exactly one value, found none.')); } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MANY_DURATION, pht( 'Expected DURATION to have exactly one value, found more than '. 'one.')); } $value = head($value); try { $duration = PhutilCalendarDuration::newFromISO8601($value); } catch (Exception $ex) { $this->raiseParseFailure( self::PARSE_BAD_DURATION, pht( 'Invalid DURATION: %s', $ex->getMessage())); } return $duration; } private function newRecurrenceRuleFromProperty(array $parameters, $value) { return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']); } private function getScalarParameterValue( array $parameters, $name, $default = null) { $match = null; foreach ($parameters as $parameter) { if ($parameter['name'] == $name) { $match = $parameter; } } if ($match === null) { return $default; } $value = $match['values']; if (!$value) { // Parameter is specified, but with no value, like "KEY=". Just return // the default, as though the parameter was not specified. return $default; } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MULTIPLE_PARAMETERS, pht( 'Expected parameter "%s" to have at most one value, but found '. 'more than one.', $name)); } return idx(head($value), 'value'); } }