Changeset View
Changeset View
Standalone View
Standalone View
src/applications/calendar/parser/ics/PhutilICSParser.php
- This file was added.
<?php | |||||
final class PhutilICSParser extends Phobject { | |||||
private $stack; | |||||
private $node; | |||||
private $document; | |||||
private $lines; | |||||
private $cursor; | |||||
private $warnings; | |||||
const PARSE_MISSING_END = 'missing-end'; | |||||
const PARSE_INITIAL_UNFOLD = 'initial-unfold'; | |||||
const PARSE_UNEXPECTED_CHILD = 'unexpected-child'; | |||||
const PARSE_EXTRA_END = 'extra-end'; | |||||
const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections'; | |||||
const PARSE_ROOT_PROPERTY = 'root-property'; | |||||
const PARSE_BAD_BASE64 = 'bad-base64'; | |||||
const PARSE_BAD_BOOLEAN = 'bad-boolean'; | |||||
const PARSE_UNEXPECTED_TEXT = 'unexpected-text'; | |||||
const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote'; | |||||
const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter'; | |||||
const PARSE_MALFORMED_PROPERTY = 'malformed-property'; | |||||
const PARSE_MISSING_VALUE = 'missing-value'; | |||||
const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash'; | |||||
const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters'; | |||||
const PARSE_EMPTY_DATETIME = 'empty-datetime'; | |||||
const PARSE_MANY_DATETIME = 'many-datetime'; | |||||
const PARSE_BAD_DATETIME = 'bad-datetime'; | |||||
const PARSE_EMPTY_DURATION = 'empty-duration'; | |||||
const PARSE_MANY_DURATION = 'many-duration'; | |||||
const PARSE_BAD_DURATION = 'bad-duration'; | |||||
const WARN_TZID_UTC = 'warn-tzid-utc'; | |||||
const WARN_TZID_GUESS = 'warn-tzid-guess'; | |||||
const WARN_TZID_IGNORED = 'warn-tzid-ignored'; | |||||
public function parseICSData($data) { | |||||
$this->stack = array(); | |||||
$this->node = null; | |||||
$this->cursor = null; | |||||
$this->warnings = array(); | |||||
$lines = $this->unfoldICSLines($data); | |||||
$this->lines = $lines; | |||||
$root = $this->newICSNode('<ROOT>'); | |||||
$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 '<ROOT>': | |||||
$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 "<caret> <single quote>" 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; | |||||
} | |||||
public function getWarnings() { | |||||
return $this->warnings; | |||||
} | |||||
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; | |||||
case 'ATTENDEE': | |||||
$attendee = $this->newAttendeeFromProperty($parameters, $value); | |||||
$node->addAttendee($attendee); | |||||
break; | |||||
} | |||||
} | |||||
private function newTextFromProperty(array $parameters, array $value) { | |||||
$value = $value['value']; | |||||
return implode("\n\n", $value); | |||||
} | |||||
private function newAttendeeFromProperty(array $parameters, array $value) { | |||||
$uri = $value['value']; | |||||
switch (idx($parameters, 'PARTSTAT')) { | |||||
case 'ACCEPTED': | |||||
$status = PhutilCalendarUserNode::STATUS_ACCEPTED; | |||||
break; | |||||
case 'DECLINED': | |||||
$status = PhutilCalendarUserNode::STATUS_DECLINED; | |||||
break; | |||||
case 'NEEDS-ACTION': | |||||
default: | |||||
$status = PhutilCalendarUserNode::STATUS_INVITED; | |||||
break; | |||||
} | |||||
$name = $this->getScalarParameterValue($parameters, 'CN'); | |||||
return id(new PhutilCalendarUserNode()) | |||||
->setURI($uri) | |||||
->setName($name) | |||||
->setStatus($status); | |||||
} | |||||
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) { | |||||
$tzid = $this->guessTimezone($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'); | |||||
} | |||||
private function guessTimezone($tzid) { | |||||
$map = DateTimeZone::listIdentifiers(); | |||||
$map = array_fuse($map); | |||||
if (isset($map[$tzid])) { | |||||
// This is a real timezone we recognize, so just use it as provided. | |||||
return $tzid; | |||||
} | |||||
// These are alternate names for timezones. | |||||
static $aliases; | |||||
if ($aliases === null) { | |||||
$aliases = array( | |||||
'Etc/GMT' => 'UTC', | |||||
); | |||||
// Load the map of Windows timezones. | |||||
$root_path = dirname(phutil_get_library_root('phutil')); | |||||
$windows_path = $root_path.'/resources/timezones/windows_timezones.json'; | |||||
$windows_data = Filesystem::readFile($windows_path); | |||||
$windows_zones = phutil_json_decode($windows_data); | |||||
$aliases = $aliases + $windows_zones; | |||||
} | |||||
if (isset($aliases[$tzid])) { | |||||
return $aliases[$tzid]; | |||||
} | |||||
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find | |||||
// anything, pick a timezone with that offset. | |||||
$offset_pattern = | |||||
'/'. | |||||
'(?:UTC|GMT)'. | |||||
'\s*'. | |||||
'(?P<sign>[+-])'. | |||||
'\s*'. | |||||
'(?P<h>\d+)'. | |||||
'(?:'. | |||||
'[:.](?P<m>\d+)'. | |||||
')?'. | |||||
'/i'; | |||||
$matches = null; | |||||
if (preg_match($offset_pattern, $tzid, $matches)) { | |||||
$hours = (int)$matches['h']; | |||||
$minutes = (int)idx($matches, 'm'); | |||||
$offset = ($hours * 60 * 60) + ($minutes * 60); | |||||
if (idx($matches, 'sign') == '-') { | |||||
$offset = -$offset; | |||||
} | |||||
// NOTE: We could possibly do better than this, by using the event start | |||||
// time to guess a timezone. However, that won't work for recurring | |||||
// events and would require us to do this work after finishing initial | |||||
// parsing. Since these unusual offset-based timezones appear to be rare, | |||||
// the benefit may not be worth the complexity. | |||||
$now = new DateTime('@'.time()); | |||||
foreach ($map as $identifier) { | |||||
$zone = new DateTimeZone($identifier); | |||||
if ($zone->getOffset($now) == $offset) { | |||||
$this->raiseWarning( | |||||
self::WARN_TZID_GUESS, | |||||
pht( | |||||
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".', | |||||
$tzid, | |||||
$identifier, | |||||
$matches[0])); | |||||
return $identifier; | |||||
} | |||||
} | |||||
} | |||||
$this->raiseWarning( | |||||
self::WARN_TZID_IGNORED, | |||||
pht( | |||||
'TZID "%s" is unknown, using UTC instead.', | |||||
$tzid)); | |||||
return 'UTC'; | |||||
} | |||||
} |