diff --git a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php --- a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php +++ b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php @@ -75,6 +75,92 @@ ->setTimezone($timezone); } + public static function newFromDictionary(array $dict) { + static $keys; + if ($keys === null) { + $keys = array_fuse( + array( + 'kind', + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'timezone', + 'isAllDay', + )); + } + + foreach ($dict as $key => $value) { + if (!isset($keys[$key])) { + throw new Exception( + pht( + 'Unexpected key "%s" in datetime dictionary, expected keys: %s.', + $key, + implode(', ', array_keys($keys)))); + } + } + + if (idx($dict, 'kind') !== 'absolute') { + throw new Exception( + pht( + 'Expected key "%s" with value "%s" in datetime dictionary.', + 'kind', + 'absolute')); + } + + if (!isset($dict['year'])) { + throw new Exception( + pht( + 'Expected key "%s" in datetime dictionary.', + 'year')); + } + + $datetime = id(new self()) + ->setYear(idx($dict, 'year')) + ->setMonth(idx($dict, 'month', 1)) + ->setDay(idx($dict, 'day', 1)) + ->setHour(idx($dict, 'hour', 0)) + ->setMinute(idx($dict, 'minute', 0)) + ->setSecond(idx($dict, 'second', 0)) + ->setTimezone(idx($dict, 'timezone')) + ->setIsAllDay(idx($dict, 'isAllDay', false)); + + return $datetime; + } + + public function newRelativeDateTime($duration) { + if (is_string($duration)) { + $duration = PhutilCalendarDuration::newFromISO8601($duration); + } + + if (!($duration instanceof PhutilCalendarDuration)) { + throw new Exception( + pht( + 'Expected "PhutilCalendarDuration" object or ISO8601 duration '. + 'string.')); + } + + return id(new PhutilCalendarRelativeDateTime()) + ->setOrigin($this) + ->setDuration($duration); + } + + public function toDictionary() { + return array( + 'kind' => 'absolute', + 'year' => $this->getYear(), + 'month' => $this->getMonth(), + 'day' => $this->getDay(), + 'hour' => $this->getHour(), + 'minute' => $this->getMinute(), + 'second' => $this->getSecond(), + 'timezone' => $this->getTimezone(), + 'isAllDay' => $this->getIsAllDay(), + ); + } + public function setYear($year) { $this->year = $year; return $this; @@ -154,12 +240,12 @@ 'Datetime has no timezone or viewer timezone.')); } - protected function newPHPDateTimeZone() { + public function newPHPDateTimeZone() { $zone = $this->getEffectiveTimezone(); return new DateTimeZone($zone); } - protected function newPHPDateTime() { + public function newPHPDateTime() { $zone = $this->newPHPDateTimeZone(); $y = $this->getYear(); diff --git a/src/parser/calendar/data/PhutilCalendarDateTime.php b/src/parser/calendar/data/PhutilCalendarDateTime.php --- a/src/parser/calendar/data/PhutilCalendarDateTime.php +++ b/src/parser/calendar/data/PhutilCalendarDateTime.php @@ -31,16 +31,31 @@ public function getISO8601() { $datetime = $this->newPHPDateTime(); - $datetime->setTimezone(new DateTimeZone('UTC')); if ($this->getIsAllDay()) { return $datetime->format('Ymd'); - } else { + } else if ($this->getTimezone()) { + // With a timezone, the event occurs at a specific second universally. + // We return the UTC representation of that point in time. + $datetime->setTimezone(new DateTimeZone('UTC')); return $datetime->format('Ymd\\THis\\Z'); + } else { + // With no timezone, events are "floating" and occur at local time. + // We return a representation without the "Z". + return $datetime->format('Ymd\\THis'); } } - abstract protected function newPHPDateTimeZone(); - abstract protected function newPHPDateTime(); + public function newAbsoluteDateTime() { + $epoch = $this->getEpoch(); + $timezone = $this->getTimezone(); + return PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch, $timezone) + ->setIsAllDay($this->getIsAllDay()) + ->setViewerTimezone($this->getViewerTimezone()); + } + + abstract public function newPHPDateTimeZone(); + abstract public function newPHPDateTime(); + abstract public function getTimezone(); } diff --git a/src/parser/calendar/data/PhutilCalendarDuration.php b/src/parser/calendar/data/PhutilCalendarDuration.php --- a/src/parser/calendar/data/PhutilCalendarDuration.php +++ b/src/parser/calendar/data/PhutilCalendarDuration.php @@ -3,12 +3,127 @@ final class PhutilCalendarDuration extends Phobject { private $isNegative = false; - private $days = 0; private $weeks = 0; + private $days = 0; private $hours = 0; private $minutes = 0; private $seconds = 0; + public static function newFromDictionary(array $dict) { + static $keys; + if ($keys === null) { + $keys = array_fuse( + array( + 'isNegative', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', + )); + } + + foreach ($dict as $key => $value) { + if (!isset($keys[$key])) { + throw new Exception( + pht( + 'Unexpected key "%s" in duration dictionary, expected keys: %s.', + $key, + implode(', ', array_keys($keys)))); + } + } + + $duration = id(new self()) + ->setIsNegative(idx($dict, 'isNegative', false)) + ->setWeeks(idx($dict, 'weeks', 0)) + ->setDays(idx($dict, 'days', 0)) + ->setHours(idx($dict, 'hours', 0)) + ->setMinutes(idx($dict, 'minutes', 0)) + ->setSeconds(idx($dict, 'seconds', 0)); + + return $duration; + } + + public function toDictionary() { + return array( + 'isNegative' => $this->getIsNegative(), + 'weeks' => $this->getWeeks(), + 'days' => $this->getDays(), + 'hours' => $this->getHours(), + 'minutes' => $this->getMinutes(), + 'seconds' => $this->getSeconds(), + ); + } + + public static function newFromISO8601($value) { + $pattern = + '/^'. + '(?P[+-])?'. + 'P'. + '(?:'. + '(?P\d+)W'. + '|'. + '(?:(?:(?P\d+)D)?'. + '(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?'. + ')'. + ')'. + '\z/'; + + $matches = null; + $ok = preg_match($pattern, $value, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Expected ISO8601 duration in the format "P12DT3H4M5S", found '. + '"%s".', + $value)); + } + + $is_negative = (idx($matches, 'sign') == '-'); + + return id(new self()) + ->setIsNegative($is_negative) + ->setWeeks((int)idx($matches, 'W', 0)) + ->setDays((int)idx($matches, 'D', 0)) + ->setHours((int)idx($matches, 'H', 0)) + ->setMinutes((int)idx($matches, 'M', 0)) + ->setSeconds((int)idx($matches, 'S', 0)); + } + + public function toISO8601() { + $parts = array(); + $parts[] = 'P'; + + $weeks = $this->getWeeks(); + if ($weeks) { + $parts[] = $weeks.'W'; + } else { + $days = $this->getDays(); + if ($days) { + $parts[] = $days.'D'; + } + + $parts[] = 'T'; + + $hours = $this->getHours(); + if ($hours) { + $parts[] = $hours.'H'; + } + + $minutes = $this->getMinutes(); + if ($minutes) { + $parts[] = $minutes.'M'; + } + + $seconds = $this->getSeconds(); + if ($seconds) { + $parts[] = $seconds.'S'; + } + } + + return implode('', $parts); + } + public function setIsNegative($is_negative) { $this->isNegative = $is_negative; return $this; @@ -18,22 +133,22 @@ return $this->isNegative; } - public function setDays($days) { - $this->days = $days; + public function setWeeks($weeks) { + $this->weeks = $weeks; return $this; } - public function getDays() { - return $this->days; + public function getWeeks() { + return $this->weeks; } - public function setWeeks($weeks) { - $this->weeks = $weeks; + public function setDays($days) { + $this->days = $days; return $this; } - public function getWeeks() { - return $this->weeks; + public function getDays() { + return $this->days; } public function setHours($hours) { diff --git a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php --- a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php +++ b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php @@ -32,12 +32,16 @@ return $this->getProxy()->getIsAllDay(); } - protected function newPHPDateTimezone() { + public function newPHPDateTimezone() { return $this->getProxy()->newPHPDateTimezone(); } - protected function newPHPDateTime() { + public function newPHPDateTime() { return $this->getProxy()->newPHPDateTime(); } + public function getTimezone() { + return $this->getProxy()->getTimezone(); + } + } diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -703,6 +703,7 @@ } $result = id(new PhutilCalendarAbsoluteDateTime()) + ->setTimezone($this->getStartDateTime()->getTimezone()) ->setViewerTimezone($this->getViewerTimezone()) ->setYear($this->stateYear) ->setMonth($this->stateMonth) diff --git a/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php b/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php --- a/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php +++ b/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php @@ -22,7 +22,7 @@ return $this->duration; } - protected function newPHPDateTime() { + public function newPHPDateTime() { $datetime = parent::newPHPDateTime(); $duration = $this->getDuration(); diff --git a/src/parser/calendar/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php --- a/src/parser/calendar/ics/PhutilICSParser.php +++ b/src/parser/calendar/ics/PhutilICSParser.php @@ -746,40 +746,16 @@ $value = head($value); - $pattern = - '/^'. - '(?P[+-])?'. - 'P'. - '(?:'. - '(?P\d+)W'. - '|'. - '(?:(?:(?P\d+)D)?'. - '(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?'. - ')'. - ')'. - '\z/'; - - $matches = null; - $ok = preg_match($pattern, $value, $matches); - if (!$ok) { + try { + $duration = PhutilCalendarDuration::newFromISO8601($value); + } catch (Exception $ex) { $this->raiseParseFailure( self::PARSE_BAD_DURATION, pht( - 'Expected DURATION in the format "P12DT3H4M5S", found '. - '"%s".', - $value)); + 'Invalid DURATION: %s', + $ex->getMessage())); } - $is_negative = (idx($matches, 'sign') == '-'); - - $duration = id(new PhutilCalendarDuration()) - ->setIsNegative($is_negative) - ->setWeeks((int)idx($matches, 'W', 0)) - ->setDays((int)idx($matches, 'D', 0)) - ->setHours((int)idx($matches, 'H', 0)) - ->setMinutes((int)idx($matches, 'M', 0)) - ->setSeconds((int)idx($matches, 'S', 0)); - return $duration; }