diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -236,6 +236,8 @@ 'PhutilICSParser' => 'parser/calendar/ics/PhutilICSParser.php', 'PhutilICSParserException' => 'parser/calendar/ics/PhutilICSParserException.php', 'PhutilICSParserTestCase' => 'parser/calendar/ics/__tests__/PhutilICSParserTestCase.php', + 'PhutilICSWriter' => 'parser/calendar/ics/PhutilICSWriter.php', + 'PhutilICSWriterTestCase' => 'parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php', 'PhutilINIParserException' => 'parser/exception/PhutilINIParserException.php', 'PhutilIPAddress' => 'ip/PhutilIPAddress.php', 'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php', @@ -823,6 +825,8 @@ 'PhutilICSParser' => 'Phobject', 'PhutilICSParserException' => 'Exception', 'PhutilICSParserTestCase' => 'PhutilTestCase', + 'PhutilICSWriter' => 'Phobject', + 'PhutilICSWriterTestCase' => 'PhutilTestCase', 'PhutilINIParserException' => 'Exception', 'PhutilIPAddress' => 'Phobject', 'PhutilIPAddressTestCase' => 'PhutilTestCase', 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 @@ -10,7 +10,51 @@ private $minute = 0; private $second = 0; private $timezone; - private $viewerTimezone; + + public static function newFromISO8601($value, $timezone = 'UTC') { + $pattern = + '/^'. + '(?P\d{4})(?P\d{2})(?P\d{2})'. + '(?:'. + 'T(?P\d{2})(?P\d{2})(?P\d{2})(?Z)?'. + ')?'. + '\z/'; + + $matches = null; + $ok = preg_match($pattern, $value, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Expected ISO8601 datetime in the format "19990105T112233Z", '. + 'found "%s".', + $value)); + } + + if (isset($matches['z'])) { + if ($timezone != 'UTC') { + throw new Exception( + pht( + 'ISO8601 date ends in "Z" indicating UTC, but a timezone other '. + 'than UTC ("%s") was specified.', + $timezone)); + } + } + + $datetime = id(new self()) + ->setYear((int)$matches['y']) + ->setMonth((int)$matches['m']) + ->setDay((int)$matches['d']) + ->setTimezone($timezone); + + if (isset($matches['h'])) { + $datetime + ->setHour((int)$matches['h']) + ->setMinute((int)$matches['i']) + ->setSecond((int)$matches['s']); + } + + return $datetime; + } public function setYear($year) { $this->year = $year; 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 @@ -19,6 +19,12 @@ return (int)$datetime->format('U'); } + public function getISO8601() { + $datetime = $this->newPHPDateTime(); + $datetime->setTimezone(new DateTimeZone('UTC')); + return $datetime->format('Ymd\\THis\\Z'); + } + abstract protected function newPHPDateTimeZone(); abstract protected function newPHPDateTime(); diff --git a/src/parser/calendar/data/PhutilCalendarEventNode.php b/src/parser/calendar/data/PhutilCalendarEventNode.php --- a/src/parser/calendar/data/PhutilCalendarEventNode.php +++ b/src/parser/calendar/data/PhutilCalendarEventNode.php @@ -5,11 +5,23 @@ const NODETYPE = 'event'; + private $uid; private $name; private $description; private $startDateTime; private $endDateTime; private $duration; + private $createdDateTime; + private $modifiedDateTime; + + public function setUID($uid) { + $this->uid = $uid; + return $this; + } + + public function getUID() { + return $this->uid; + } public function setName($name) { $this->name = $name; @@ -29,8 +41,8 @@ return $this->description; } - public function setStartDateTime(PhutilCalendarDateTime $start_date_time) { - $this->startDateTime = $start_date_time; + public function setStartDateTime(PhutilCalendarDateTime $start) { + $this->startDateTime = $start; return $this; } @@ -38,8 +50,8 @@ return $this->startDateTime; } - public function setEndDateTime(PhutilCalendarDateTime $end_date_time) { - $this->endDateTime = $end_date_time; + public function setEndDateTime(PhutilCalendarDateTime $end) { + $this->endDateTime = $end; return $this; } @@ -69,5 +81,22 @@ return $this->duration; } + public function setCreatedDateTime(PhutilCalendarDateTime $created) { + $this->createdDateTime = $created; + return $this; + } + + public function getCreatedDateTime() { + return $this->createdDateTime; + } + + public function setModifiedDateTime(PhutilCalendarDateTime $modified) { + $this->modifiedDateTime = $modified; + return $this; + } + + public function getModifiedDateTime() { + return $this->modifiedDateTime; + } } 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 @@ -92,6 +92,11 @@ // 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; @@ -618,6 +623,18 @@ 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); @@ -667,29 +684,9 @@ } $value = head($value); - - $pattern = - '/^'. - '(?P\d{4})(?P\d{2})(?P\d{2})'. - '(?:'. - 'T(?P\d{2})(?P\d{2})(?P\d{2})(?Z)?'. - ')?'. - '\z/'; - - $matches = null; - $ok = preg_match($pattern, $value, $matches); - if (!$ok) { - $this->raiseParseFailure( - self::PARSE_BAD_DATETIME, - pht( - 'Expected DATE-TIME in the format "19990105T112233Z", found '. - '"%s".', - $value)); - } - $tzid = $this->getScalarParameterValue($parameters, 'TZID'); - if (isset($matches['z'])) { + if (preg_match('/Z\z/', $value)) { if ($tzid) { $this->raiseWarning( self::WARN_TZID_UTC, @@ -713,17 +710,16 @@ } } - $datetime = id(new PhutilCalendarAbsoluteDateTime()) - ->setYear((int)$matches['y']) - ->setMonth((int)$matches['m']) - ->setDay((int)$matches['d']) - ->setTimezone($tzid); - - if (isset($matches['h'])) { - $datetime - ->setHour((int)$matches['h']) - ->setMinute((int)$matches['i']) - ->setSecond((int)$matches['s']); + 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; diff --git a/src/parser/calendar/ics/PhutilICSWriter.php b/src/parser/calendar/ics/PhutilICSWriter.php new file mode 100644 --- /dev/null +++ b/src/parser/calendar/ics/PhutilICSWriter.php @@ -0,0 +1,228 @@ +getChildren() as $child) { + $out[] = $this->writeNode($child); + } + + return implode('', $out); + } + + private function writeNode(PhutilCalendarNode $node) { + if (!$this->getICSNodeType($node)) { + return null; + } + + $out = array(); + + $out[] = $this->writeBeginNode($node); + $out[] = $this->writeNodeProperties($node); + + if ($node instanceof PhutilCalendarContainerNode) { + foreach ($node->getChildren() as $child) { + $out[] = $this->writeNode($child); + } + } + + $out[] = $this->writeEndNode($node); + + return implode('', $out); + } + + private function writeBeginNode(PhutilCalendarNode $node) { + $type = $this->getICSNodeType($node); + return $this->wrapICSLine("BEGIN:{$type}"); + } + + private function writeEndNode(PhutilCalendarNode $node) { + $type = $this->getICSNodeType($node); + return $this->wrapICSLine("END:{$type}"); + } + + private function writeNodeProperties(PhutilCalendarNode $node) { + $properties = $this->getNodeProperties($node); + + $out = array(); + foreach ($properties as $property) { + $propname = $property['name']; + $propvalue = $property['value']; + + $propline = array(); + $propline[] = $propname; + + foreach ($property['parameters'] as $parameter) { + $paramname = $parameter['name']; + $paramvalue = 'TODO'; + $propline[] = ";{$paramname}={$paramvalue}"; + } + + $propline[] = ":{$propvalue}"; + $propline = implode('', $propline); + + $out[] = $this->wrapICSLine($propline); + } + + return implode('', $out); + } + + private function getICSNodeType(PhutilCalendarNode $node) { + switch ($node->getNodeType()) { + case PhutilCalendarDocumentNode::NODETYPE: + return 'VCALENDAR'; + case PhutilCalendarEventNode::NODETYPE: + return 'VEVENT'; + default: + return null; + } + } + + private function wrapICSLine($line) { + $out = array(); + $buf = ''; + + // NOTE: The line may contain sequences of combining characters which are + // more than 80 bytes in length. If it does, we'll split them in the + // middle of the sequence. This is okay and generally anticipated by + // RFC5545, which even allows implementations to split multibyte + // characters. The sequence will be stitched back together properly by + // whatever is parsing things. + + foreach (phutil_utf8v($line) as $character) { + // If adding this character would bring the line over 75 bytes, start + // a new line. + if (strlen($buf) + strlen($character) > 75) { + $out[] = $buf."\n"; + $buf = ' '; + } + + $buf .= $character; + } + + $out[] = $buf."\n"; + + return implode('', $out); + } + + private function getNodeProperties(PhutilCalendarNode $node) { + switch ($node->getNodeType()) { + case PhutilCalendarDocumentNode::NODETYPE: + return array(); + case PhutilCalendarEventNode::NODETYPE: + return $this->getEventNodeProperties($node); + default: + return array(); + } + } + + private function getEventNodeProperties(PhutilCalendarEventNode $event) { + $properties = array(); + + $uid = $event->getUID(); + if (!strlen($uid)) { + throw new Exception( + pht( + 'Unable to write ICS document: event has no UID, but each event '. + 'MUST have a UID.')); + } + $properties[] = $this->newTextProperty( + 'UID', + $uid); + + $created = $event->getCreatedDateTime(); + if ($created) { + $properties[] = $this->newDateTimeProperty( + 'CREATED', + $event->getCreatedDateTime()); + } + + $dtstamp = $event->getModifiedDateTime(); + if (!$dtstamp) { + throw new Exception( + pht( + 'Unable to write ICS document: event has no modified time, but '. + 'each event MUST have a modified time.')); + } + $properties[] = $this->newDateTimeProperty( + 'DTSTAMP', + $dtstamp); + + $dtstart = $event->getStartDateTime(); + if ($dtstart) { + $properties[] = $this->newDateTimeProperty( + 'DTSTART', + $dtstart); + } + + $dtend = $event->getEndDateTime(); + if ($dtend) { + $properties[] = $this->newDateTimeProperty( + 'DTEND', + $event->getEndDateTime()); + } + + $name = $event->getName(); + if (strlen($name)) { + $properties[] = $this->newTextProperty( + 'SUMMARY', + $name); + } + + $description = $event->getDescription(); + if (strlen($description)) { + $properties[] = $this->newTextProperty( + 'DESCRIPTION', + $description); + } + + return $properties; + } + + private function newTextProperty( + $name, + $value, + array $parameters = array()) { + + $map = array( + '\\' => '\\\\', + ',' => '\\,', + "\n" => '\\n', + ); + + $value = (array)$value; + foreach ($value as $k => $v) { + $v = str_replace(array_keys($map), array_values($map), $v); + $value[$k] = $v; + } + + $value = implode(',', $value); + + return $this->newProperty($name, $value, $parameters); + } + + private function newDateTimeProperty( + $name, + PhutilCalendarDateTime $value, + array $parameters = array()) { + $datetime = $value->getISO8601(); + return $this->newProperty($name, $datetime, $parameters); + } + + private function newProperty( + $name, + $value, + array $parameters = array()) { + + // TODO: Actually handle parameters. + + return array( + 'name' => $name, + 'value' => $value, + 'parameters' => array(), + ); + } + +} diff --git a/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php new file mode 100644 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php @@ -0,0 +1,61 @@ +setUID('tea-time') + ->setName('Tea Time') + ->setDescription( + "Tea and, perhaps, crumpets.\n". + "Your presence is requested!\n". + "This is a long list of types of tea to test line wrapping: {$teas}.") + ->setCreatedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) + ->setModifiedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) + ->setStartDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z')) + ->setEndDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z')); + + $ics_data = $this->writeICSSingleEvent($event); + + $this->assertICS('writer-tea-time.ics', $ics_data); + } + + private function writeICSSingleEvent(PhutilCalendarEventNode $event) { + $calendar = id(new PhutilCalendarDocumentNode()) + ->appendChild($event); + + $root = id(new PhutilCalendarRootNode()) + ->appendChild($calendar); + + return $this->writeICS($root); + } + + private function writeICS(PhutilCalendarRootNode $root) { + return id(new PhutilICSWriter()) + ->writeICSDocument($root); + } + + private function assertICS($name, $actual) { + $path = dirname(__FILE__).'/data/'.$name; + $data = Filesystem::readFile($path); + $this->assertEqual($data, $actual, pht('ICS: %s', $name)); + } + +} diff --git a/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics new file mode 100644 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +UID:tea-time +CREATED:20160915T070000Z +DTSTAMP:20160915T070000Z +DTSTART:20160916T150000Z +DTEND:20160916T160000Z +SUMMARY:Tea Time +DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi + s is a long list of types of tea to test line wrapping: earl grey tea\, En + glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te + a\, tea with milk. +END:VEVENT +END:VCALENDAR