Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15384940
D16551.id39835.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
16 KB
Referenced Files
None
Subscribers
None
D16551.id39835.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D16551: Write basic ICS files from Phutil intermediate objects
Attached
Detach File
Event Timeline
Log In to Comment