@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
fork

Configure Feed

Select the types of activity you want to include in your feed.

at recaptime-dev/main 909 lines 25 kB view raw
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}