Page MenuHomePhabricator

D16551.id39835.diff
No OneTemporary

D16551.id39835.diff

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<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
+ '(?:'.
+ 'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>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<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
- '(?:'.
- 'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>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 @@
+<?php
+
+final class PhutilICSWriter extends Phobject {
+
+ public function writeICSDocument(PhutilCalendarRootNode $node) {
+ $out = array();
+
+ foreach ($node->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 @@
+<?php
+
+final class PhutilICSWriterTestCase extends PhutilTestCase {
+
+ public function testICSWriter() {
+ $teas = array(
+ 'earl grey tea',
+ 'English breakfast tea',
+ 'black tea',
+ 'green tea',
+ 't-rex',
+ 'oolong tea',
+ 'mint tea',
+ 'tea with milk',
+ );
+
+ $teas = implode(', ', $teas);
+
+ $event = id(new PhutilCalendarEventNode())
+ ->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

File Metadata

Mime Type
text/plain
Expires
Sat, Mar 15, 9:14 PM (1 w, 2 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7672351
Default Alt Text
D16551.id39835.diff (16 KB)

Event Timeline