| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om; |
| 5: | |
| 6: | use ArrayObject; |
| 7: | use DateInterval; |
| 8: | use DateTime; |
| 9: | use DateTimeInterface; |
| 10: | use DateTimeZone; |
| 11: | use Exception; |
| 12: | use InvalidArgumentException; |
| 13: | use om\ICal\ContentLine; |
| 14: | use om\ICal\Value\Duration; |
| 15: | use om\RRule\RecurrenceSet; |
| 16: | use om\RRule\Rule; |
| 17: | use RuntimeException; |
| 18: | |
| 19: | |
| 20: | |
| 21: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | class IcalParser { |
| 30: | private const array DATE_PROPERTIES = ['DTSTAMP' => true, 'LAST-MODIFIED' => true, 'CREATED' => true, 'DTSTART' => true, 'DTEND' => true, 'DUE' => true, 'COMPLETED' => true]; |
| 31: | private const array MULTIPLE_KEYS = ['ATTACH' => 'ATTACHMENTS', 'EXDATE' => 'EXDATES', 'RDATE' => 'RDATES', 'ATTENDEE' => 'ATTENDEES']; |
| 32: | private const array COMMA_SEPARATED_KEYS = ['X-CATEGORIES' => 'X-CATEGORIES', 'CATEGORIES' => 'CATEGORIES']; |
| 33: | private const array META_KEYS = ['DTSTART' => true, 'RRULE' => true, 'EXDATE' => true, 'RECURRENCE-ID' => true]; |
| 34: | |
| 35: | |
| 36: | private const array TEXT_PROPERTIES = [ |
| 37: | 'CALSCALE' => true, 'METHOD' => true, 'PRODID' => true, 'VERSION' => true, 'CATEGORIES' => true, |
| 38: | 'CLASS' => true, 'COMMENT' => true, 'DESCRIPTION' => true, 'LOCATION' => true, 'RESOURCES' => true, |
| 39: | 'STATUS' => true, 'SUMMARY' => true, 'TRANSP' => true, 'TZID' => true, 'TZNAME' => true, 'CONTACT' => true, |
| 40: | 'RELATED-TO' => true, 'UID' => true, 'ACTION' => true, 'REQUEST-STATUS' => true, 'URL' => true, |
| 41: | ]; |
| 42: | |
| 43: | private const array TEXT_ESCAPES = ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ',']; |
| 44: | |
| 45: | |
| 46: | public ?DateTimeZone $timezone = null; |
| 47: | |
| 48: | public ?array $data = null; |
| 49: | |
| 50: | protected array $counters = []; |
| 51: | |
| 52: | private readonly ParserOptions $options; |
| 53: | private readonly TimezoneResolver $timezones; |
| 54: | |
| 55: | |
| 56: | |
| 57: | |
| 58: | |
| 59: | |
| 60: | |
| 61: | private array $meta = []; |
| 62: | |
| 63: | |
| 64: | |
| 65: | |
| 66: | |
| 67: | |
| 68: | private array $overrides = []; |
| 69: | |
| 70: | public function __construct(?ParserOptions $options = null) { |
| 71: | $this->options = $options ?? new ParserOptions(); |
| 72: | $this->timezones = new TimezoneResolver($this->options->windowsTimezones ?? []); |
| 73: | } |
| 74: | |
| 75: | |
| 76: | |
| 77: | |
| 78: | |
| 79: | |
| 80: | |
| 81: | |
| 82: | |
| 83: | |
| 84: | public function parseFile(string $file, ?callable $callback = null): ?array { |
| 85: | |
| 86: | return $this->parseString(self::readFile($file), $callback); |
| 87: | } |
| 88: | |
| 89: | private static function readFile(string $file): string { |
| 90: | $content = @file_get_contents($file); |
| 91: | if ($content === false) { |
| 92: | throw new RuntimeException(sprintf('Cannot read iCalendar file "%s".', $file)); |
| 93: | } |
| 94: | return $content; |
| 95: | } |
| 96: | |
| 97: | |
| 98: | |
| 99: | |
| 100: | |
| 101: | |
| 102: | |
| 103: | |
| 104: | |
| 105: | |
| 106: | |
| 107: | public function parseString(string $string, ?callable $callback = null, bool $add = false): ?array { |
| 108: | if (stripos($string, 'BEGIN:VCALENDAR') === false) { |
| 109: | throw new InvalidArgumentException('Invalid ICAL data format'); |
| 110: | } |
| 111: | |
| 112: | if ($add === false || $this->data === null) { |
| 113: | $this->data = []; |
| 114: | $this->counters = []; |
| 115: | $this->meta = []; |
| 116: | $this->overrides = []; |
| 117: | $this->timezone = null; |
| 118: | } |
| 119: | |
| 120: | |
| 121: | |
| 122: | $string = str_replace("\r\n", "\n", $string); |
| 123: | if (str_contains($string, "\r")) { |
| 124: | $string = str_replace("\r", "\n", $string); |
| 125: | } |
| 126: | $string = str_replace("\n ", '', $string); |
| 127: | if (str_contains($string, "\n\t")) { |
| 128: | $string = str_replace("\n\t", '', $string); |
| 129: | } |
| 130: | if (str_starts_with($string, "\u{FEFF}")) { |
| 131: | $string = substr($string, 3); |
| 132: | } |
| 133: | |
| 134: | $section = 'VCALENDAR'; |
| 135: | $parents = []; |
| 136: | |
| 137: | foreach (explode("\n", $string) as $row) { |
| 138: | if ($row === '') { |
| 139: | continue; |
| 140: | } |
| 141: | |
| 142: | if (strncasecmp($row, 'BEGIN:', 6) === 0) { |
| 143: | $component = strtoupper(trim(substr($row, 6))); |
| 144: | if ($component !== 'VCALENDAR') { |
| 145: | $parents[] = $section; |
| 146: | $section = $component; |
| 147: | $this->counters[$section] = isset($this->counters[$section]) ? $this->counters[$section] + 1 : 0; |
| 148: | if ($callback === null) { |
| 149: | $this->data[$section][$this->counters[$section]] = []; |
| 150: | } |
| 151: | } |
| 152: | continue; |
| 153: | } |
| 154: | |
| 155: | if (strncasecmp($row, 'END:', 4) === 0) { |
| 156: | $component = strtoupper(trim(substr($row, 4))); |
| 157: | if ($component !== 'VCALENDAR') { |
| 158: | if ($component === 'VEVENT' && $callback === null && isset($this->data['VEVENT'][$this->counters['VEVENT'] ?? -1]['RECURRENCE-ID'])) { |
| 159: | $this->registerOverride($this->counters['VEVENT']); |
| 160: | } |
| 161: | $section = array_pop($parents) ?? 'VCALENDAR'; |
| 162: | } |
| 163: | continue; |
| 164: | } |
| 165: | |
| 166: | $row = $this->parseRow($row); |
| 167: | if ($row === null) { |
| 168: | continue; |
| 169: | } |
| 170: | [$key, $middle, $value, $raw, $line] = $row; |
| 171: | |
| 172: | if ($callback) { |
| 173: | $callback($line, $key, $middle, $value, $section, $this->counters[$section] ?? 0); |
| 174: | } elseif ($section === 'VCALENDAR') { |
| 175: | $this->data[$key] = $value; |
| 176: | } else { |
| 177: | $this->store($section, $this->counters[$section], $key, $middle, $value, $raw); |
| 178: | } |
| 179: | } |
| 180: | |
| 181: | if ($callback) { |
| 182: | return null; |
| 183: | } |
| 184: | |
| 185: | $this->expandRecurringEvents(); |
| 186: | return $this->data; |
| 187: | } |
| 188: | |
| 189: | |
| 190: | |
| 191: | |
| 192: | |
| 193: | |
| 194: | |
| 195: | |
| 196: | public function parseRecurrences(array $event): array { |
| 197: | |
| 198: | $counter = array_search($event, $this->data['VEVENT'] ?? [], true); |
| 199: | return $this->recurrences($event, $counter === false ? [] : $this->meta['VEVENT'][$counter] ?? []); |
| 200: | } |
| 201: | |
| 202: | public function isMultipleKey(string $key): ?string { |
| 203: | return self::MULTIPLE_KEYS[$key] ?? null; |
| 204: | } |
| 205: | |
| 206: | public function isMultipleKeyWithCommaSeparation(string $key): ?string { |
| 207: | return self::COMMA_SEPARATED_KEYS[$key] ?? null; |
| 208: | } |
| 209: | |
| 210: | |
| 211: | |
| 212: | |
| 213: | public function getAlarms(): array { |
| 214: | return $this->data['VALARM'] ?? []; |
| 215: | } |
| 216: | |
| 217: | |
| 218: | |
| 219: | |
| 220: | public function getTimezone(): array { |
| 221: | return $this->getTimezones(); |
| 222: | } |
| 223: | |
| 224: | |
| 225: | |
| 226: | |
| 227: | public function getTimezones(): array { |
| 228: | return $this->data['VTIMEZONE'] ?? []; |
| 229: | } |
| 230: | |
| 231: | |
| 232: | |
| 233: | |
| 234: | public function getTodos(): array { |
| 235: | return array_values($this->data['VTODO'] ?? []); |
| 236: | } |
| 237: | |
| 238: | |
| 239: | |
| 240: | |
| 241: | public function getJournals(): array { |
| 242: | return array_values($this->data['VJOURNAL'] ?? []); |
| 243: | } |
| 244: | |
| 245: | |
| 246: | |
| 247: | |
| 248: | |
| 249: | |
| 250: | |
| 251: | public function getSortedEvents(): ArrayObject { |
| 252: | return $this->getEvents()->sorted(); |
| 253: | } |
| 254: | |
| 255: | |
| 256: | |
| 257: | |
| 258: | |
| 259: | public function getReverseSortedEvents(): ArrayObject { |
| 260: | return $this->getEvents()->reversed(); |
| 261: | } |
| 262: | |
| 263: | |
| 264: | |
| 265: | |
| 266: | |
| 267: | |
| 268: | |
| 269: | |
| 270: | public function getEvents(): EventsList { |
| 271: | $events = new EventsList(); |
| 272: | foreach ($this->data['VEVENT'] ?? [] as $counter => $event) { |
| 273: | $start = $event['DTSTART'] ?? null; |
| 274: | if (!isset($event['RECURRENCES']) || !$start instanceof DateTimeInterface) { |
| 275: | if (!array_key_exists('DTEND', $event) && $start instanceof DateTimeInterface |
| 276: | && ($duration = $this->duration($event, $this->meta['VEVENT'][$counter] ?? [])) !== null) { |
| 277: | $end = DateTime::createFromInterface($start); |
| 278: | $event['DTEND'] = $end->add($duration); |
| 279: | } |
| 280: | $events->append($event); |
| 281: | continue; |
| 282: | } |
| 283: | |
| 284: | $event['RECURRING'] = true; |
| 285: | $duration = $this->duration($event, $this->meta['VEVENT'][$counter] ?? []) ?? new DateInterval('PT0S'); |
| 286: | $template = $event; |
| 287: | unset($template['RECURRENCES']); |
| 288: | foreach ($event['RECURRENCES'] as $index => $date) { |
| 289: | if (!$date instanceof DateTime) { |
| 290: | continue; |
| 291: | } |
| 292: | $instance = $index === 0 ? $event : $template; |
| 293: | $instance['DTSTART'] = clone $date; |
| 294: | $instance['DTEND'] = (clone $date)->add($duration); |
| 295: | $instance['RECURRENCE_INSTANCE'] = $index; |
| 296: | $events->append($instance); |
| 297: | } |
| 298: | } |
| 299: | return $events; |
| 300: | } |
| 301: | |
| 302: | |
| 303: | |
| 304: | |
| 305: | private function store(string $section, int $counter, string $key, mixed $middle, mixed $value, string $raw): void { |
| 306: | $this->data ??= []; |
| 307: | |
| 308: | |
| 309: | |
| 310: | if ($newKey = self::MULTIPLE_KEYS[$key] ?? null) { |
| 311: | $this->data[$section][$counter][$newKey][] = $value; |
| 312: | } |
| 313: | |
| 314: | if (isset(self::COMMA_SEPARATED_KEYS[$key])) { |
| 315: | |
| 316: | foreach (preg_split('/(?<!\\\\),/', $raw) ?: [] as $item) { |
| 317: | $this->data[$section][$counter][$key][] = trim(strtr($item, self::TEXT_ESCAPES)); |
| 318: | } |
| 319: | return; |
| 320: | } |
| 321: | |
| 322: | if ($key === 'ORGANIZER') { |
| 323: | foreach (is_array($middle) ? $middle : [] as $midKey => $midVal) { |
| 324: | $this->data[$section][$counter][$key . '-' . $midKey] = $midVal; |
| 325: | } |
| 326: | } |
| 327: | if ($key === 'ATTENDEE' || $key === 'ORGANIZER') { |
| 328: | $value = $value['VALUE']; |
| 329: | } |
| 330: | $this->data[$section][$counter][$key] = $value; |
| 331: | |
| 332: | if (isset(self::META_KEYS[$key])) { |
| 333: | $this->storeMeta($section, $counter, $key, $middle, $raw); |
| 334: | } |
| 335: | } |
| 336: | |
| 337: | |
| 338: | |
| 339: | |
| 340: | |
| 341: | private function storeMeta(string $section, int $counter, string $key, mixed $middle, string $raw): void { |
| 342: | $params = is_array($middle) ? $middle : []; |
| 343: | $dateOnly = ($params['VALUE'] ?? null) === 'DATE'; |
| 344: | switch ($key) { |
| 345: | case 'DTSTART': |
| 346: | if ($dateOnly || (strlen($raw) === 8 && ctype_digit($raw))) { |
| 347: | $this->meta[$section][$counter]['dateOnly'] = true; |
| 348: | } else { |
| 349: | unset($this->meta[$section][$counter]['dateOnly']); |
| 350: | } |
| 351: | break; |
| 352: | case 'RRULE': |
| 353: | $this->meta[$section][$counter]['rrule'] = $raw; |
| 354: | break; |
| 355: | case 'EXDATE': |
| 356: | foreach (explode(',', $raw) as $item) { |
| 357: | $item = trim($item); |
| 358: | if ($dateOnly || preg_match('/^\d{8}$/D', $item)) { |
| 359: | $this->meta[$section][$counter]['exdateDays'][] = substr($item, 0, 8); |
| 360: | } |
| 361: | } |
| 362: | break; |
| 363: | case 'RECURRENCE-ID': |
| 364: | if (($params['TZID'] ?? null) instanceof DateTimeZone) { |
| 365: | $this->meta[$section][$counter]['recurrenceIdTimezone'] = $params['TZID']; |
| 366: | } |
| 367: | break; |
| 368: | } |
| 369: | } |
| 370: | |
| 371: | private function registerOverride(int $counter): void { |
| 372: | $event = $this->data['VEVENT'][$counter] ?? []; |
| 373: | if (!isset($event['RECURRENCE-ID'], $event['UID']) || !is_string($event['RECURRENCE-ID'])) { |
| 374: | return; |
| 375: | } |
| 376: | $this->data['_RECURRENCE_IDS'][$event['UID']][$event['RECURRENCE-ID']] = $event; |
| 377: | $this->overrides[$event['UID']][] = [ |
| 378: | 'value' => $event['RECURRENCE-ID'], |
| 379: | 'timezone' => $this->meta['VEVENT'][$counter]['recurrenceIdTimezone'] ?? null, |
| 380: | ]; |
| 381: | } |
| 382: | |
| 383: | private function expandRecurringEvents(): void { |
| 384: | foreach ($this->data['VEVENT'] ?? [] as $counter => $event) { |
| 385: | if (empty($event['RRULE']) && empty($event['RDATE']) && empty($event['EXDATE'])) { |
| 386: | continue; |
| 387: | } |
| 388: | if (!($event['DTSTART'] ?? null) instanceof DateTimeInterface) { |
| 389: | continue; |
| 390: | } |
| 391: | $this->data['VEVENT'][$counter]['RECURRENCES'] = $this->recurrences($event, $this->meta['VEVENT'][$counter] ?? []); |
| 392: | if (!empty($event['UID'])) { |
| 393: | $this->data['_RECURRENCE_COUNTERS_BY_UID'][$event['UID']] = $counter; |
| 394: | } |
| 395: | } |
| 396: | } |
| 397: | |
| 398: | |
| 399: | |
| 400: | |
| 401: | |
| 402: | |
| 403: | private function recurrences(array $event, array $meta): array { |
| 404: | $start = $event['DTSTART'] ?? null; |
| 405: | if (!$start instanceof DateTimeInterface) { |
| 406: | throw new InvalidArgumentException('A recurring event requires a valid DTSTART.'); |
| 407: | } |
| 408: | $start = DateTime::createFromInterface($start); |
| 409: | $timezone = $start->getTimezone(); |
| 410: | |
| 411: | $rule = null; |
| 412: | if (!empty($event['RRULE'])) { |
| 413: | try { |
| 414: | $rule = isset($meta['rrule']) ? Rule::fromString($meta['rrule'], true) : Rule::fromArray($event['RRULE'], true); |
| 415: | $rule->assertGregorian(); |
| 416: | } catch (InvalidArgumentException $e) { |
| 417: | if ($this->options->strict) { |
| 418: | throw $e; |
| 419: | } |
| 420: | $rule = null; |
| 421: | } |
| 422: | } |
| 423: | |
| 424: | |
| 425: | $until = $from = null; |
| 426: | if ($rule !== null && $rule->count === null && $rule->until === null) { |
| 427: | $now = $this->options->now(); |
| 428: | $until = ($this->options->untilInterval ? $now->add($this->options->untilInterval) : $now)->getTimestamp(); |
| 429: | if ($this->options->shiftEventDates) { |
| 430: | $from = $now->sub($this->options->shiftEventDates)->getTimestamp(); |
| 431: | } |
| 432: | } |
| 433: | |
| 434: | $set = new RecurrenceSet( |
| 435: | $start, |
| 436: | $rule, |
| 437: | rdates: self::timestamps($event['RDATES'] ?? []), |
| 438: | exdates: self::timestamps($event['EXDATES'] ?? []), |
| 439: | exdays: $meta['exdateDays'] ?? [], |
| 440: | until: $until, |
| 441: | from: $from, |
| 442: | limit: $this->options->maxOccurrences, |
| 443: | strict: $this->options->strict, |
| 444: | ); |
| 445: | [$overriddenTimestamps, $overriddenDays] = $this->overriddenInstances($event['UID'] ?? null, $timezone); |
| 446: | |
| 447: | $recurrences = []; |
| 448: | foreach ($set as $timestamp) { |
| 449: | if (isset($overriddenTimestamps[$timestamp])) { |
| 450: | continue; |
| 451: | } |
| 452: | $date = (clone $start)->setTimestamp($timestamp); |
| 453: | if ($overriddenDays !== [] && isset($overriddenDays[$date->format('Ymd')])) { |
| 454: | continue; |
| 455: | } |
| 456: | $recurrences[] = $date; |
| 457: | } |
| 458: | return $recurrences; |
| 459: | } |
| 460: | |
| 461: | |
| 462: | |
| 463: | |
| 464: | |
| 465: | |
| 466: | private function overriddenInstances(?string $uid, DateTimeZone $timezone): array { |
| 467: | $timestamps = $days = []; |
| 468: | foreach ($this->overrides[$uid ?? ''] ?? [] as ['value' => $value, 'timezone' => $idTimezone]) { |
| 469: | $value = trim($value); |
| 470: | if (preg_match('/^\d{8}$/D', $value)) { |
| 471: | $days[$value] = true; |
| 472: | continue; |
| 473: | } |
| 474: | try { |
| 475: | $timestamps[(new DateTime($value, $idTimezone ?? $timezone))->getTimestamp()] = true; |
| 476: | } catch (Exception) { |
| 477: | |
| 478: | } |
| 479: | } |
| 480: | return [$timestamps, $days]; |
| 481: | } |
| 482: | |
| 483: | |
| 484: | |
| 485: | |
| 486: | |
| 487: | private function duration(array $event, array $meta): ?DateInterval { |
| 488: | if (($event['DTEND'] ?? null) instanceof DateTimeInterface) { |
| 489: | return $event['DTSTART']->diff($event['DTEND']); |
| 490: | } |
| 491: | if (is_string($event['DURATION'] ?? null) && ($duration = self::parseDuration($event['DURATION'])) !== null) { |
| 492: | return $duration; |
| 493: | } |
| 494: | return !empty($meta['dateOnly']) ? new DateInterval('P1D') : null; |
| 495: | } |
| 496: | |
| 497: | |
| 498: | |
| 499: | |
| 500: | public static function parseDuration(string $value): ?DateInterval { |
| 501: | return Duration::parse($value); |
| 502: | } |
| 503: | |
| 504: | |
| 505: | |
| 506: | |
| 507: | |
| 508: | private static function timestamps(array $dates): array { |
| 509: | $result = []; |
| 510: | foreach ($dates as $date) { |
| 511: | foreach (is_array($date) ? $date : [$date] as $single) { |
| 512: | if ($single instanceof DateTimeInterface) { |
| 513: | $result[] = $single->getTimestamp(); |
| 514: | } |
| 515: | } |
| 516: | } |
| 517: | return $result; |
| 518: | } |
| 519: | |
| 520: | |
| 521: | |
| 522: | |
| 523: | |
| 524: | |
| 525: | private function parseRow(string $row): ?array { |
| 526: | $line = ContentLine::split($row); |
| 527: | if ($line === null) { |
| 528: | return null; |
| 529: | } |
| 530: | [$key, $middle, $raw] = $line; |
| 531: | $value = $raw; |
| 532: | $timezone = null; |
| 533: | |
| 534: | if ($key === 'X-WR-TIMEZONE' || $key === 'TZID') { |
| 535: | $resolved = $this->timezones->resolve($value); |
| 536: | if ($resolved !== null) { |
| 537: | $value = $resolved->getName(); |
| 538: | $this->timezone = $resolved; |
| 539: | } |
| 540: | } |
| 541: | |
| 542: | if ($middle !== '' && ($params = ContentLine::parameters($middle)) !== []) { |
| 543: | $middle = []; |
| 544: | foreach ($params as $name => $paramValue) { |
| 545: | if ($name === 'TZID') { |
| 546: | $resolved = $this->timezones->resolve($paramValue); |
| 547: | $middle[$name] = $resolved ?? $paramValue; |
| 548: | $timezone = $resolved; |
| 549: | } elseif ($name === 'ENCODING') { |
| 550: | if (strtoupper($paramValue) === 'QUOTED-PRINTABLE') { |
| 551: | $value = $raw = quoted_printable_decode($value); |
| 552: | } |
| 553: | } else { |
| 554: | $middle[$name] = $paramValue; |
| 555: | } |
| 556: | } |
| 557: | } |
| 558: | |
| 559: | if (isset(self::DATE_PROPERTIES[$key])) { |
| 560: | $value = self::createDate($value, $timezone ?? $this->timezone); |
| 561: | } elseif ($key === 'EXDATE' || $key === 'RDATE') { |
| 562: | $values = []; |
| 563: | foreach (explode(',', $value) as $singleValue) { |
| 564: | |
| 565: | $singleValue = strstr($singleValue, '/', true) ?: $singleValue; |
| 566: | if (($date = self::createDate($singleValue, $timezone ?? $this->timezone)) !== null) { |
| 567: | $values[] = $date; |
| 568: | } |
| 569: | } |
| 570: | $value = count($values) === 1 ? $values[0] : $values; |
| 571: | } elseif ($key === 'RRULE' && preg_match_all('#(?<key>[^=;]+)=(?<value>[^;]+)#', $value, $matches, PREG_SET_ORDER)) { |
| 572: | $middle = null; |
| 573: | $value = []; |
| 574: | foreach ($matches as $match) { |
| 575: | if ($match['key'] === 'UNTIL') { |
| 576: | $value[$match['key']] = self::createDate($match['value'], $timezone ?? $this->timezone) ?? $match['value']; |
| 577: | } else { |
| 578: | $value[$match['key']] = $match['value']; |
| 579: | } |
| 580: | } |
| 581: | } elseif (isset(self::TEXT_PROPERTIES[$key]) || str_starts_with($key, 'X-')) { |
| 582: | |
| 583: | $value = strtr($value, self::TEXT_ESCAPES); |
| 584: | } |
| 585: | |
| 586: | if ($key === 'ATTENDEE' || $key === 'ORGANIZER') { |
| 587: | $value = array_merge(is_array($middle) ? $middle : ['middle' => $middle], ['VALUE' => $value]); |
| 588: | } |
| 589: | |
| 590: | return [$key, $middle, $value, $raw, $row]; |
| 591: | } |
| 592: | |
| 593: | private static function createDate(string $value, ?DateTimeZone $timezone): ?DateTime { |
| 594: | try { |
| 595: | |
| 596: | |
| 597: | if (strlen($value) === 16 && $value[15] === 'Z' && $value[8] === 'T') { |
| 598: | static $utc, $zulu; |
| 599: | $utc ??= new DateTimeZone('UTC'); |
| 600: | $zulu ??= (new DateTime('20000101T000000Z'))->getTimezone(); |
| 601: | return (new DateTime(substr($value, 0, 15), $utc))->setTimezone($zulu); |
| 602: | } |
| 603: | return new DateTime($value, $timezone); |
| 604: | } catch (Exception) { |
| 605: | return null; |
| 606: | } |
| 607: | } |
| 608: | } |
| 609: | |