@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 PhutilICSParser extends Phobject {
4
5 private $stack;
6 private $node;
7 private $document;
8 private $lines;
9 private $cursor;
10
11 private $warnings;
12
13 const PARSE_MISSING_END = 'missing-end';
14 const PARSE_INITIAL_UNFOLD = 'initial-unfold';
15 const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
16 const PARSE_EXTRA_END = 'extra-end';
17 const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections';
18 const PARSE_ROOT_PROPERTY = 'root-property';
19 const PARSE_BAD_BASE64 = 'bad-base64';
20 const PARSE_BAD_BOOLEAN = 'bad-boolean';
21 const PARSE_UNEXPECTED_TEXT = 'unexpected-text';
22 const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote';
23 const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter';
24 const PARSE_MALFORMED_PROPERTY = 'malformed-property';
25 const PARSE_MISSING_VALUE = 'missing-value';
26 const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
27 const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
28 const PARSE_EMPTY_DATETIME = 'empty-datetime';
29 const PARSE_MANY_DATETIME = 'many-datetime';
30 const PARSE_BAD_DATETIME = 'bad-datetime';
31 const PARSE_EMPTY_DURATION = 'empty-duration';
32 const PARSE_MANY_DURATION = 'many-duration';
33 const PARSE_BAD_DURATION = 'bad-duration';
34
35 const WARN_TZID_UTC = 'warn-tzid-utc';
36 const WARN_TZID_GUESS = 'warn-tzid-guess';
37 const WARN_TZID_IGNORED = 'warn-tzid-ignored';
38
39 public function parseICSData($data) {
40 $this->stack = array();
41 $this->node = null;
42 $this->cursor = null;
43 $this->warnings = array();
44
45 $lines = $this->unfoldICSLines($data);
46 $this->lines = $lines;
47
48 $root = $this->newICSNode('<ROOT>');
49 $this->stack[] = $root;
50 $this->node = $root;
51
52 foreach ($lines as $key => $line) {
53 $this->cursor = $key;
54 $matches = null;
55 if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) {
56 $this->beginParsingNode($matches[1]);
57 } else if (preg_match('(^END:(.*)\z)', $line, $matches)) {
58 $this->endParsingNode($matches[1]);
59 } else {
60 if (count($this->stack) < 2) {
61 $this->raiseParseFailure(
62 self::PARSE_ROOT_PROPERTY,
63 pht(
64 'Found unexpected property at ICS document root.'));
65 }
66 $this->parseICSProperty($line);
67 }
68 }
69
70 if (count($this->stack) > 1) {
71 $this->raiseParseFailure(
72 self::PARSE_MISSING_END,
73 pht(
74 'Expected all "BEGIN:" sections in ICS document to have '.
75 'corresponding "END:" sections.'));
76 }
77
78 $this->node = null;
79 $this->lines = null;
80 $this->cursor = null;
81
82 return $root;
83 }
84
85 private function getNode() {
86 return $this->node;
87 }
88
89 private function unfoldICSLines($data) {
90 $lines = phutil_split_lines($data, $retain_endings = false);
91 $this->lines = $lines;
92
93 // ICS files are wrapped at 75 characters, with overlong lines continued
94 // on the following line with an initial space or tab. Unwrap all of the
95 // lines in the file.
96
97 // This unwrapping is specifically byte-oriented, not character oriented,
98 // and RFC5545 anticipates that simple implementations may even split UTF8
99 // characters in the middle.
100
101 $last = null;
102 foreach ($lines as $idx => $line) {
103 $this->cursor = $idx;
104 if (!preg_match('/^[ \t]/', $line)) {
105 $last = $idx;
106 continue;
107 }
108
109 if ($last === null) {
110 $this->raiseParseFailure(
111 self::PARSE_INITIAL_UNFOLD,
112 pht(
113 'First line of ICS file begins with a space or tab, but this '.
114 'marks a line which should be unfolded.'));
115 }
116
117 $lines[$last] = $lines[$last].substr($line, 1);
118 unset($lines[$idx]);
119 }
120
121 return $lines;
122 }
123
124 private function beginParsingNode($type) {
125 $node = $this->getNode();
126 $new_node = $this->newICSNode($type);
127
128 if ($node instanceof PhutilCalendarContainerNode) {
129 $node->appendChild($new_node);
130 } else {
131 $this->raiseParseFailure(
132 self::PARSE_UNEXPECTED_CHILD,
133 pht(
134 'Found unexpected node "%s" inside node "%s".',
135 $new_node->getAttribute('ics.type'),
136 $node->getAttribute('ics.type')));
137 }
138
139 $this->stack[] = $new_node;
140 $this->node = $new_node;
141
142 return $this;
143 }
144
145 private function newICSNode($type) {
146 switch ($type) {
147 case '<ROOT>':
148 $node = new PhutilCalendarRootNode();
149 break;
150 case 'VCALENDAR':
151 $node = new PhutilCalendarDocumentNode();
152 break;
153 case 'VEVENT':
154 $node = new PhutilCalendarEventNode();
155 break;
156 default:
157 $node = new PhutilCalendarRawNode();
158 break;
159 }
160
161 $node->setAttribute('ics.type', $type);
162
163 return $node;
164 }
165
166 private function endParsingNode($type) {
167 $node = $this->getNode();
168 if ($node instanceof PhutilCalendarRootNode) {
169 $this->raiseParseFailure(
170 self::PARSE_EXTRA_END,
171 pht(
172 'Found unexpected "END" without a "BEGIN".'));
173 }
174
175 $old_type = $node->getAttribute('ics.type');
176 if ($old_type != $type) {
177 $this->raiseParseFailure(
178 self::PARSE_MISMATCHED_SECTIONS,
179 pht(
180 'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.',
181 $old_type,
182 $type));
183 }
184
185 array_pop($this->stack);
186 $this->node = last($this->stack);
187
188 return $this;
189 }
190
191 private function parseICSProperty($line) {
192 $matches = null;
193
194 // Properties begin with an alphanumeric name with no escaping, followed
195 // by either a ";" (to begin a list of parameters) or a ":" (to begin
196 // the actual field body).
197
198 $ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches);
199 if (!$ok) {
200 $this->raiseParseFailure(
201 self::PARSE_MALFORMED_PROPERTY,
202 pht(
203 'Found malformed property in ICS document.'));
204 }
205
206 $name = $matches[1];
207 $body = $matches[3];
208 $has_parameters = ($matches[2] == ';');
209
210 $parameters = array();
211 if ($has_parameters) {
212 // Parameters are a sensible name, a literal "=", a pile of magic,
213 // and then maybe a comma and another parameter.
214
215 while (true) {
216 // We're going to get the first couple of parts first.
217 $ok = preg_match('(^([^=]+)=)', $body, $matches);
218 if (!$ok) {
219 $this->raiseParseFailure(
220 self::PARSE_MALFORMED_PARAMETER_NAME,
221 pht(
222 'Found malformed property in ICS document: %s',
223 $body));
224 }
225
226 $param_name = $matches[1];
227 $body = substr($body, strlen($matches[0]));
228
229 // Now we're going to match zero or more values.
230 $param_values = array();
231 while (true) {
232 // The value can either be a double-quoted string or an unquoted
233 // string, with some characters forbidden.
234 if (strlen($body) && $body[0] == '"') {
235 $is_quoted = true;
236 $ok = preg_match(
237 '(^"([^\x00-\x08\x10-\x19"]*)")',
238 $body,
239 $matches);
240 if (!$ok) {
241 $this->raiseParseFailure(
242 self::PARSE_MALFORMED_DOUBLE_QUOTE,
243 pht(
244 'Found malformed double-quoted string in ICS document '.
245 'parameter value.'));
246 }
247 } else {
248 $is_quoted = false;
249
250 // It's impossible for this not to match since it can match
251 // nothing, and it's valid for it to match nothing.
252 preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches);
253 }
254
255 // NOTE: RFC5545 says "Property parameter values that are not in
256 // quoted-strings are case-insensitive." -- that is, the quoted and
257 // unquoted representations are not equivalent. Thus, preserve the
258 // original formatting in case we ever need to respect this.
259
260 $param_values[] = array(
261 'value' => $this->unescapeParameterValue($matches[1]),
262 'quoted' => $is_quoted,
263 );
264
265 $body = substr($body, strlen($matches[0]));
266 if (!strlen($body)) {
267 $this->raiseParseFailure(
268 self::PARSE_MISSING_VALUE,
269 pht(
270 'Expected ":" after parameters in ICS document property.'));
271 }
272
273 // If we have a comma now, we're going to read another value. Strip
274 // it off and keep going.
275 if ($body[0] == ',') {
276 $body = substr($body, 1);
277 continue;
278 }
279
280 // If we have a semicolon, we're going to read another parameter.
281 if ($body[0] == ';') {
282 break;
283 }
284
285 // If we have a colon, this is the last value and also the last
286 // property. Break, then handle the colon below.
287 if ($body[0] == ':') {
288 break;
289 }
290
291 $short_body = id(new PhutilUTF8StringTruncator())
292 ->setMaximumGlyphs(32)
293 ->truncateString($body);
294
295 // We aren't expecting anything else.
296 $this->raiseParseFailure(
297 self::PARSE_UNEXPECTED_TEXT,
298 pht(
299 'Found unexpected text ("%s") after reading parameter value.',
300 $short_body));
301 }
302
303 $parameters[] = array(
304 'name' => $param_name,
305 'values' => $param_values,
306 );
307
308 if ($body[0] == ';') {
309 $body = substr($body, 1);
310 continue;
311 }
312
313 if ($body[0] == ':') {
314 $body = substr($body, 1);
315 break;
316 }
317 }
318 }
319
320 $value = $this->unescapeFieldValue($name, $parameters, $body);
321
322 $node = $this->getNode();
323
324
325 $raw = $node->getAttribute('ics.properties', array());
326 $raw[] = array(
327 'name' => $name,
328 'parameters' => $parameters,
329 'value' => $value,
330 );
331 $node->setAttribute('ics.properties', $raw);
332
333 switch ($node->getAttribute('ics.type')) {
334 case 'VEVENT':
335 $this->didParseEventProperty($node, $name, $parameters, $value);
336 break;
337 }
338 }
339
340 private function unescapeParameterValue($data) {
341 // The parameter grammar is adjusted by RFC6868 to permit escaping with
342 // carets. Remove that escaping.
343
344 // This escaping is a bit weird because it's trying to be backwards
345 // compatible and the original spec didn't think about this and didn't
346 // provide much room to fix things.
347
348 $out = '';
349 $esc = false;
350 foreach (phutil_utf8v($data) as $c) {
351 if (!$esc) {
352 if ($c != '^') {
353 $out .= $c;
354 } else {
355 $esc = true;
356 }
357 } else {
358 switch ($c) {
359 case 'n':
360 $out .= "\n";
361 break;
362 case '^':
363 $out .= '^';
364 break;
365 case "'":
366 // NOTE: This is "<caret> <single quote>" being decoded into a
367 // double quote!
368 $out .= '"';
369 break;
370 default:
371 // NOTE: The caret is NOT an escape for any other characters.
372 // This is a "MUST" requirement of RFC6868.
373 $out .= '^'.$c;
374 break;
375 }
376 }
377 }
378
379 // NOTE: Because caret on its own just means "caret" for backward
380 // compatibility, we don't warn if we're still in escaped mode once we
381 // reach the end of the string.
382
383 return $out;
384 }
385
386 private function unescapeFieldValue($name, array $parameters, $data) {
387 // NOTE: The encoding of the field value data is dependent on the field
388 // name (which defines a default encoding) and the parameters (which may
389 // include "VALUE", specifying a type of the data.
390
391 $default_types = array(
392 'CALSCALE' => 'TEXT',
393 'METHOD' => 'TEXT',
394 'PRODID' => 'TEXT',
395 'VERSION' => 'TEXT',
396
397 'ATTACH' => 'URI',
398 'CATEGORIES' => 'TEXT',
399 'CLASS' => 'TEXT',
400 'COMMENT' => 'TEXT',
401 'DESCRIPTION' => 'TEXT',
402
403 // TODO: The spec appears to contradict itself: it says that the value
404 // type is FLOAT, but it also says that this property value is actually
405 // two semicolon-separated values, which is not what FLOAT is defined as.
406 'GEO' => 'TEXT',
407
408 'LOCATION' => 'TEXT',
409 'PERCENT-COMPLETE' => 'INTEGER',
410 'PRIORITY' => 'INTEGER',
411 'RESOURCES' => 'TEXT',
412 'STATUS' => 'TEXT',
413 'SUMMARY' => 'TEXT',
414
415 'COMPLETED' => 'DATE-TIME',
416 'DTEND' => 'DATE-TIME',
417 'DUE' => 'DATE-TIME',
418 'DTSTART' => 'DATE-TIME',
419 'DURATION' => 'DURATION',
420 'FREEBUSY' => 'PERIOD',
421 'TRANSP' => 'TEXT',
422
423 'TZID' => 'TEXT',
424 'TZNAME' => 'TEXT',
425 'TZOFFSETFROM' => 'UTC-OFFSET',
426 'TZOFFSETTO' => 'UTC-OFFSET',
427 'TZURL' => 'URI',
428
429 'ATTENDEE' => 'CAL-ADDRESS',
430 'CONTACT' => 'TEXT',
431 'ORGANIZER' => 'CAL-ADDRESS',
432 'RECURRENCE-ID' => 'DATE-TIME',
433 'RELATED-TO' => 'TEXT',
434 'URL' => 'URI',
435 'UID' => 'TEXT',
436 'EXDATE' => 'DATE-TIME',
437 'RDATE' => 'DATE-TIME',
438 'RRULE' => 'RECUR',
439
440 'ACTION' => 'TEXT',
441 'REPEAT' => 'INTEGER',
442 'TRIGGER' => 'DURATION',
443
444 'CREATED' => 'DATE-TIME',
445 'DTSTAMP' => 'DATE-TIME',
446 'LAST-MODIFIED' => 'DATE-TIME',
447 'SEQUENCE' => 'INTEGER',
448
449 'REQUEST-STATUS' => 'TEXT',
450 );
451
452 $value_type = idx($default_types, $name, 'TEXT');
453
454 foreach ($parameters as $parameter) {
455 if ($parameter['name'] == 'VALUE') {
456 $value_type = idx(head($parameter['values']), 'value');
457 }
458 }
459
460 switch ($value_type) {
461 case 'BINARY':
462 $result = base64_decode($data, true);
463 if ($result === false) {
464 $this->raiseParseFailure(
465 self::PARSE_BAD_BASE64,
466 pht(
467 'Unable to decode base64 data: %s',
468 $data));
469 }
470 break;
471 case 'BOOLEAN':
472 $map = array(
473 'true' => true,
474 'false' => false,
475 );
476 $result = phutil_utf8_strtolower($data);
477 if (!isset($map[$result])) {
478 $this->raiseParseFailure(
479 self::PARSE_BAD_BOOLEAN,
480 pht(
481 'Unexpected BOOLEAN value "%s".',
482 $data));
483 }
484 $result = $map[$result];
485 break;
486 case 'CAL-ADDRESS':
487 case 'RECUR':
488 case 'URI':
489 case 'UTC-OFFSET':
490 $result = $data;
491 break;
492 case 'DATE':
493 // This is a comma-separated list of "YYYYMMDD" values.
494 $result = explode(',', $data);
495 break;
496 case 'DATE-TIME':
497 case 'DURATION':
498 if (!strlen($data)) {
499 $result = array();
500 } else {
501 $result = explode(',', $data);
502 }
503 break;
504 case 'FLOAT':
505 $result = explode(',', $data);
506 foreach ($result as $k => $v) {
507 $result[$k] = (float)$v;
508 }
509 break;
510 case 'INTEGER':
511 $result = explode(',', $data);
512 foreach ($result as $k => $v) {
513 $result[$k] = (int)$v;
514 }
515 break;
516 case 'PERIOD':
517 case 'TIME':
518 $result = explode(',', $data);
519 break;
520 case 'TEXT':
521 $result = $this->unescapeTextValue($data);
522 break;
523 default:
524 // RFC5545 says we MUST preserve the data for any types we don't
525 // recognize.
526 $result = $data;
527 break;
528 }
529
530 return array(
531 'type' => $value_type,
532 'value' => $result,
533 'raw' => $data,
534 );
535 }
536
537 private function unescapeTextValue($data) {
538 $result = array();
539
540 $buf = '';
541 $esc = false;
542 foreach (phutil_utf8v($data) as $c) {
543 if (!$esc) {
544 if ($c == '\\') {
545 $esc = true;
546 } else if ($c == ',') {
547 $result[] = $buf;
548 $buf = '';
549 } else {
550 $buf .= $c;
551 }
552 } else {
553 switch ($c) {
554 case 'n':
555 case 'N':
556 $buf .= "\n";
557 break;
558 default:
559 $buf .= $c;
560 break;
561 }
562 $esc = false;
563 }
564 }
565
566 if ($esc) {
567 $this->raiseParseFailure(
568 self::PARSE_UNESCAPED_BACKSLASH,
569 pht(
570 'ICS document contains TEXT value ending with unescaped '.
571 'backslash.'));
572 }
573
574 $result[] = $buf;
575
576 return $result;
577 }
578
579 private function raiseParseFailure($code, $message) {
580 if ($this->lines && isset($this->lines[$this->cursor])) {
581 $message = pht(
582 "ICS Parse Error near line %s:\n\n>>> %s\n\n%s",
583 $this->cursor + 1,
584 $this->lines[$this->cursor],
585 $message);
586 } else {
587 $message = pht(
588 'ICS Parse Error: %s',
589 $message);
590 }
591
592 throw id(new PhutilICSParserException($message))
593 ->setParserFailureCode($code);
594 }
595
596 private function raiseWarning($code, $message) {
597 $this->warnings[] = array(
598 'code' => $code,
599 'line' => $this->cursor,
600 'text' => $this->lines[$this->cursor],
601 'message' => $message,
602 );
603
604 return $this;
605 }
606
607 public function getWarnings() {
608 return $this->warnings;
609 }
610
611 private function didParseEventProperty(
612 PhutilCalendarEventNode $node,
613 $name,
614 array $parameters,
615 array $value) {
616
617 switch ($name) {
618 case 'UID':
619 $text = $this->newTextFromProperty($parameters, $value);
620 $node->setUID($text);
621 break;
622 case 'CREATED':
623 $datetime = $this->newDateTimeFromProperty($parameters, $value);
624 $node->setCreatedDateTime($datetime);
625 break;
626 case 'DTSTAMP':
627 $datetime = $this->newDateTimeFromProperty($parameters, $value);
628 $node->setModifiedDateTime($datetime);
629 break;
630 case 'SUMMARY':
631 $text = $this->newTextFromProperty($parameters, $value);
632 $node->setName($text);
633 break;
634 case 'DESCRIPTION':
635 $text = $this->newTextFromProperty($parameters, $value);
636 $node->setDescription($text);
637 break;
638 case 'DTSTART':
639 $datetime = $this->newDateTimeFromProperty($parameters, $value);
640 $node->setStartDateTime($datetime);
641 break;
642 case 'DTEND':
643 $datetime = $this->newDateTimeFromProperty($parameters, $value);
644 $node->setEndDateTime($datetime);
645 break;
646 case 'DURATION':
647 $duration = $this->newDurationFromProperty($parameters, $value);
648 $node->setDuration($duration);
649 break;
650 case 'RRULE':
651 $rrule = $this->newRecurrenceRuleFromProperty($parameters, $value);
652 $node->setRecurrenceRule($rrule);
653 break;
654 case 'RECURRENCE-ID':
655 $text = $this->newTextFromProperty($parameters, $value);
656 $node->setRecurrenceID($text);
657 break;
658 case 'ATTENDEE':
659 $attendee = $this->newAttendeeFromProperty($parameters, $value);
660 $node->addAttendee($attendee);
661 break;
662 case 'TRANSP':
663 $transp = $this->newTextFromProperty($parameters, $value);
664 $node->setTimeTransparency($transp);
665 break;
666 }
667
668 }
669
670 private function newTextFromProperty(array $parameters, array $value) {
671 $value = $value['value'];
672 return implode("\n\n", $value);
673 }
674
675 private function newAttendeeFromProperty(array $parameters, array $value) {
676 $uri = $value['value'];
677
678 switch (idx($parameters, 'PARTSTAT')) {
679 case 'ACCEPTED':
680 $status = PhutilCalendarUserNode::STATUS_ACCEPTED;
681 break;
682 case 'DECLINED':
683 $status = PhutilCalendarUserNode::STATUS_DECLINED;
684 break;
685 case 'NEEDS-ACTION':
686 default:
687 $status = PhutilCalendarUserNode::STATUS_INVITED;
688 break;
689 }
690
691 $name = $this->getScalarParameterValue($parameters, 'CN');
692
693 return id(new PhutilCalendarUserNode())
694 ->setURI($uri)
695 ->setName($name)
696 ->setStatus($status);
697 }
698
699 private function newDateTimeFromProperty(array $parameters, array $value) {
700 $value = $value['value'];
701
702 if (!$value) {
703 $this->raiseParseFailure(
704 self::PARSE_EMPTY_DATETIME,
705 pht(
706 'Expected DATE-TIME to have exactly one value, found none.'));
707
708 }
709
710 if (count($value) > 1) {
711 $this->raiseParseFailure(
712 self::PARSE_MANY_DATETIME,
713 pht(
714 'Expected DATE-TIME to have exactly one value, found more than '.
715 'one.'));
716 }
717
718 $value = head($value);
719 $tzid = $this->getScalarParameterValue($parameters, 'TZID');
720
721 if (preg_match('/Z\z/', $value)) {
722 if ($tzid) {
723 $this->raiseWarning(
724 self::WARN_TZID_UTC,
725 pht(
726 'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
727 'parameter with value "%s". This violates RFC5545. The TZID '.
728 'will be ignored, and the value will be interpreted as UTC.',
729 $value,
730 $tzid));
731 }
732 $tzid = 'UTC';
733 } else if ($tzid !== null) {
734 $tzid = $this->guessTimezone($tzid);
735 }
736
737 try {
738 $datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
739 $value,
740 $tzid);
741 } catch (Exception $ex) {
742 $this->raiseParseFailure(
743 self::PARSE_BAD_DATETIME,
744 pht(
745 'Error parsing DATE-TIME: %s',
746 $ex->getMessage()));
747 }
748
749 return $datetime;
750 }
751
752 private function newDurationFromProperty(array $parameters, array $value) {
753 $value = $value['value'];
754
755 if (!$value) {
756 $this->raiseParseFailure(
757 self::PARSE_EMPTY_DURATION,
758 pht(
759 'Expected DURATION to have exactly one value, found none.'));
760
761 }
762
763 if (count($value) > 1) {
764 $this->raiseParseFailure(
765 self::PARSE_MANY_DURATION,
766 pht(
767 'Expected DURATION to have exactly one value, found more than '.
768 'one.'));
769 }
770
771 $value = head($value);
772
773 try {
774 $duration = PhutilCalendarDuration::newFromISO8601($value);
775 } catch (Exception $ex) {
776 $this->raiseParseFailure(
777 self::PARSE_BAD_DURATION,
778 pht(
779 'Invalid DURATION: %s',
780 $ex->getMessage()));
781 }
782
783 return $duration;
784 }
785
786 private function newRecurrenceRuleFromProperty(array $parameters, $value) {
787 return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']);
788 }
789
790 private function getScalarParameterValue(
791 array $parameters,
792 $name,
793 $default = null) {
794
795 $match = null;
796 foreach ($parameters as $parameter) {
797 if ($parameter['name'] == $name) {
798 $match = $parameter;
799 }
800 }
801
802 if ($match === null) {
803 return $default;
804 }
805
806 $value = $match['values'];
807 if (!$value) {
808 // Parameter is specified, but with no value, like "KEY=". Just return
809 // the default, as though the parameter was not specified.
810 return $default;
811 }
812
813 if (count($value) > 1) {
814 $this->raiseParseFailure(
815 self::PARSE_MULTIPLE_PARAMETERS,
816 pht(
817 'Expected parameter "%s" to have at most one value, but found '.
818 'more than one.',
819 $name));
820 }
821
822 return idx(head($value), 'value');
823 }
824
825 private function guessTimezone($tzid) {
826 $map = DateTimeZone::listIdentifiers();
827 $map = array_fuse($map);
828 if (isset($map[$tzid])) {
829 // This is a real timezone we recognize, so just use it as provided.
830 return $tzid;
831 }
832
833 // These are alternate names for timezones.
834 static $aliases;
835
836 if ($aliases === null) {
837 $aliases = array(
838 'Etc/GMT' => 'UTC',
839 );
840
841 // Load the map of Windows timezones.
842 $root_path = dirname(phutil_get_library_root('phabricator'));
843 $windows_path = $root_path.'/resources/timezones/windows-timezones.json';
844 $windows_data = Filesystem::readFile($windows_path);
845 $windows_zones = phutil_json_decode($windows_data);
846
847 $aliases = $aliases + $windows_zones;
848 }
849
850 if (isset($aliases[$tzid])) {
851 return $aliases[$tzid];
852 }
853
854 // Look for something that looks like "UTC+3" or "GMT -05.00". If we find
855 // anything, pick a timezone with that offset.
856 $offset_pattern =
857 '/'.
858 '(?:UTC|GMT)'.
859 '\s*'.
860 '(?P<sign>[+-])'.
861 '\s*'.
862 '(?P<h>\d+)'.
863 '(?:'.
864 '[:.](?P<m>\d+)'.
865 ')?'.
866 '/i';
867
868 $matches = null;
869 if (preg_match($offset_pattern, $tzid, $matches)) {
870 $hours = (int)$matches['h'];
871 $minutes = (int)idx($matches, 'm');
872 $offset = ($hours * 60 * 60) + ($minutes * 60);
873
874 if (idx($matches, 'sign') == '-') {
875 $offset = -$offset;
876 }
877
878 // NOTE: We could possibly do better than this, by using the event start
879 // time to guess a timezone. However, that won't work for recurring
880 // events and would require us to do this work after finishing initial
881 // parsing. Since these unusual offset-based timezones appear to be rare,
882 // the benefit may not be worth the complexity.
883 $now = new DateTime('@'.time());
884
885 foreach ($map as $identifier) {
886 $zone = new DateTimeZone($identifier);
887 if ($zone->getOffset($now) == $offset) {
888 $this->raiseWarning(
889 self::WARN_TZID_GUESS,
890 pht(
891 'TZID "%s" is unknown, guessing "%s" based on pattern "%s".',
892 $tzid,
893 $identifier,
894 $matches[0]));
895 return $identifier;
896 }
897 }
898 }
899
900 $this->raiseWarning(
901 self::WARN_TZID_IGNORED,
902 pht(
903 'TZID "%s" is unknown, using UTC instead.',
904 $tzid));
905
906 return 'UTC';
907 }
908
909}