diff options
Diffstat (limited to 'includes/ical/src/IcalParser.php')
-rw-r--r-- | includes/ical/src/IcalParser.php | 466 |
1 files changed, 466 insertions, 0 deletions
diff --git a/includes/ical/src/IcalParser.php b/includes/ical/src/IcalParser.php new file mode 100644 index 0000000..b4996e4 --- /dev/null +++ b/includes/ical/src/IcalParser.php @@ -0,0 +1,466 @@ +<?php + +namespace om; + +use DateInterval; +use DateTime; +use DateTimeZone; +use Exception; +use InvalidArgumentException; +use RuntimeException; + +/** + * Copyright (c) 2004-2022 Roman Ožana (https://ozana.cz) + * + * @license BSD-3-Clause + * @author Roman Ožana <roman@ozana.cz> + */ +class IcalParser { + + /** @var ?DateTimeZone */ + public ?DateTimeZone $timezone = null; + + /** @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('/(?<![^\\\\]\\\\),/', $value)); + } else { + $values = [$value]; + } + + foreach ($values as $value) { + $this->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('#(?<key>[^=;]+)=(?<value>[^;]+)#', $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('#(?<key>[^=;]+)=(?<value>[^;]+)#', $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(); + } + +} |