*/ class IcalParser { /** @var DateTimeZone */ public DateTimeZone $timezone; /** @var array|null */ public ?array $data = null; /** @var array */ protected array $counters = []; /** @var array */ private $windowsTimezones; public function __construct() { $this->windowsTimezones = require __DIR__ . '/WindowsTimezones.php'; // load Windows timezones from separate file } /** * @param string $file * @param callable|null $callback * @return array|null * @throws Exception */ public function parseFile(string $file, callable $callback = null): array { if (!$handle = fopen($file, 'rb')) { throw new RuntimeException('Can\'t open file' . $file . ' for reading'); } fclose($handle); return $this->parseString(file_get_contents($file), $callback); } /** * @param string $string * @param callable|null $callback * @param boolean $add if true the parsed string is added to existing data * @return array|null * @throws Exception */ public function parseString(string $string, callable $callback = null, bool $add = false): ?array { if ($add === false) { // delete old data $this->data = []; $this->counters = []; } if (!preg_match('/BEGIN:VCALENDAR/', $string)) { throw new InvalidArgumentException('Invalid ICAL data format'); } $section = 'VCALENDAR'; // Replace \r\n with \n $string = str_replace("\r\n", "\n", $string); // Unfold multi-line strings $string = str_replace("\n ", '', $string); foreach (explode("\n", $string) as $row) { switch ($row) { case 'BEGIN:DAYLIGHT': case 'BEGIN:VALARM': case 'BEGIN:VTIMEZONE': case 'BEGIN:VFREEBUSY': case 'BEGIN:VJOURNAL': case 'BEGIN:STANDARD': case 'BEGIN:VTODO': case 'BEGIN:VEVENT': $section = substr($row, 6); $this->counters[$section] = isset($this->counters[$section]) ? $this->counters[$section] + 1 : 0; continue 2; // while case 'END:VEVENT': $section = substr($row, 4); $currCounter = $this->counters[$section]; $event = $this->data[$section][$currCounter]; if (!empty($event['RECURRENCE-ID'])) { $this->data['_RECURRENCE_IDS'][$event['RECURRENCE-ID']] = $event; } continue 2; // while case 'END:DAYLIGHT': case 'END:VALARM': case 'END:VTIMEZONE': case 'END:VFREEBUSY': case 'END:VJOURNAL': case 'END:STANDARD': case 'END:VTODO': continue 2; // while case 'END:VCALENDAR': $veventSection = 'VEVENT'; if (!empty($this->data[$veventSection])) { foreach ($this->data[$veventSection] as $currCounter => $event) { if (!empty($event['RRULE']) || !empty($event['RDATE'])) { $recurrences = $this->parseRecurrences($event); if (!empty($recurrences)) { $this->data[$veventSection][$currCounter]['RECURRENCES'] = $recurrences; } if (!empty($event['UID'])) { $this->data["_RECURRENCE_COUNTERS_BY_UID"][$event['UID']] = $currCounter; } } } } continue 2; // while } [$key, $middle, $value] = $this->parseRow($row); if ($callback) { // call user function for processing line call_user_func($callback, $row, $key, $middle, $value, $section, $this->counters[$section]); } else { if ($section === 'VCALENDAR') { $this->data[$key] = $value; } else { // use an array since there can be multiple entries for this key. This does not // break the current implementation--it leaves the original key alone and adds // a new one specifically for the array of values. if ($newKey = $this->isMultipleKey($key)) { $this->data[$section][$this->counters[$section]][$newKey][] = $value; } // CATEGORIES can be multiple also but there is special case that there are comma separated categories if ($this->isMultipleKeyWithCommaSeparation($key)) { if (strpos($value, ',') !== false) { $values = array_map('trim', preg_split('/(?data[$section][$this->counters[$section]][$key][] = $value; } } else { $this->data[$section][$this->counters[$section]][$key] = $value; } } } } return ($callback) ? null : $this->data; } /** * @param $event * @return array * @throws Exception */ public function parseRecurrences($event): array { $recurring = new Recurrence($event['RRULE']); $exclusions = []; $additions = []; if (!empty($event['EXDATES'])) { foreach ($event['EXDATES'] as $exDate) { if (is_array($exDate)) { foreach ($exDate as $singleExDate) { $exclusions[] = $singleExDate->getTimestamp(); } } else { $exclusions[] = $exDate->getTimestamp(); } } } if (!empty($event['RDATES'])) { foreach ($event['RDATES'] as $rDate) { if (is_array($rDate)) { foreach ($rDate as $singleRDate) { $additions[] = $singleRDate->getTimestamp(); } } else { $additions[] = $rDate->getTimestamp(); } } } $until = $recurring->getUntil(); if ($until === false) { //forever... limit to 3 years from now $end = new DateTime('now'); $end->add(new DateInterval('P3Y')); // + 3 years $recurring->setUntil($end); $until = $recurring->getUntil(); } date_default_timezone_set($event['DTSTART']->getTimezone()->getName()); $frequency = new Freq($recurring->rrule, $event['DTSTART']->getTimestamp(), $exclusions, $additions); $recurrenceTimestamps = $frequency->getAllOccurrences(); $recurrences = []; foreach ($recurrenceTimestamps as $recurrenceTimestamp) { $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); $tmp->setTimestamp($recurrenceTimestamp); $recurrenceIDDate = $tmp->format('Ymd'); $recurrenceIDDateTime = $tmp->format('Ymd\THis'); if (empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDate]) && empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDateTime])) { $gmtCheck = new DateTime('now', new DateTimeZone('UTC')); $gmtCheck->setTimestamp($recurrenceTimestamp); $recurrenceIDDateTimeZ = $gmtCheck->format('Ymd\THis\Z'); if (empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDateTimeZ])) { $recurrences[] = $tmp; } } } return $recurrences; } private function parseRow($row): array { preg_match('#^([\w-]+);?([\w-]+="[^"]*"|.*?):(.*)$#i', $row, $matches); $key = false; $middle = null; $value = null; if ($matches) { $key = $matches[1]; $middle = $matches[2]; $value = $matches[3]; $timezone = null; if ($key === 'X-WR-TIMEZONE' || $key === 'TZID') { if (preg_match('#(\w+/\w+)$#i', $value, $matches)) { $value = $matches[1]; } $value = $this->toTimezone($value); $this->timezone = new DateTimeZone($value); } // have some middle part ? if ($middle && preg_match_all('#(?[^=;]+)=(?[^;]+)#', $middle, $matches, PREG_SET_ORDER)) { $middle = []; foreach ($matches as $match) { if ($match['key'] === 'TZID') { $match['value'] = trim($match['value'], "'\""); $match['value'] = $this->toTimezone($match['value']); try { $middle[$match['key']] = $timezone = new DateTimeZone($match['value']); } catch (Exception $e) { $middle[$match['key']] = $match['value']; } } elseif ($match['key'] === 'ENCODING') { if ($match['value'] === 'QUOTED-PRINTABLE') { $value = quoted_printable_decode($value); } } } } } // process simple dates with timezone if (in_array($key, ['DTSTAMP', 'LAST-MODIFIED', 'CREATED', 'DTSTART', 'DTEND'], true)) { try { $value = new DateTime($value, ($timezone ?: $this->timezone)); } catch (Exception $e) { $value = null; } } elseif (in_array($key, ['EXDATE', 'RDATE'])) { $values = []; foreach (explode(',', $value) as $singleValue) { try { $values[] = new DateTime($singleValue, ($timezone ?: $this->timezone)); } catch (Exception $e) { // pass } } if (count($values) === 1) { $value = $values[0]; } else { $value = $values; } } if ($key === 'RRULE' && preg_match_all('#(?[^=;]+)=(?[^;]+)#', $value, $matches, PREG_SET_ORDER)) { $middle = null; $value = []; foreach ($matches as $match) { if (in_array($match['key'], ['UNTIL'])) { try { $value[$match['key']] = new DateTime($match['value'], ($timezone ?: $this->timezone)); } catch (Exception $e) { $value[$match['key']] = $match['value']; } } else { $value[$match['key']] = $match['value']; } } } //implement 4.3.11 Text ESCAPED-CHAR $text_properties = [ 'CALSCALE', 'METHOD', 'PRODID', 'VERSION', 'CATEGORIES', 'CLASS', 'COMMENT', 'DESCRIPTION', 'LOCATION', 'RESOURCES', 'STATUS', 'SUMMARY', 'TRANSP', 'TZID', 'TZNAME', 'CONTACT', 'RELATED-TO', 'UID', 'ACTION', 'REQUEST-STATUS', 'URL', ]; if (in_array($key, $text_properties, true) || strpos($key, 'X-') === 0) { if (is_array($value)) { foreach ($value as &$var) { $var = strtr($var, ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ',']); } } else { $value = strtr($value, ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ',']); } } return [$key, $middle, $value]; } /** * Process timezone and return correct one... * * @param string $zone * @return mixed|null */ private function toTimezone(string $zone) { return $this->windowsTimezones[$zone] ?? $zone; } public function isMultipleKey(string $key): ?string { return (['ATTACH' => 'ATTACHMENTS', 'EXDATE' => 'EXDATES', 'RDATE' => 'RDATES'])[$key] ?? null; } /** * @param $key * @return string|null */ public function isMultipleKeyWithCommaSeparation($key): ?string { return (['X-CATEGORIES' => 'X-CATEGORIES', 'CATEGORIES' => 'CATEGORIES'])[$key] ?? null; } public function getAlarms(): array { return $this->data['VALARM'] ?? []; } public function getTimezone(): array { return $this->getTimezones(); } public function getTimezones(): array { return $this->data['VTIMEZONE'] ?? []; } /** * Return sorted event list as ArrayObject * * @deprecated use IcalParser::getEvents()->sorted() instead */ public function getSortedEvents(): \ArrayObject { return $this->getEvents()->sorted(); } public function getEvents(): EventsList { $events = new EventsList(); if (isset($this->data['VEVENT'])) { foreach ($this->data['VEVENT'] as $iValue) { $event = $iValue; if (empty($event['RECURRENCES'])) { if (!empty($event['RECURRENCE-ID']) && !empty($event['UID']) && isset($event['SEQUENCE'])) { $modifiedEventUID = $event['UID']; $modifiedEventRecurID = $event['RECURRENCE-ID']; $modifiedEventSeq = (int)$event['SEQUENCE']; if (isset($this->data['_RECURRENCE_COUNTERS_BY_UID'][$modifiedEventUID])) { $counter = $this->data['_RECURRENCE_COUNTERS_BY_UID'][$modifiedEventUID]; $originalEvent = $this->data['VEVENT'][$counter]; if (isset($originalEvent['SEQUENCE'])) { $originalEventSeq = (int)$originalEvent['SEQUENCE']; $originalEventFormattedStartDate = $originalEvent['DTSTART']->format('Ymd\THis'); if ($modifiedEventRecurID === $originalEventFormattedStartDate && $modifiedEventSeq > $originalEventSeq) { // this modifies the original event $modifiedEvent = array_replace_recursive($originalEvent, $event); $this->data['VEVENT'][$counter] = $modifiedEvent; foreach ($events as $z => $event) { if ($events[$z]['UID'] === $originalEvent['UID'] && $events[$z]['SEQUENCE'] === $originalEvent['SEQUENCE']) { // replace the original event with the modified event $events[$z] = $modifiedEvent; break; } } $event = null; // don't add this to the $events[] array again } elseif (!empty($originalEvent['RECURRENCES'])) { for ($j = 0; $j < count($originalEvent['RECURRENCES']); $j++) { $recurDate = $originalEvent['RECURRENCES'][$j]; $formattedStartDate = $recurDate->format('Ymd\THis'); if ($formattedStartDate === $modifiedEventRecurID) { unset($this->data['VEVENT'][$counter]['RECURRENCES'][$j]); $this->data['VEVENT'][$counter]['RECURRENCES'] = array_values($this->data['VEVENT'][$counter]['RECURRENCES']); break; } } } } } } if (!empty($event)) { $events->append($event); } } else { $recurrences = $event['RECURRENCES']; $event['RECURRING'] = true; $event['DTEND'] = !empty($event['DTEND']) ? $event['DTEND'] : $event['DTSTART']; $eventInterval = $event['DTSTART']->diff($event['DTEND']); $firstEvent = true; foreach ($recurrences as $j => $recurDate) { $newEvent = $event; if (!$firstEvent) { unset($newEvent['RECURRENCES']); $newEvent['DTSTART'] = $recurDate; $newEvent['DTEND'] = clone($recurDate); $newEvent['DTEND']->add($eventInterval); } $newEvent['RECURRENCE_INSTANCE'] = $j; $events->append($newEvent); $firstEvent = false; } } } } return $events; } /** * @return \ArrayObject * @deprecated use IcalParser::getEvents->reversed(); */ public function getReverseSortedEvents(): \ArrayObject { return $this->getEvents()->reversed(); } }