From 4d021eb32dc6f69882c716d0f6a3788c0029f956 Mon Sep 17 00:00:00 2001 From: Patrick Schwarz Date: Sun, 23 Apr 2023 20:07:52 +0200 Subject: [PATCH] Update ICal --- ICal/Event.php | 85 ++++++++++---- ICal/ICal.php | 308 ++++++++++++++++++++++++++++--------------------- 2 files changed, 240 insertions(+), 153 deletions(-) diff --git a/ICal/Event.php b/ICal/Event.php index 60f3b0d..d230f94 100644 --- a/ICal/Event.php +++ b/ICal/Event.php @@ -11,122 +11,129 @@ class Event /** * https://www.kanzaki.com/docs/ical/summary.html * - * @var $summary + * @var string */ public $summary; /** * https://www.kanzaki.com/docs/ical/dtstart.html * - * @var $dtstart + * @var string */ public $dtstart; /** * https://www.kanzaki.com/docs/ical/dtend.html * - * @var $dtend + * @var string */ public $dtend; /** * https://www.kanzaki.com/docs/ical/duration.html * - * @var $duration + * @var string */ public $duration; /** * https://www.kanzaki.com/docs/ical/dtstamp.html * - * @var $dtstamp + * @var string */ public $dtstamp; /** * When the event starts, represented as a timezone-adjusted string * - * @var $dtstart_tz + * @var string */ public $dtstart_tz; /** * When the event ends, represented as a timezone-adjusted string * - * @var $dtend_tz + * @var string */ public $dtend_tz; /** * https://www.kanzaki.com/docs/ical/uid.html * - * @var $uid + * @var string */ public $uid; /** * https://www.kanzaki.com/docs/ical/created.html * - * @var $created + * @var string */ public $created; /** * https://www.kanzaki.com/docs/ical/lastModified.html * - * @var $lastmodified + * @var string */ - public $lastmodified; + public $last_modified; /** * https://www.kanzaki.com/docs/ical/description.html * - * @var $description + * @var string */ public $description; /** * https://www.kanzaki.com/docs/ical/location.html * - * @var $location + * @var string */ public $location; /** * https://www.kanzaki.com/docs/ical/sequence.html * - * @var $sequence + * @var string */ public $sequence; /** * https://www.kanzaki.com/docs/ical/status.html * - * @var $status + * @var string */ public $status; /** * https://www.kanzaki.com/docs/ical/transp.html * - * @var $transp + * @var string */ public $transp; /** * https://www.kanzaki.com/docs/ical/organizer.html * - * @var $organizer + * @var string */ public $organizer; /** * https://www.kanzaki.com/docs/ical/attendee.html * - * @var $attendee + * @var string */ public $attendee; + /** + * Manage additional properties + * + * @var array + */ + private $additionalProperties = []; + /** * Creates the Event object * @@ -137,10 +144,40 @@ class Event { foreach ($data as $key => $value) { $variable = self::snakeCase($key); - $this->{$variable} = self::prepareData($value); + if (property_exists($this, $variable)) { + $this->{$variable} = $this->prepareData($value); + } else { + $this->additionalProperties[$variable] = $this->prepareData($value); + } } } + /** + * Magic getter method + * + * @param string $additionalPropertyName + * @return mixed + */ + public function __get($additionalPropertyName) + { + if (array_key_exists($additionalPropertyName, $this->additionalProperties)) { + return $this->additionalProperties[$additionalPropertyName]; + } + + return null; + } + + /** + * Magic isset method + * + * @param string $name + * @return boolean + */ + public function __isset($name) + { + return is_null($this->$name) === false; + } + /** * Prepares the data for output * @@ -151,8 +188,12 @@ class Event { if (is_string($value)) { return stripslashes(trim(str_replace('\n', "\n", $value))); - } elseif (is_array($value)) { - return array_map('self::prepareData', $value); + } + + if (is_array($value)) { + return array_map(function ($value) { + return $this->prepareData($value); + }, $value); } return $value; @@ -177,7 +218,7 @@ class Event 'DTSTAMP' => $this->dtstamp, 'UID' => $this->uid, 'CREATED' => $this->created, - 'LAST-MODIFIED' => $this->lastmodified, + 'LAST-MODIFIED' => $this->last_modified, 'DESCRIPTION' => $this->description, 'LOCATION' => $this->location, 'SEQUENCE' => $this->sequence, diff --git a/ICal/ICal.php b/ICal/ICal.php index c765f59..f0dd788 100644 --- a/ICal/ICal.php +++ b/ICal/ICal.php @@ -4,11 +4,11 @@ * This PHP class will read an ICS (`.ics`, `.ical`, `.ifb`) file, parse it and return an * array of its contents. * - * PHP 5 (≥ 5.3.9) + * PHP 5 (≥ 5.6.40) * * @author Jonathan Goode * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 2.2.2 + * @version 3.2.0 */ namespace ICal; @@ -66,7 +66,7 @@ class ICal /** * Enables customisation of the default time zone * - * @var string + * @var string|null */ public $defaultTimeZone; @@ -94,14 +94,14 @@ class ICal /** * With this being non-null the parser will ignore all events more than roughly this many days after now. * - * @var integer + * @var integer|null */ public $filterDaysBefore; /** * With this being non-null the parser will ignore all events more than roughly this many days before now. * - * @var integer + * @var integer|null */ public $filterDaysAfter; @@ -190,6 +190,13 @@ class ICal */ protected $httpAcceptLanguage; + /** + * Holds the custom HTTP Protocol version + * + * @var string + */ + protected $httpProtocolVersion; + /** * Define which variables can be configured * @@ -202,6 +209,7 @@ class ICal 'disableCharacterReplacement', 'filterDaysAfter', 'filterDaysBefore', + 'httpUserAgent', 'skipRecurrence', ); @@ -497,7 +505,9 @@ class ICal */ public function __construct($files = false, array $options = array()) { - ini_set('auto_detect_line_endings', '1'); + if (\PHP_VERSION_ID < 80100) { + ini_set('auto_detect_line_endings', '1'); + } foreach ($options as $option => $value) { if (in_array($option, self::$configurableOptions)) { @@ -581,9 +591,10 @@ class ICal * @param string $password * @param string $userAgent * @param string $acceptLanguage + * @param string $httpProtocolVersion * @return ICal */ - public function initUrl($url, $username = null, $password = null, $userAgent = null, $acceptLanguage = null) + public function initUrl($url, $username = null, $password = null, $userAgent = null, $acceptLanguage = null, $httpProtocolVersion = null) { if (!is_null($username) && !is_null($password)) { $this->httpBasicAuth['username'] = $username; @@ -598,6 +609,10 @@ class ICal $this->httpAcceptLanguage = $acceptLanguage; } + if (!is_null($httpProtocolVersion)) { + $this->httpProtocolVersion = $httpProtocolVersion; + } + $this->initFile($url); return $this; @@ -853,6 +868,7 @@ class ICal protected function unfold(array $lines) { $string = implode(PHP_EOL, $lines); + $string = str_ireplace(' ', ' ', $string); $string = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string); $lines = explode(PHP_EOL, $string); @@ -865,12 +881,12 @@ class ICal * * @param string $component * @param string|boolean $keyword - * @param string $value + * @param string|array $value * @return void */ protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value) { - if ($keyword == false) { + if ($keyword === false) { $keyword = $this->lastKeyword; } @@ -896,6 +912,7 @@ class ICal $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value; } } + break; case 'VEVENT': @@ -935,6 +952,7 @@ class ICal $this->cal[$key1][$key2][$keyword] .= ',' . $value; } } + break; case 'VFREEBUSY': @@ -957,6 +975,7 @@ class ICal } else { $this->cal[$key1][$key2][$key3][] = $value; } + break; case 'VTODO': @@ -979,95 +998,109 @@ class ICal * @param string $text * @return array|boolean */ - protected function keyValueFromString($text) + public function keyValueFromString($text) { - $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); + $splitLine = $this->parseLine($text); + $object = array(); + $paramObj = array(); + $valueObj = ''; + $i = 0; - $colon = strpos($text, ':'); - $quote = strpos($text, '"'); - if ($colon === false) { - $matches = array(); - } elseif ($quote === false || $colon < $quote) { - list($before, $after) = explode(':', $text, 2); - $matches = array($text, $before, $after); - } else { - list($before, $text) = explode('"', $text, 2); - $text = '"' . $text; - $matches = str_getcsv($text, ':'); - $combinedValue = ''; + while ($i < count($splitLine)) { + // The first token corresponds to the property name + if ($i === 0) { + $object[0] = $splitLine[$i]; + $i++; + continue; + } - foreach (array_keys($matches) as $key) { - if ($key === 0) { - if (!empty($before)) { - $matches[$key] = $before . '"' . $matches[$key] . '"'; - } + // After each semicolon define the property parameters + if ($splitLine[$i] == ';') { + $i++; + $paramName = $splitLine[$i]; + $i += 2; + $paramValue = array(); + $multiValue = false; + // A parameter can have multiple values separated by a comma + while ($i + 1 < count($splitLine) && $splitLine[$i + 1] === ',') { + $paramValue[] = $splitLine[$i]; + $i += 2; + $multiValue = true; + } + + if ($multiValue) { + $paramValue[] = $splitLine[$i]; } else { - if ($key > 1) { - $combinedValue .= ':'; - } + $paramValue = $splitLine[$i]; + } - $combinedValue .= $matches[$key]; + // Create object with paramName => paramValue + $paramObj[$paramName] = $paramValue; + } + + // After a colon all tokens are concatenated (non-standard behaviour because the property can have multiple values + // according to RFC5545) + if ($splitLine[$i] === ':') { + $i++; + while ($i < count($splitLine)) { + $valueObj .= $splitLine[$i]; + $i++; } } - $matches = array_slice($matches, 0, 2); - $matches[1] = $combinedValue; - array_unshift($matches, $before . $text); + $i++; } - if (count($matches) === 0) { - return false; - } - - if (preg_match('/^([A-Z-]+)([;][\w\W]*)?$/', $matches[1])) { - $matches = array_splice($matches, 1, 2); // Remove first match and re-align ordering - - // Process properties - if (preg_match('/([A-Z-]+)[;]([\w\W]*)/', $matches[0], $properties)) { - // Remove first match - array_shift($properties); - // Fix to ignore everything in keyword after a ; (e.g. Language, TZID, etc.) - $matches[0] = $properties[0]; - array_shift($properties); // Repeat removing first match - - $formatted = array(); - foreach ($properties as $property) { - // Match semicolon separator outside of quoted substrings - preg_match_all('~[^' . PHP_EOL . '";]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '";]*)*~', $property, $attributes); - // Remove multi-dimensional array and use the first key - $attributes = (count($attributes) === 0) ? array($property) : reset($attributes); - - if (is_array($attributes)) { - foreach ($attributes as $attribute) { - // Match equals sign separator outside of quoted substrings - preg_match_all( - '~[^' . PHP_EOL . '"=]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '"=]*)*~', - $attribute, - $values - ); - // Remove multi-dimensional array and use the first key - $value = (count($values) === 0) ? null : reset($values); - - if (is_array($value) && isset($value[1])) { - // Remove double quotes from beginning and end only - $formatted[$value[0]] = trim($value[1], '"'); - } - } - } - } - - // Assign the keyword property information - $properties[0] = $formatted; - - // Add match to beginning of array - array_unshift($properties, $matches[1]); - $matches[1] = $properties; - } - - return $matches; + // Object construction + if ($paramObj !== []) { + $object[1][0] = $valueObj; + $object[1][1] = $paramObj; } else { - return false; // Ignore this match + $object[1] = $valueObj; } + + return $object; + } + + /** + * Parses a line from an iCal file into an array of tokens + * + * @param string $line + * @return array + */ + protected function parseLine($line) + { + $words = array(); + $word = ''; + // The use of str_split is not a problem here even if the character set is in utf8 + // Indeed we only compare the characters , ; : = " which are on a single byte + $arrayOfChar = str_split($line); + $inDoubleQuotes = false; + + foreach ($arrayOfChar as $char) { + // Don't stop the word on ; , : = if it is enclosed in double quotes + if ($char === '"') { + if ($word !== '') { + $words[] = $word; + } + + $word = ''; + $inDoubleQuotes = !$inDoubleQuotes; + } elseif (!in_array($char, array(';', ':', ',', '=')) || $inDoubleQuotes) { + $word .= $char; + } else { + if ($word !== '') { + $words[] = $word; + } + + $words[] = $char; + $word = ''; + } + } + + $words[] = $word; + + return $words; } /** @@ -1141,10 +1174,10 @@ class ICal /** * Returns a date adapted to the calendar time zone depending on the event `TZID` * - * @param array $event - * @param string $key - * @param string $format - * @return string|boolean + * @param array $event + * @param string $key + * @param string|null $format + * @return string|boolean|\DateTime */ public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT) { @@ -1224,7 +1257,7 @@ class ICal $eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]); // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition - if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']])) !== false) { + if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']], true)) !== false) { $eventKeysToRemove[] = $alteredEventKey; $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]); @@ -1273,7 +1306,7 @@ class ICal // Separate the RRULE stanzas, and explode the values that are lists. $rrules = array(); - foreach (explode(';', $anEvent['RRULE']) as $s) { + foreach (array_filter(explode(';', $anEvent['RRULE'])) as $s) { list($k, $v) = explode('=', $s); if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYYEARDAY', 'BYWEEKNO'))) { $rrules[$k] = explode(',', $v); @@ -1311,7 +1344,7 @@ class ICal } // Get Interval - $interval = (empty($rrules['INTERVAL'])) ? 1 : $rrules['INTERVAL']; + $interval = (empty($rrules['INTERVAL'])) ? 1 : (int) $rrules['INTERVAL']; // Throw an error if this isn't an integer. if (!is_int($this->defaultSpan)) { @@ -1323,7 +1356,7 @@ class ICal // Determine if the initial date is also an EXDATE $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) { - return $carry || $exdate->getTimestamp() == $initialEventDate->getTimestamp(); + return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp(); }, false); if ($initialDateIsExdate) { @@ -1346,7 +1379,7 @@ class ICal * enddate = || */ $count = 1; - $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : 0; + $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : PHP_INT_MAX; $until = date_create()->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp(); if (isset($rrules['UNTIL'])) { @@ -1356,7 +1389,7 @@ class ICal $eventRecurrences = array(); $frequencyRecurringDateTime = clone $initialEventDate; - while ($frequencyRecurringDateTime->getTimestamp() <= $until) { + while ($frequencyRecurringDateTime->getTimestamp() <= $until && $count < $countLimit) { $candidateDateTimes = array(); // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault @@ -1389,15 +1422,15 @@ class ICal if (empty($rrules['WKST'])) { if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) { - $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays)); + $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays), true); } } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) { - $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays)); + $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays), true); } $matchingDays = array_map( function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) { - $day = array_search($weekday, array_keys($this->weekdays)); + $day = array_search($weekday, array_keys($this->weekdays), true); if ($day < $initialDayOfWeek) { $day += 7; @@ -1424,11 +1457,12 @@ class ICal foreach ($matchingDays as $day) { $clonedDateTime = clone $frequencyRecurringDateTime; $candidateDateTimes[] = $clonedDateTime->setISODate( - $frequencyRecurringDateTime->format('o'), - $frequencyRecurringDateTime->format('W'), + (int) $frequencyRecurringDateTime->format('o'), + (int) $frequencyRecurringDateTime->format('W'), $day ); } + break; case 'MONTHLY': @@ -1446,6 +1480,8 @@ class ICal } } elseif (!empty($rrules['BYDAY'])) { $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime); + } else { + $matchingDays[] = $frequencyRecurringDateTime->format('d'); } if (!empty($rrules['BYSETPOS'])) { @@ -1460,11 +1496,12 @@ class ICal $clonedDateTime = clone $frequencyRecurringDateTime; $candidateDateTimes[] = $clonedDateTime->setDate( - $frequencyRecurringDateTime->format('Y'), - $frequencyRecurringDateTime->format('m'), + (int) $frequencyRecurringDateTime->format('Y'), + (int) $frequencyRecurringDateTime->format('m'), $day ); } + break; case 'YEARLY': @@ -1474,9 +1511,9 @@ class ICal $bymonthRecurringDatetime = clone $frequencyRecurringDateTime; foreach ($rrules['BYMONTH'] as $byMonth) { $bymonthRecurringDatetime->setDate( - $frequencyRecurringDateTime->format('Y'), + (int) $frequencyRecurringDateTime->format('Y'), $byMonth, - $frequencyRecurringDateTime->format('d') + (int) $frequencyRecurringDateTime->format('d') ); // Determine the days of the month affected @@ -1493,8 +1530,8 @@ class ICal // And add each of them to the list of recurrences foreach ($monthDays as $day) { $matchingDays[] = $bymonthRecurringDatetime->setDate( - $frequencyRecurringDateTime->format('Y'), - $bymonthRecurringDatetime->format('m'), + (int) $frequencyRecurringDateTime->format('Y'), + (int) $bymonthRecurringDatetime->format('m'), $day )->format('z') + 1; } @@ -1515,12 +1552,12 @@ class ICal return in_array($yearDay, $matchingDays); } ); - } elseif (count($matchingDays) === 0) { + } elseif ($matchingDays === []) { $matchingDays = $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime); } } - if (count($matchingDays) === 0) { + if ($matchingDays === []) { $matchingDays = array($frequencyRecurringDateTime->format('z') + 1); } else { sort($matchingDays); @@ -1533,11 +1570,12 @@ class ICal foreach ($matchingDays as $day) { $clonedDateTime = clone $frequencyRecurringDateTime; $candidateDateTimes[] = $clonedDateTime->setDate( - $frequencyRecurringDateTime->format('Y'), + (int) $frequencyRecurringDateTime->format('Y'), 1, $day ); } + break; } @@ -1553,7 +1591,7 @@ class ICal // Exclusions $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) { - return $exdate->getTimestamp() == $timestamp; + return $exdate->getTimestamp() === $timestamp; }); if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) { @@ -1567,14 +1605,11 @@ class ICal $this->eventCount++; } - // Count all evaluated candidates including excluded ones - if (isset($rrules['COUNT'])) { - $count++; - - // If RRULE[COUNT] is reached then break - if ($count >= $countLimit) { - break 2; - } + // Count all evaluated candidates including excluded ones, + // and if RRULE[COUNT] (if set) is reached then break. + $count++; + if ($count >= $countLimit) { + break 2; } } @@ -1771,7 +1806,7 @@ class ICal */ protected function getDaysOfMonthMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime) { - return $this->resolveIndicesOfRange($byMonthDays, $initialDateTime->format('t')); + return $this->resolveIndicesOfRange($byMonthDays, (int) $initialDateTime->format('t')); } /** @@ -1897,7 +1932,7 @@ class ICal $byweekDateTime = clone $initialDateTime; foreach ($matchingWeeks as $weekNum) { $dayNum = $byweekDateTime->setISODate( - $initialDateTime->format('Y'), + (int) $initialDateTime->format('Y'), $weekNum, 1 )->format('z') + 1; @@ -1932,7 +1967,7 @@ class ICal $monthDateTime = clone $initialDateTime; for ($month = 1; $month < 13; $month++) { $monthDateTime->setDate( - $initialDateTime->format('Y'), + (int) $initialDateTime->format('Y'), $month, 1 ); @@ -1940,8 +1975,8 @@ class ICal $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($byMonthDays, $monthDateTime); foreach ($monthDays as $day) { $matchingDays[] = $monthDateTime->setDate( - $initialDateTime->format('Y'), - $monthDateTime->format('m'), + (int) $initialDateTime->format('Y'), + (int) $monthDateTime->format('m'), $day )->format('z') + 1; } @@ -2075,7 +2110,7 @@ class ICal * Returns the calendar time zone * * @param boolean $ignoreUtc - * @return string + * @return string|null */ public function calendarTimeZone($ignoreUtc = false) { @@ -2119,7 +2154,7 @@ class ICal */ public function hasEvents() { - return (count($this->events()) > 0) ?: false; + return ($this->events() !== []) ?: false; } /** @@ -2321,9 +2356,9 @@ class ICal /** * Parses a duration and applies it to a date * - * @param string $date - * @param string $duration - * @param string $format + * @param string $date + * @param \DateInterval $duration + * @param string|null $format * @return integer|\DateTime */ protected function parseDuration($date, $duration, $format = self::UNIX_FORMAT) @@ -2457,6 +2492,7 @@ class ICal protected function cleanData($data) { $replacementChars = array( + "\t" => ' ', "\xe2\x80\x98" => "'", // ‘ "\xe2\x80\x99" => "'", // ’ "\xe2\x80\x9a" => "'", // ‚ @@ -2468,7 +2504,7 @@ class ICal "\xe2\x80\x93" => '-', // – "\xe2\x80\x94" => '--', // — "\xe2\x80\xa6" => '...', // … - "\xc2\xa0" => ' ', + "\xc2\xa0" => ' ', // Non-breaking space ); // Replace UTF-8 characters $cleanedData = strtr($data, $replacementChars); @@ -2591,7 +2627,17 @@ class ICal } } - $options['http']['protocol_version'] = '1.1'; + if (empty($this->httpUserAgent)) { + if (mb_stripos($filename, 'outlook.office365.com') !== false) { + $options['http']['header'][] = 'User-Agent: A User Agent'; + } + } + + if (!empty($this->httpProtocolVersion)) { + $options['http']['protocol_version'] = $this->httpProtocolVersion; + } else { + $options['http']['protocol_version'] = '1.1'; + } $options['http']['header'][] = 'Connection: close';