@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhutilICSWriter extends Phobject {
4
5 public function writeICSDocument(PhutilCalendarRootNode $node) {
6 $out = array();
7
8 foreach ($node->getChildren() as $child) {
9 $out[] = $this->writeNode($child);
10 }
11
12 return implode('', $out);
13 }
14
15 private function writeNode(PhutilCalendarNode $node) {
16 if (!$this->getICSNodeType($node)) {
17 return null;
18 }
19
20 $out = array();
21
22 $out[] = $this->writeBeginNode($node);
23 $out[] = $this->writeNodeProperties($node);
24
25 if ($node instanceof PhutilCalendarContainerNode) {
26 foreach ($node->getChildren() as $child) {
27 $out[] = $this->writeNode($child);
28 }
29 }
30
31 $out[] = $this->writeEndNode($node);
32
33 return implode('', $out);
34 }
35
36 private function writeBeginNode(PhutilCalendarNode $node) {
37 $type = $this->getICSNodeType($node);
38 return $this->wrapICSLine("BEGIN:{$type}");
39 }
40
41 private function writeEndNode(PhutilCalendarNode $node) {
42 $type = $this->getICSNodeType($node);
43 return $this->wrapICSLine("END:{$type}");
44 }
45
46 private function writeNodeProperties(PhutilCalendarNode $node) {
47 $properties = $this->getNodeProperties($node);
48
49 $out = array();
50 foreach ($properties as $property) {
51 $propname = $property['name'];
52 $propvalue = $property['value'];
53
54 $propline = array();
55 $propline[] = $propname;
56
57 foreach ($property['parameters'] as $parameter) {
58 $paramname = $parameter['name'];
59 $paramvalue = $parameter['value'];
60 $propline[] = ";{$paramname}={$paramvalue}";
61 }
62
63 $propline[] = ":{$propvalue}";
64 $propline = implode('', $propline);
65
66 $out[] = $this->wrapICSLine($propline);
67 }
68
69 return implode('', $out);
70 }
71
72 private function getICSNodeType(PhutilCalendarNode $node) {
73 switch ($node->getNodeType()) {
74 case PhutilCalendarDocumentNode::NODETYPE:
75 return 'VCALENDAR';
76 case PhutilCalendarEventNode::NODETYPE:
77 return 'VEVENT';
78 default:
79 return null;
80 }
81 }
82
83 private function wrapICSLine($line) {
84 $out = array();
85 $buf = '';
86
87 // NOTE: The line may contain sequences of combining characters which are
88 // more than 80 bytes in length. If it does, we'll split them in the
89 // middle of the sequence. This is okay and generally anticipated by
90 // RFC5545, which even allows implementations to split multibyte
91 // characters. The sequence will be stitched back together properly by
92 // whatever is parsing things.
93
94 foreach (phutil_utf8v($line) as $character) {
95 // If adding this character would bring the line over 75 bytes, start
96 // a new line.
97 if (strlen($buf) + strlen($character) > 75) {
98 $out[] = $buf."\r\n";
99 $buf = ' ';
100 }
101
102 $buf .= $character;
103 }
104
105 $out[] = $buf."\r\n";
106
107 return implode('', $out);
108 }
109
110 private function getNodeProperties(PhutilCalendarNode $node) {
111 switch ($node->getNodeType()) {
112 case PhutilCalendarDocumentNode::NODETYPE:
113 return $this->getDocumentNodeProperties($node);
114 case PhutilCalendarEventNode::NODETYPE:
115 return $this->getEventNodeProperties($node);
116 default:
117 return array();
118 }
119 }
120
121 private function getDocumentNodeProperties(
122 PhutilCalendarDocumentNode $event) {
123 $properties = array();
124
125 $properties[] = $this->newTextProperty(
126 'VERSION',
127 '2.0');
128
129 $properties[] = $this->newTextProperty(
130 'PRODID',
131 self::getICSPRODID());
132
133 return $properties;
134 }
135
136 public static function getICSPRODID() {
137 return '-//Phacility//Phabricator//EN';
138 }
139
140 private function getEventNodeProperties(PhutilCalendarEventNode $event) {
141 $properties = array();
142
143 $uid = $event->getUID();
144 if (!strlen($uid)) {
145 throw new Exception(
146 pht(
147 'Unable to write ICS document: event has no UID, but each event '.
148 'MUST have a UID.'));
149 }
150 $properties[] = $this->newTextProperty(
151 'UID',
152 $uid);
153
154 $created = $event->getCreatedDateTime();
155 if ($created) {
156 $properties[] = $this->newDateTimeProperty(
157 'CREATED',
158 $event->getCreatedDateTime());
159 }
160
161 $dtstamp = $event->getModifiedDateTime();
162 if (!$dtstamp) {
163 throw new Exception(
164 pht(
165 'Unable to write ICS document: event has no modified time, but '.
166 'each event MUST have a modified time.'));
167 }
168 $properties[] = $this->newDateTimeProperty(
169 'DTSTAMP',
170 $dtstamp);
171
172 $dtstart = $event->getStartDateTime();
173 if ($dtstart) {
174 $properties[] = $this->newDateTimeProperty(
175 'DTSTART',
176 $dtstart);
177 }
178
179 $dtend = $event->getEndDateTime();
180 if ($dtend) {
181 $properties[] = $this->newDateTimeProperty(
182 'DTEND',
183 $event->getEndDateTime());
184 }
185
186 $name = $event->getName();
187 if (phutil_nonempty_string($name)) {
188 $properties[] = $this->newTextProperty(
189 'SUMMARY',
190 $name);
191 }
192
193 $description = $event->getDescription();
194 if (phutil_nonempty_string($description)) {
195 $properties[] = $this->newTextProperty(
196 'DESCRIPTION',
197 $description);
198 }
199
200 $organizer = $event->getOrganizer();
201 if ($organizer) {
202 $properties[] = $this->newUserProperty(
203 'ORGANIZER',
204 $organizer);
205 }
206
207 $attendees = $event->getAttendees();
208 if ($attendees) {
209 foreach ($attendees as $attendee) {
210 $properties[] = $this->newUserProperty(
211 'ATTENDEE',
212 $attendee);
213 }
214 }
215
216 // In the future you may want to add export support
217 // to the "Time Trasparency" field. In case, please tell us why.
218 // No one needs it at the moment. This is not even persisted
219 // in the event object, so, this cannot be exported.
220// $transp = $event->getTimeTransparency();
221// if ($transp) {
222// $properties[] = $this->newTextProperty(
223// 'TRANSP',
224// $transp);
225// }
226
227 $rrule = $event->getRecurrenceRule();
228 if ($rrule) {
229 $properties[] = $this->newRRULEProperty(
230 'RRULE',
231 $rrule);
232 }
233
234 $recurrence_id = $event->getRecurrenceID();
235 if ($recurrence_id) {
236 $properties[] = $this->newTextProperty(
237 'RECURRENCE-ID',
238 $recurrence_id);
239 }
240
241 $exdates = $event->getRecurrenceExceptions();
242 if ($exdates) {
243 $properties[] = $this->newDateTimesProperty(
244 'EXDATE',
245 $exdates);
246 }
247
248 $rdates = $event->getRecurrenceDates();
249 if ($rdates) {
250 $properties[] = $this->newDateTimesProperty(
251 'RDATE',
252 $rdates);
253 }
254
255 return $properties;
256 }
257
258 private function newTextProperty(
259 $name,
260 $value,
261 array $parameters = array()) {
262
263 $map = array(
264 '\\' => '\\\\',
265 ',' => '\\,',
266 "\n" => '\\n',
267 );
268
269 $value = (array)$value;
270 foreach ($value as $k => $v) {
271 $v = str_replace(array_keys($map), array_values($map), $v);
272 $value[$k] = $v;
273 }
274
275 $value = implode(',', $value);
276
277 return $this->newProperty($name, $value, $parameters);
278 }
279
280 private function newDateTimeProperty(
281 $name,
282 PhutilCalendarDateTime $value,
283 array $parameters = array()) {
284
285 return $this->newDateTimesProperty($name, array($value), $parameters);
286 }
287
288 /**
289 * @param $name
290 * @param array<PhutilCalendarDateTime> $values
291 * @param array $parameters
292 */
293 private function newDateTimesProperty(
294 $name,
295 array $values,
296 array $parameters = array()) {
297 assert_instances_of($values, PhutilCalendarDateTime::class);
298
299 if (head($values)->getIsAllDay()) {
300 $parameters[] = array(
301 'name' => 'VALUE',
302 'values' => array(
303 'DATE',
304 ),
305 );
306 }
307
308 $datetimes = array();
309 foreach ($values as $value) {
310 $datetimes[] = $value->getISO8601();
311 }
312 $datetimes = implode(';', $datetimes);
313
314 return $this->newProperty($name, $datetimes, $parameters);
315 }
316
317 private function newUserProperty(
318 $name,
319 PhutilCalendarUserNode $value,
320 array $parameters = array()) {
321
322 $parameters[] = array(
323 'name' => 'CN',
324 'values' => array(
325 $value->getName(),
326 ),
327 );
328
329 $partstat = null;
330 switch ($value->getStatus()) {
331 case PhutilCalendarUserNode::STATUS_INVITED:
332 $partstat = 'NEEDS-ACTION';
333 break;
334 case PhutilCalendarUserNode::STATUS_ACCEPTED:
335 $partstat = 'ACCEPTED';
336 break;
337 case PhutilCalendarUserNode::STATUS_DECLINED:
338 $partstat = 'DECLINED';
339 break;
340 }
341
342 if ($partstat !== null) {
343 $parameters[] = array(
344 'name' => 'PARTSTAT',
345 'values' => array(
346 $partstat,
347 ),
348 );
349 }
350
351 // TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it
352 // isn't clear if these are important to external programs or not.
353
354 return $this->newProperty($name, $value->getURI(), $parameters);
355 }
356
357 private function newRRULEProperty(
358 $name,
359 PhutilCalendarRecurrenceRule $rule,
360 array $parameters = array()) {
361
362 $value = $rule->toRRULE();
363 return $this->newProperty($name, $value, $parameters);
364 }
365
366 private function newProperty(
367 $name,
368 $value,
369 array $parameters = array()) {
370
371 $map = array(
372 '^' => '^^',
373 "\n" => '^n',
374 '"' => "^'",
375 );
376
377 $writable_params = array();
378 foreach ($parameters as $k => $parameter) {
379 $value_list = array();
380 foreach ($parameter['values'] as $v) {
381 $v = str_replace(array_keys($map), array_values($map), $v);
382
383 // If the parameter value isn't a very simple one, quote it.
384
385 // RFC5545 says that we MUST quote it if it has a colon, a semicolon,
386 // or a comma, and that we MUST quote it if it's a URI.
387 if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) {
388 $v = '"'.$v.'"';
389 }
390
391 $value_list[] = $v;
392 }
393
394 $writable_params[] = array(
395 'name' => $parameter['name'],
396 'value' => implode(',', $value_list),
397 );
398 }
399
400 return array(
401 'name' => $name,
402 'value' => $value,
403 'parameters' => $writable_params,
404 );
405 }
406
407}