From bbf7ce0978a1cf6947fdf600b605f97c3a856010 Mon Sep 17 00:00:00 2001 From: Simon Rettberg Date: Tue, 8 Sep 2020 13:31:56 +0200 Subject: [locationinfo] His: Switch from SOAP to iCal iCal downloads are much faster in more revent HisInOne versions, while the SOAP API will make cobbling together a time table for a specific room even more complicated in the next release (2020.12). Switch to iCal for now and see how it goes. TODO: Create a generic iCal backend module based on this. --- .../locationinfo/inc/coursebackend.inc.php | 16 +- .../coursebackend/coursebackend_hisinone.inc.php | 344 +--- .../locationinfo/inc/icalevent.inc.php | 200 ++ .../locationinfo/inc/icalparser.inc.php | 2092 ++++++++++++++++++++ .../locationinfo/lang/de/backend-hisinone.json | 10 +- .../locationinfo/lang/en/backend-hisinone.json | 12 +- modules-available/locationinfo/page.inc.php | 3 +- 7 files changed, 2373 insertions(+), 304 deletions(-) create mode 100644 modules-available/locationinfo/inc/icalevent.inc.php create mode 100644 modules-available/locationinfo/inc/icalparser.inc.php (limited to 'modules-available') diff --git a/modules-available/locationinfo/inc/coursebackend.inc.php b/modules-available/locationinfo/inc/coursebackend.inc.php index a66d35a3..fc03671c 100644 --- a/modules-available/locationinfo/inc/coursebackend.inc.php +++ b/modules-available/locationinfo/inc/coursebackend.inc.php @@ -149,13 +149,25 @@ abstract class CourseBackend */ protected abstract function fetchSchedulesInternal($roomId); + /** + * In case you want to sanitize or otherwise mangle a property for your backend, + * override this. + * @param string $prop + * @param $value + * @return mixed + */ + public function mangleProperty($prop, $value) + { + return $value; + } + private static function fixTime(&$start, &$end) { - if (!preg_match('/^\d+-\d+-\d+T\d+:\d+:\d+$/', $start) || !preg_match('/^\d+-\d+-\d+T\d+:\d+:\d+$/', $end)) + if (!preg_match('/^(\d{2}|\d{4})-?\d{2}-?\d{2}-?T\d{1,2}:?\d{2}:?(\d{2})?$/', $start)) return false; $start = strtotime($start); $end = strtotime($end); - if ($start >= $end) + if ($start === false || $end === false || $start >= $end) return false; $start = date('Y-m-d\TH:i:s', $start); $end = date('Y-m-d\TH:i:s', $end); diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php index 4664a011..ee152684 100644 --- a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php @@ -2,9 +2,6 @@ class CourseBackend_HisInOne extends CourseBackend { - private $username = ''; - private $password = ''; - private $open = true; private $location; private $verifyHostname = true; private $verifyCert = true; @@ -16,32 +13,12 @@ class CourseBackend_HisInOne extends CourseBackend public function setCredentialsInternal($data) { - if (!$data['open']) { - // If not using OpenCourseService, require credentials - foreach (['username', 'password'] as $field) { - if (empty($data[$field])) { - $this->addError('setCredentials: Missing field ' . $field, true); - return false; - } - } - } if (empty($data['baseUrl'])) { $this->addError("No url is given", true); return false; } - $this->username = $data['username']; - if (!empty($data['role'])) { - $this->username .= "\t" . $data['role']; - } - $this->password = $data['password']; - $this->open = $data['open'] !== 'CourseService'; - $url = preg_replace('#(/+qisserver(/+services\d+(/+OpenCourseService)?)?)?\W*$#i', '', $data['baseUrl']); - if ($this->open) { - $this->location = $url . "/qisserver/services2/OpenCourseService"; - } else { - $this->location = $url . "/qisserver/services2/CourseService"; - } + $this->location = $this->mangleProperty('baseUrl', $data['baseUrl']); $this->verifyHostname = $data['verifyHostname']; $this->verifyCert = $data['verifyCert']; @@ -52,156 +29,80 @@ class CourseBackend_HisInOne extends CourseBackend { return [ new BackendProperty('baseUrl', 'string'), - new BackendProperty('username', 'string'), - new BackendProperty('role', 'string'), - new BackendProperty('password', 'password'), - new BackendProperty('open', ['OpenCourseService', 'CourseService'], 'OpenCourseService'), new BackendProperty('verifyCert', 'bool', true), new BackendProperty('verifyHostname', 'bool', true) ]; } - public function checkConnection() + public function mangleProperty($prop, $value) { - if (empty($this->location)) { - $this->addError("Credentials are not set", true); - return false; + if ($prop === 'baseUrl') { + // Update form SOAP to iCal url + if (preg_match(',^(http.*?)/qisserver,', $value, $out)) { + $value = $out[1] . '/qisserver/pages/cm/exa/timetable/roomScheduleCalendarExport.faces?roomId='; + } elseif (preg_match(',(.*[/=])\d*$', $value, $out)) { + $value = $out[1]; + } elseif (substr_count($value, '/') <= 3) { + if (substr($value, -1) !== '/') { + $value .= '/'; + } + $value .= 'qisserver/pages/cm/exa/timetable/roomScheduleCalendarExport.faces?roomId='; + } } - return $this->findUnit(123456789, date('Y-m-d'), true) !== false; + return $value; } - /** - * @param int $roomId his in one room id to get - * @param bool $connectionCheckOnly true will only check if no soapError is returned, return value will be empty - * @return array|bool if successful an array with the event ids that take place in the room - */ - public function findUnit($roomId, $day, $connectionCheckOnly = false) + public function checkConnection() { - $doc = new DOMDocument('1.0', 'utf-8'); - $doc->formatOutput = true; - $envelope = $doc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'SOAP-ENV:Envelope'); - $doc->appendChild($envelope); - if ($this->open) { - $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/OpenCourseService'); - } else { - $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/CourseService'); - } - $header = $this->getHeader($doc); - $envelope->appendChild($header); - //Body of the request - $body = $doc->createElement('SOAP-ENV:Body'); - $envelope->appendChild($body); - $findUnit = $doc->createElement('ns1:findUnit'); - $body->appendChild($findUnit); - $findUnit->appendChild($doc->createElement('ns1:individualDatesExecutionDate', $day)); - $findUnit->appendChild($doc->createElement('ns1:roomId', $roomId)); - - $soap_request = $doc->saveXML(); - $response1 = $this->postToServer($soap_request, "findUnit"); - if ($response1 === false) { - $this->addError('Could not fetch room ' . $roomId, true); - return false; - } - $response2 = $this->xmlStringToArray($response1, $err); - if (!is_array($response2)) { - $this->addError("Parsing room $roomId: $err", false); - return false; - } - if (!isset($response2['soapenvBody'])) { - $this->addError('Backend reply is missing element soapenvBody', true); - return false; - } - if (isset($response2['soapenvBody']['soapenvFault'])) { - $this->addError('SOAP-Fault (' . $response2['soapenvBody']['soapenvFault']['faultcode'] . ") " . $response2['soapenvBody']['soapenvFault']['faultstring'], true); - return false; - } - // We only need to check if the connection is working (URL ok, credentials ok, ..) so bail out early - if ($connectionCheckOnly) { - return array(); - } - if ($this->open) { - $path = '/soapenvBody/hisfindUnitResponse/hisunits'; - $subpath = '/hisunit/hisid'; - } else { - $path = '/soapenvBody/hisfindUnitResponse/hisunitIds'; - $subpath = '/hisid'; - } - $idSubDoc = $this->getArrayPath($response2, $path); - if ($idSubDoc === false) { - $this->addError('Cannot find ' . $path, false); - //@file_put_contents('/tmp/findUnit-1.' . $roomId . '.' . microtime(true), print_r($response2, true)); + if (empty($this->location)) { + $this->addError("Credentials are not set", true); return false; } - if (empty($idSubDoc)) - return $idSubDoc; - $idList = $this->getArrayPath($idSubDoc, $subpath); - if ($idList === false) { - $this->addError('Cannot find ' . $subpath . ' after ' . $path, false); - @file_put_contents('/tmp/bwlp-findUnit-2.' . $roomId . '.' . microtime(true), print_r($idSubDoc, true)); + // Unfortunately HisInOne returns an internal server error if you pass an invalid roomId. + // So we just try a bunch and see if anything works. Even if this fails, using + // the backend should work, given the URL is actually correct. + foreach ([60, 100, 5, 10, 50, 110, 200, 210, 250, 300, 333, 500, 1000, 2000] as $roomId) { + if ($this->downloadIcal($roomId) !== null) + return true; } - return $idList; + return false; } /** - * @param $doc DOMDocument - * @return DOMElement + * @param int $roomId room id + * @return ICalEvent[]|null all events for this room in the range -7 days to +7 days, or NULL on error */ - private function getHeader($doc) + private function downloadIcal($roomId) { - $header = $doc->createElement('SOAP-ENV:Header'); - $security = $doc->createElementNS('http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', 'ns2:Security'); - $mustunderstand = $doc->createAttribute('SOAP-ENV:mustUnderstand'); - $mustunderstand->value = 1; - $security->appendChild($mustunderstand); - $header->appendChild($security); - $token = $doc->createElement('ns2:UsernameToken'); - $security->appendChild($token); - $user = $doc->createElement('ns2:Username', $this->username); - $token->appendChild($user); - $pass = $doc->createElement('ns2:Password', $this->password); - $type = $doc->createAttribute('Type'); - $type->value = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText'; - $pass->appendChild($type); - $token->appendChild($pass); - return $header; - } - - /** - * @param $request string with xml SOAP request - * @param $action string with the name of the SOAP action - * @return bool|string if successful the answer xml from the SOAP server - */ - private function postToServer($request, $action) - { - $header = array( - 'Content-type: text/xml;charset="utf-8"', - 'SOAPAction: "' . $action . '"', - ); - if ($this->curlHandle === false) { $this->curlHandle = curl_init(); } + $ical = new ICalParser(['filterDaysBefore' => 7, 'filterDaysAfter' => 7]); $options = array( - CURLOPT_RETURNTRANSFER => true, + CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($ical) { + $ical->feedData($data); + return strlen($data); + }, CURLOPT_FOLLOWLOCATION => true, CURLOPT_SSL_VERIFYHOST => $this->verifyHostname ? 2 : 0, CURLOPT_SSL_VERIFYPEER => $this->verifyCert ? 1 : 0, - CURLOPT_URL => $this->location, - CURLOPT_POSTFIELDS => $request, - CURLOPT_HTTPHEADER => $header, - CURLOPT_TIMEOUT => 15, - CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_URL => $this->location . $roomId, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 4, ); curl_setopt_array($this->curlHandle, $options); - $output = curl_exec($this->curlHandle); - - if ($output === false) { + if (!curl_exec($this->curlHandle)) { $this->addError('Curl error: ' . curl_error($this->curlHandle), false); } - return $output; + $ical->finish(); + if (!$ical->isValid()) { + error_log('Did not find a VCALENDAR in returned data'); + return null; + } + return $ical->events(); } public function getCacheTime() @@ -209,7 +110,6 @@ class CourseBackend_HisInOne extends CourseBackend return 30 * 60; } - public function getRefreshTime() { return 60 * 60; @@ -226,159 +126,39 @@ class CourseBackend_HisInOne extends CourseBackend if (empty($requestedRoomIds)) { return array(); } - $currentWeek = $this->getCurrentWeekDates(); $tTables = []; - //get all eventIDs in a given room - $eventIds = []; foreach ($requestedRoomIds as $roomId) { - $ok = false; - foreach ($currentWeek as $day) { - $roomEventIds = $this->findUnit($roomId, $day, false); - if ($roomEventIds === false) - continue; - $ok = true; - $eventIds = array_merge($eventIds, $roomEventIds); - } - if ($ok) { - $tTables[$roomId] = []; - } - } - $eventIds = array_unique($eventIds); - if (empty($eventIds)) { - return $tTables; - } - $eventDetails = []; - //get all information on each event - foreach ($eventIds as $eventId) { - $event = $this->readUnit(intval($eventId)); - if ($event === false) - continue; - $eventDetails = array_merge($eventDetails, $event); - } - $name = false; - $now = time(); - foreach ($eventDetails as $event) { - foreach (array('/hisdefaulttext', - '/hisshorttext', - '/hisshortcomment') as $path) { - $name = $this->getArrayPath($event, $path); - if (!empty($name) && !empty($name[0])) - break; - $name = false; - } - if ($name === false) { - $name = ['???']; - } - $planElements = $this->getArrayPath($event, '/hisplanelements/hisplanelement'); - if ($planElements === false) { - $this->addError('Cannot find ./hisplanelements/hisplanelement', false); - //error_log('Cannot find ./hisplanelements/hisplanelement'); - //error_log(print_r($event, true)); + $data = $this->downloadIcal($roomId); + if ($data === null) { + $this->addError("Downloading ical for $roomId failed", false); continue; } - foreach ($planElements as $planElement) { - if (empty($planElement['hisplannedDates'])) - continue; - // Do not use -- is set improperly for some courses :-( - /* - $checkDate = $this->getArrayPath($planElement, '/hisplannedDates/hisplannedDate/hisenddate'); - if (!empty($checkDate) && strtotime($checkDate[0]) + 86400 < $now) - continue; // Course ended - $checkDate = $this->getArrayPath($planElement, '/hisplannedDates/hisplannedDate/hisstartdate'); - if (!empty($checkDate) && strtotime($checkDate[0]) - 86400 > $now) - continue; // Course didn't start yet - */ - $cancelled = $this->getArrayPath($planElement, '/hiscancelled'); - $cancelled = $cancelled !== false && is_array($cancelled) && ($cancelled[0] > 0 || strtolower($cancelled[0]) === 'true'); - $unitPlannedDates = $this->getArrayPath($planElement, - '/hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate'); - if ($unitPlannedDates === false) { - $this->addError('Cannot find ./hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate', false); - //error_log('Cannot find ./hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate'); - //error_log(print_r($planElement, true)); - continue; - } - $localName = $this->getArrayPath($planElement, '/hisdefaulttext'); - if ($localName === false || empty($localName[0])) { - $localName = $name; - } - foreach ($unitPlannedDates as $plannedDate) { - $eventRoomId = $this->getArrayPath($plannedDate, '/hisroomId')[0]; - $eventDate = $this->getArrayPath($plannedDate, '/hisexecutiondate')[0]; - if (in_array($eventRoomId, $requestedRoomIds) && in_array($eventDate, $currentWeek)) { - $startTime = $this->getArrayPath($plannedDate, '/hisstarttime')[0]; - $endTime = $this->getArrayPath($plannedDate, '/hisendtime')[0]; - $tTables[$eventRoomId][] = array( - 'title' => $localName[0], - 'start' => $eventDate . "T" . $startTime, - 'end' => $eventDate . "T" . $endTime, - 'cancelled' => $cancelled, - ); - } - } + foreach ($data as $event) { + $tTables[$roomId][] = array( + 'title' => $this->toTitle($event), + 'start' => $event->dtstart, + 'end' => $event->dtend, + 'cancelled' => false, // ??? How + ); } } return $tTables; } - /** - * @param $unit int ID of the subject in HisInOne database - * @return bool|array false if there was an error otherwise an array with the information about the subject + * Get a usable title from either SUMMARY or DESCRIPTION + * @param ICalEvent $event */ - public function readUnit($unit) + private function toTitle($event) { - $doc = new DOMDocument('1.0', 'utf-8'); - $doc->formatOutput = true; - $envelope = $doc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'SOAP-ENV:Envelope'); - $doc->appendChild($envelope); - if ($this->open) { - $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/OpenCourseService'); - } else { - $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/CourseService'); - } - $header = $this->getHeader($doc); - $envelope->appendChild($header); - //body of the request - $body = $doc->createElement('SOAP-ENV:Body'); - $envelope->appendChild($body); - $readUnit = $doc->createElement('ns1:readUnit'); - $body->appendChild($readUnit); - $readUnit->appendChild($doc->createElement('ns1:unitId', $unit)); - - $soap_request = $doc->saveXML(); - $response1 = $this->postToServer($soap_request, "readUnit"); - if ($response1 === false) { - return false; - } - $response2 = $this->xmlStringToArray($response1, $err); - if ($response2 === false) { - $this->addError("Cannot parse unit $unit as XML: $err", false); - return false; - } - if (!isset($response2['soapenvBody'])) { - $this->addError('Backend reply is missing element soapenvBody', true); - return false; - } - if (isset($response2['soapenvBody']['soapenvFault'])) { - $this->addError('SOAP-Fault (' . $response2['soapenvBody']['soapenvFault']['faultcode'] . ") " . $response2['soapenvBody']['soapenvFault']['faultstring'], true); - return false; + $title = $event->summary; + if (empty($title)) { + $title = $event->description; } - return $this->getArrayPath($response2, '/soapenvBody/hisreadUnitResponse/hisunit'); - } - - /** - * @return array with days of the current week in datetime format - */ - private function getCurrentWeekDates() - { - $returnValue = array(); - $date = date('Y-m-d', strtotime('last Monday')); - for ($i = 0; $i < 14; $i++) { - $returnValue[] = $date; - $date = date('Y-m-d', strtotime($date.' +1 day')); + if (empty($title)) { + $title = 'Unknown'; } - return $returnValue; + return preg_replace([',(\s*\s*|\r|\n|\\\r|\\\n)+,', '/\\\\([,;:])/'], ["\n", '$1'], $title); } public function __destruct() diff --git a/modules-available/locationinfo/inc/icalevent.inc.php b/modules-available/locationinfo/inc/icalevent.inc.php new file mode 100644 index 00000000..1fb586ee --- /dev/null +++ b/modules-available/locationinfo/inc/icalevent.inc.php @@ -0,0 +1,200 @@ +%s: %s

'; + + /** + * https://www.kanzaki.com/docs/ical/summary.html + * + * @var $summary + */ + public $summary; + + /** + * https://www.kanzaki.com/docs/ical/dtstart.html + * + * @var $dtstart + */ + public $dtstart; + + /** + * https://www.kanzaki.com/docs/ical/dtend.html + * + * @var $dtend + */ + public $dtend; + + /** + * https://www.kanzaki.com/docs/ical/duration.html + * + * @var $duration + */ + public $duration; + + /** + * https://www.kanzaki.com/docs/ical/dtstamp.html + * + * @var $dtstamp + */ + public $dtstamp; + + /** + * https://www.kanzaki.com/docs/ical/uid.html + * + * @var $uid + */ + public $uid; + + /** + * https://www.kanzaki.com/docs/ical/created.html + * + * @var $created + */ + public $created; + + /** + * https://www.kanzaki.com/docs/ical/lastModified.html + * + * @var $lastmodified + */ + public $lastmodified; + + /** + * https://www.kanzaki.com/docs/ical/description.html + * + * @var $description + */ + public $description; + + /** + * https://www.kanzaki.com/docs/ical/location.html + * + * @var $location + */ + public $location; + + /** + * https://www.kanzaki.com/docs/ical/sequence.html + * + * @var $sequence + */ + public $sequence; + + /** + * https://www.kanzaki.com/docs/ical/status.html + * + * @var $status + */ + public $status; + + /** + * https://www.kanzaki.com/docs/ical/transp.html + * + * @var $transp + */ + public $transp; + + /** + * https://www.kanzaki.com/docs/ical/organizer.html + * + * @var $organizer + */ + public $organizer; + + /** + * https://www.kanzaki.com/docs/ical/attendee.html + * + * @var $attendee + */ + public $attendee; + + /** + * Creates the Event object + * + * @param array $data + * @return void + */ + public function __construct(array $data = array()) + { + foreach ($data as $key => $value) { + $variable = self::snakeCase($key); + $this->{$variable} = self::prepareData($value); + } + } + + /** + * Prepares the data for output + * + * @param mixed $value + * @return mixed + */ + protected function prepareData($value) + { + if (is_string($value)) { + return stripslashes(trim(str_replace('\n', "\n", $value))); + } elseif (is_array($value)) { + return array_map('self::prepareData', $value); + } + + return $value; + } + + /** + * Returns Event data excluding anything blank + * within an HTML template + * + * @param string $html HTML template to use + * @return string + */ + public function printData($html = self::HTML_TEMPLATE) + { + $data = array( + 'SUMMARY' => $this->summary, + 'DTSTART' => $this->dtstart, + 'DTEND' => $this->dtend, + 'DURATION' => $this->duration, + 'DTSTAMP' => $this->dtstamp, + 'UID' => $this->uid, + 'CREATED' => $this->created, + 'LAST-MODIFIED' => $this->lastmodified, + 'DESCRIPTION' => $this->description, + 'LOCATION' => $this->location, + 'SEQUENCE' => $this->sequence, + 'STATUS' => $this->status, + 'TRANSP' => $this->transp, + 'ORGANISER' => $this->organizer, + 'ATTENDEE(S)' => $this->attendee, + ); + + // Remove any blank values + $data = array_filter($data); + + $output = ''; + + foreach ($data as $key => $value) { + $output .= sprintf($html, $key, $value); + } + + return $output; + } + + /** + * Converts the given input to snake_case + * + * @param string $input + * @param string $glue + * @param string $separator + * @return string + */ + protected static function snakeCase($input, $glue = '_', $separator = '-') + { + $input = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input); + $input = implode($glue, $input); + $input = str_replace($separator, $glue, $input); + + return strtolower($input); + } +} diff --git a/modules-available/locationinfo/inc/icalparser.inc.php b/modules-available/locationinfo/inc/icalparser.inc.php new file mode 100644 index 00000000..6d41404c --- /dev/null +++ b/modules-available/locationinfo/inc/icalparser.inc.php @@ -0,0 +1,2092 @@ + + * @license https://opensource.org/licenses/mit-license.php MIT License + * @version 2.1.20 + */ +class ICalParser +{ + // phpcs:disable Generic.Arrays.DisallowLongArraySyntax + + const DATE_TIME_FORMAT = 'Ymd\THis'; + const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:'; + const ISO_8601_WEEK_START = 'MO'; + const RECURRENCE_EVENT = 'Generated recurrence event'; + const TIME_FORMAT = 'His'; + const TIME_ZONE_UTC = 'UTC'; + const UNIX_FORMAT = 'U'; + + /** + * Tracks the number of alarms in the current iCal feed + * + * @var integer + */ + public $alarmCount = 0; + + /** + * Tracks the number of events in the current iCal feed + * + * @var integer + */ + public $eventCount = 0; + + /** + * Tracks the free/busy count in the current iCal feed + * + * @var integer + */ + public $freeBusyCount = 0; + + /** + * Tracks the number of todos in the current iCal feed + * + * @var integer + */ + public $todoCount = 0; + + /** + * The value in years to use for indefinite, recurring events + * + * @var integer + */ + public $defaultSpan = 2; + + /** + * Enables customisation of the default time zone + * + * @var string + */ + public $defaultTimeZone; + + /** + * The two letter representation of the first day of the week + * + * @var string + */ + public $defaultWeekStart = self::ISO_8601_WEEK_START; + + /** + * Toggles whether to skip the parsing of recurrence rules + * + * @var boolean + */ + public $skipRecurrence = false; + + /** + * With this being non-null the parser will ignore all events more than roughly this many days after now. + * + * @var integer + */ + public $filterDaysBefore; + + /** + * With this being non-null the parser will ignore all events more than roughly this many days before now. + * + * @var integer + */ + public $filterDaysAfter; + + /** + * @var string Which object type we're currently handling while parsing. + */ + private $parseStateComponent = ''; + + /** + * @var string Current line being read (in case of continuation). + */ + private $currentLineBuffer = ''; + + /** + * @var string Chunk of data currently being handled - might stop mid-line. + */ + private $feedBuffer = ''; + + /** + * @var bool whether we ever saw a BEGIN:VCALENDAR in the data + */ + private $hasSeenStart = false; + + /** + * The parsed calendar + * + * @var array + */ + public $cal = array(); + + /** + * Tracks the VFREEBUSY component + * + * @var integer + */ + protected $freeBusyIndex = 0; + + /** + * Variable to track the previous keyword + * + * @var string + */ + protected $lastKeyword; + + /** + * Cache valid IANA time zone IDs to avoid unnecessary lookups + * + * @var array + */ + protected $validIanaTimeZones = array(); + + /** + * Event recurrence instances that have been altered + * + * @var array + */ + protected $alteredRecurrenceInstances = array(); + + /** + * An associative array containing weekday conversion data + * + * The order of the days in the array follow the ISO-8601 specification of a week. + * + * @var array + */ + protected $weekdays = array( + 'MO' => 'monday', + 'TU' => 'tuesday', + 'WE' => 'wednesday', + 'TH' => 'thursday', + 'FR' => 'friday', + 'SA' => 'saturday', + 'SU' => 'sunday', + ); + + /** + * An associative array containing frequency conversion terms + * + * @var array + */ + protected $frequencyConversion = array( + 'DAILY' => 'day', + 'WEEKLY' => 'week', + 'MONTHLY' => 'month', + 'YEARLY' => 'year', + ); + + /** + * Define which variables can be configured + * + * @var array + */ + private static $configurableOptions = array( + 'defaultSpan', + 'defaultTimeZone', + 'defaultWeekStart', + 'filterDaysAfter', + 'filterDaysBefore', + 'skipRecurrence', + ); + + /** + * CLDR time zones mapped to IANA time zones. + * + * @var array + */ + private static $cldrTimeZonesMap = array( + '(UTC-12:00) International Date Line West' => 'Etc/GMT+12', + '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11', + '(UTC-10:00) Hawaii' => 'Pacific/Honolulu', + '(UTC-09:00) Alaska' => 'America/Anchorage', + '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles', + '(UTC-07:00) Arizona' => 'America/Phoenix', + '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua', + '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver', + '(UTC-06:00) Central America' => 'America/Guatemala', + '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago', + '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City', + '(UTC-06:00) Saskatchewan' => 'America/Regina', + '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota', + '(UTC-05:00) Chetumal' => 'America/Cancun', + '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York', + '(UTC-05:00) Indiana (East)' => 'America/Indianapolis', + '(UTC-04:00) Asuncion' => 'America/Asuncion', + '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax', + '(UTC-04:00) Caracas' => 'America/Caracas', + '(UTC-04:00) Cuiaba' => 'America/Cuiaba', + '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz', + '(UTC-04:00) Santiago' => 'America/Santiago', + '(UTC-03:30) Newfoundland' => 'America/St_Johns', + '(UTC-03:00) Brasilia' => 'America/Sao_Paulo', + '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne', + '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires', + '(UTC-03:00) Greenland' => 'America/Godthab', + '(UTC-03:00) Montevideo' => 'America/Montevideo', + '(UTC-03:00) Salvador' => 'America/Bahia', + '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2', + '(UTC-01:00) Azores' => 'Atlantic/Azores', + '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde', + '(UTC) Coordinated Universal Time' => 'Etc/GMT', + '(UTC+00:00) Casablanca' => 'Africa/Casablanca', + '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London', + '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik', + '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin', + '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest', + '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris', + '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw', + '(UTC+01:00) West Central Africa' => 'Africa/Lagos', + '(UTC+02:00) Amman' => 'Asia/Amman', + '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest', + '(UTC+02:00) Beirut' => 'Asia/Beirut', + '(UTC+02:00) Cairo' => 'Africa/Cairo', + '(UTC+02:00) Chisinau' => 'Europe/Chisinau', + '(UTC+02:00) Damascus' => 'Asia/Damascus', + '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg', + '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev', + '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem', + '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad', + '(UTC+02:00) Tripoli' => 'Africa/Tripoli', + '(UTC+02:00) Windhoek' => 'Africa/Windhoek', + '(UTC+03:00) Baghdad' => 'Asia/Baghdad', + '(UTC+03:00) Istanbul' => 'Europe/Istanbul', + '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh', + '(UTC+03:00) Minsk' => 'Europe/Minsk', + '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow', + '(UTC+03:00) Nairobi' => 'Africa/Nairobi', + '(UTC+03:30) Tehran' => 'Asia/Tehran', + '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai', + '(UTC+04:00) Baku' => 'Asia/Baku', + '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara', + '(UTC+04:00) Port Louis' => 'Indian/Mauritius', + '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi', + '(UTC+04:00) Yerevan' => 'Asia/Yerevan', + '(UTC+04:30) Kabul' => 'Asia/Kabul', + '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent', + '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg', + '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi', + '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta', + '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo', + '(UTC+05:45) Kathmandu' => 'Asia/Katmandu', + '(UTC+06:00) Astana' => 'Asia/Almaty', + '(UTC+06:00) Dhaka' => 'Asia/Dhaka', + '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon', + '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok', + '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk', + '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk', + '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai', + '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk', + '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore', + '(UTC+08:00) Perth' => 'Australia/Perth', + '(UTC+08:00) Taipei' => 'Asia/Taipei', + '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar', + '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo', + '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang', + '(UTC+09:00) Seoul' => 'Asia/Seoul', + '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk', + '(UTC+09:30) Adelaide' => 'Australia/Adelaide', + '(UTC+09:30) Darwin' => 'Australia/Darwin', + '(UTC+10:00) Brisbane' => 'Australia/Brisbane', + '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney', + '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby', + '(UTC+10:00) Hobart' => 'Australia/Hobart', + '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok', + '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk', + '(UTC+11:00) Magadan' => 'Asia/Magadan', + '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal', + '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka', + '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland', + '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12', + '(UTC+12:00) Fiji' => 'Pacific/Fiji', + "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu', + '(UTC+13:00) Samoa' => 'Pacific/Apia', + '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati', + ); + + /** + * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID + * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though. + * + * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml + * + * @var array + */ + private static $windowsTimeZonesMap = array( + 'AUS Central Standard Time' => 'Australia/Darwin', + 'AUS Eastern Standard Time' => 'Australia/Sydney', + 'Afghanistan Standard Time' => 'Asia/Kabul', + 'Alaskan Standard Time' => 'America/Anchorage', + 'Aleutian Standard Time' => 'America/Adak', + 'Altai Standard Time' => 'Asia/Barnaul', + 'Arab Standard Time' => 'Asia/Riyadh', + 'Arabian Standard Time' => 'Asia/Dubai', + 'Arabic Standard Time' => 'Asia/Baghdad', + 'Argentina Standard Time' => 'America/Buenos_Aires', + 'Astrakhan Standard Time' => 'Europe/Astrakhan', + 'Atlantic Standard Time' => 'America/Halifax', + 'Aus Central W. Standard Time' => 'Australia/Eucla', + 'Azerbaijan Standard Time' => 'Asia/Baku', + 'Azores Standard Time' => 'Atlantic/Azores', + 'Bahia Standard Time' => 'America/Bahia', + 'Bangladesh Standard Time' => 'Asia/Dhaka', + 'Belarus Standard Time' => 'Europe/Minsk', + 'Bougainville Standard Time' => 'Pacific/Bougainville', + 'Canada Central Standard Time' => 'America/Regina', + 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', + 'Caucasus Standard Time' => 'Asia/Yerevan', + 'Cen. Australia Standard Time' => 'Australia/Adelaide', + 'Central America Standard Time' => 'America/Guatemala', + 'Central Asia Standard Time' => 'Asia/Almaty', + 'Central Brazilian Standard Time' => 'America/Cuiaba', + 'Central Europe Standard Time' => 'Europe/Budapest', + 'Central European Standard Time' => 'Europe/Warsaw', + 'Central Pacific Standard Time' => 'Pacific/Guadalcanal', + 'Central Standard Time (Mexico)' => 'America/Mexico_City', + 'Central Standard Time' => 'America/Chicago', + 'Chatham Islands Standard Time' => 'Pacific/Chatham', + 'China Standard Time' => 'Asia/Shanghai', + 'Cuba Standard Time' => 'America/Havana', + 'Dateline Standard Time' => 'Etc/GMT+12', + 'E. Africa Standard Time' => 'Africa/Nairobi', + 'E. Australia Standard Time' => 'Australia/Brisbane', + 'E. Europe Standard Time' => 'Europe/Chisinau', + 'E. South America Standard Time' => 'America/Sao_Paulo', + 'Easter Island Standard Time' => 'Pacific/Easter', + 'Eastern Standard Time (Mexico)' => 'America/Cancun', + 'Eastern Standard Time' => 'America/New_York', + 'Egypt Standard Time' => 'Africa/Cairo', + 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', + 'FLE Standard Time' => 'Europe/Kiev', + 'Fiji Standard Time' => 'Pacific/Fiji', + 'GMT Standard Time' => 'Europe/London', + 'GTB Standard Time' => 'Europe/Bucharest', + 'Georgian Standard Time' => 'Asia/Tbilisi', + 'Greenland Standard Time' => 'America/Godthab', + 'Greenwich Standard Time' => 'Atlantic/Reykjavik', + 'Haiti Standard Time' => 'America/Port-au-Prince', + 'Hawaiian Standard Time' => 'Pacific/Honolulu', + 'India Standard Time' => 'Asia/Calcutta', + 'Iran Standard Time' => 'Asia/Tehran', + 'Israel Standard Time' => 'Asia/Jerusalem', + 'Jordan Standard Time' => 'Asia/Amman', + 'Kaliningrad Standard Time' => 'Europe/Kaliningrad', + 'Korea Standard Time' => 'Asia/Seoul', + 'Libya Standard Time' => 'Africa/Tripoli', + 'Line Islands Standard Time' => 'Pacific/Kiritimati', + 'Lord Howe Standard Time' => 'Australia/Lord_Howe', + 'Magadan Standard Time' => 'Asia/Magadan', + 'Magallanes Standard Time' => 'America/Punta_Arenas', + 'Marquesas Standard Time' => 'Pacific/Marquesas', + 'Mauritius Standard Time' => 'Indian/Mauritius', + 'Middle East Standard Time' => 'Asia/Beirut', + 'Montevideo Standard Time' => 'America/Montevideo', + 'Morocco Standard Time' => 'Africa/Casablanca', + 'Mountain Standard Time (Mexico)' => 'America/Chihuahua', + 'Mountain Standard Time' => 'America/Denver', + 'Myanmar Standard Time' => 'Asia/Rangoon', + 'N. Central Asia Standard Time' => 'Asia/Novosibirsk', + 'Namibia Standard Time' => 'Africa/Windhoek', + 'Nepal Standard Time' => 'Asia/Katmandu', + 'New Zealand Standard Time' => 'Pacific/Auckland', + 'Newfoundland Standard Time' => 'America/St_Johns', + 'Norfolk Standard Time' => 'Pacific/Norfolk', + 'North Asia East Standard Time' => 'Asia/Irkutsk', + 'North Asia Standard Time' => 'Asia/Krasnoyarsk', + 'North Korea Standard Time' => 'Asia/Pyongyang', + 'Omsk Standard Time' => 'Asia/Omsk', + 'Pacific SA Standard Time' => 'America/Santiago', + 'Pacific Standard Time (Mexico)' => 'America/Tijuana', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'Pakistan Standard Time' => 'Asia/Karachi', + 'Paraguay Standard Time' => 'America/Asuncion', + 'Romance Standard Time' => 'Europe/Paris', + 'Russia Time Zone 10' => 'Asia/Srednekolymsk', + 'Russia Time Zone 11' => 'Asia/Kamchatka', + 'Russia Time Zone 3' => 'Europe/Samara', + 'Russian Standard Time' => 'Europe/Moscow', + 'SA Eastern Standard Time' => 'America/Cayenne', + 'SA Pacific Standard Time' => 'America/Bogota', + 'SA Western Standard Time' => 'America/La_Paz', + 'SE Asia Standard Time' => 'Asia/Bangkok', + 'Saint Pierre Standard Time' => 'America/Miquelon', + 'Sakhalin Standard Time' => 'Asia/Sakhalin', + 'Samoa Standard Time' => 'Pacific/Apia', + 'Sao Tome Standard Time' => 'Africa/Sao_Tome', + 'Saratov Standard Time' => 'Europe/Saratov', + 'Singapore Standard Time' => 'Asia/Singapore', + 'South Africa Standard Time' => 'Africa/Johannesburg', + 'Sri Lanka Standard Time' => 'Asia/Colombo', + 'Sudan Standard Time' => 'Africa/Tripoli', + 'Syria Standard Time' => 'Asia/Damascus', + 'Taipei Standard Time' => 'Asia/Taipei', + 'Tasmania Standard Time' => 'Australia/Hobart', + 'Tocantins Standard Time' => 'America/Araguaina', + 'Tokyo Standard Time' => 'Asia/Tokyo', + 'Tomsk Standard Time' => 'Asia/Tomsk', + 'Tonga Standard Time' => 'Pacific/Tongatapu', + 'Transbaikal Standard Time' => 'Asia/Chita', + 'Turkey Standard Time' => 'Europe/Istanbul', + 'Turks And Caicos Standard Time' => 'America/Grand_Turk', + 'US Eastern Standard Time' => 'America/Indianapolis', + 'US Mountain Standard Time' => 'America/Phoenix', + 'UTC' => 'Etc/GMT', + 'UTC+12' => 'Etc/GMT-12', + 'UTC+13' => 'Etc/GMT-13', + 'UTC-02' => 'Etc/GMT+2', + 'UTC-08' => 'Etc/GMT+8', + 'UTC-09' => 'Etc/GMT+9', + 'UTC-11' => 'Etc/GMT+11', + 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', + 'Venezuela Standard Time' => 'America/Caracas', + 'Vladivostok Standard Time' => 'Asia/Vladivostok', + 'W. Australia Standard Time' => 'Australia/Perth', + 'W. Central Africa Standard Time' => 'Africa/Lagos', + 'W. Europe Standard Time' => 'Europe/Berlin', + 'W. Mongolia Standard Time' => 'Asia/Hovd', + 'West Asia Standard Time' => 'Asia/Tashkent', + 'West Bank Standard Time' => 'Asia/Hebron', + 'West Pacific Standard Time' => 'Pacific/Port_Moresby', + 'Yakutsk Standard Time' => 'Asia/Yakutsk', + ); + + /** + * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined + * by this field and `$windowMaxTimestamp`. + * + * @var integer + */ + private $windowMinTimestamp; + + /** + * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined + * by this field and `$windowMinTimestamp`. + * + * @var integer + */ + private $windowMaxTimestamp; + + /** + * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set. + * + * @var boolean + */ + private $shouldFilterByWindow; + + /** + * Creates the ICal object + * + * @param mixed $files + * @param array $options + * @return void + * @throws Exception + */ + public function __construct(array $options = array()) + { + ini_set('auto_detect_line_endings', '1'); + + foreach ($options as $option => $value) { + if (in_array($option, self::$configurableOptions)) { + $this->{$option} = $value; + } + } + + // Fallback to use the system default time zone + if (!isset($this->defaultTimeZone) || !$this->isValidTimeZoneId($this->defaultTimeZone)) { + $this->defaultTimeZone = date_default_timezone_get(); + } + + // Ideally you would use `PHP_INT_MIN` from PHP 7 + $php_int_min = -2147483648; + + $this->windowMinTimestamp = is_null($this->filterDaysBefore) ? $php_int_min : (new DateTime('now'))->sub(new DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp(); + $this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new DateTime('now'))->add(new DateInterval('P' . $this->filterDaysAfter . 'D'))->getTimestamp(); + + $this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter); + } + + /** + * Feed more data to the parser. This can be a chunk of arbitrary length, it + * is not required to end on a line break. + * + * @param string $data + */ + public function feedData($data) + { + $this->feedBuffer .= $data; + $start = 0; + $bufferLen = strlen($this->feedBuffer); + while (($newLine = strcspn($this->feedBuffer, "\r\n", $start) + $start) !== $bufferLen) { + $length = $newLine - $start; + if ($length > 1) { + if ($this->feedBuffer[$start] === ' ' || $this->feedBuffer[$start] === '\t') { + // Continuation of previous line + $this->currentLineBuffer .= substr($this->feedBuffer, $start + 1, $length - 1); + } else { + // New line, flush previous one + $this->handleLine($this->currentLineBuffer); + $this->currentLineBuffer = substr($this->feedBuffer, $start, $length); + } + } + $start = $newLine + 1; + } + $this->feedBuffer = substr($this->feedBuffer, $start); + } + + /** + * Finish feeding more data to the parser, process the data. + */ + public function finish() + { + // Flush + $this->feedData("\n*\n"); + $this->currentLineBuffer = ''; + $this->feedBuffer = ''; + $this->processEvents(); + + if (!$this->skipRecurrence) { + $this->processRecurrences(); + + // Apply changes to altered recurrence instances + if (!empty($this->alteredRecurrenceInstances)) { + $events = $this->cal['VEVENT']; + + foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) { + if (isset($alteredRecurrenceInstance['altered-event'])) { + $alteredEvent = $alteredRecurrenceInstance['altered-event']; + $key = key($alteredEvent); + $events[$key] = $alteredEvent[$key]; + } + } + + $this->cal['VEVENT'] = $events; + } + } + + if ($this->shouldFilterByWindow) { + $this->reduceEventsToMinMaxRange(); + } + + $this->processDateConversions(); + } + + /** + * True if this resembles a calendar, i.e. we've seen the + * BEGIN:VCALENDAR line at some point. + * + * @return bool + */ + public function isValid() + { + return $this->hasSeenStart; + } + + /** + * Process next completed line from file + * + * @param string $line + */ + protected function handleLine($line) + { + $line = rtrim($line); // Trim trailing whitespace + $line = $this->removeUnprintableChars($line); + + if (empty($line)) { + return; + } + + $add = $this->keyValueFromString($line); + + if ($add === false) { + return; + } + + $keyword = $add[0]; + $values = $add[1]; // May be an array containing multiple values + + if (!is_array($values)) { + if (!empty($values)) { + $values = array($values); // Make an array as not already + $blankArray = array(); // Empty placeholder array + array_push($values, $blankArray); + } else { + $values = array(); // Use blank array to ignore this line + } + } elseif (empty($values[0])) { + $values = array(); // Use blank array to ignore this line + } + + // Reverse so that our array of properties is processed first + $values = array_reverse($values); + + foreach ($values as $value) { + switch ($line) { + // https://www.kanzaki.com/docs/ical/vtodo.html + case 'BEGIN:VTODO': + if (!is_array($value)) { + $this->todoCount++; + } + + $this->parseStateComponent = 'VTODO'; + + break; + + // https://www.kanzaki.com/docs/ical/vevent.html + case 'BEGIN:VEVENT': + if (!is_array($value)) { + $this->eventCount++; + } + + $this->parseStateComponent = 'VEVENT'; + + break; + + // https://www.kanzaki.com/docs/ical/vfreebusy.html + case 'BEGIN:VFREEBUSY': + if (!is_array($value)) { + $this->freeBusyIndex++; + } + + $this->parseStateComponent = 'VFREEBUSY'; + + break; + + case 'BEGIN:VALARM': + if (!is_array($value)) { + $this->alarmCount++; + } + + $this->parseStateComponent = 'VALARM'; + + break; + + case 'END:VALARM': + $this->parseStateComponent = 'VEVENT'; + + break; + + case 'BEGIN:DAYLIGHT': + case 'BEGIN:STANDARD': + case 'BEGIN:VTIMEZONE': + $this->parseStateComponent = $value; + + break; + + case 'END:DAYLIGHT': + case 'END:STANDARD': + case 'END:VFREEBUSY': + case 'END:VTIMEZONE': + case 'END:VTODO': + $this->parseStateComponent = 'VCALENDAR'; + + break; + + case 'BEGIN:VCALENDAR': + $this->hasSeenStart = true; + $this->parseStateComponent = $value; + + break; + + case 'END:VCALENDAR': + $this->parseStateComponent = ''; + + break; + + case 'END:VEVENT': + if ($this->shouldFilterByWindow) { + $this->removeLastEventIfOutsideWindowAndNonRecurring(); + } + + $this->parseStateComponent = 'VCALENDAR'; + + break; + + default: + if (!empty($this->parseStateComponent)) { + $this->addCalendarComponentWithKeyAndValue($this->parseStateComponent, $keyword, $value); + } + break; + } + } + } + + /** + * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by + * `$windowMinTimestamp` / `$windowMaxTimestamp`. + * + * @return void + */ + protected function removeLastEventIfOutsideWindowAndNonRecurring() + { + $events = $this->cal['VEVENT']; + + if (!empty($events)) { + $lastIndex = count($events) - 1; + $lastEvent = $events[$lastIndex]; + + if (empty($lastEvent['RRULE']) && $this->doesEventStartOutsideWindow($lastEvent)) { + $this->eventCount--; + + unset($events[$lastIndex]); + } + + $this->cal['VEVENT'] = $events; + } + } + + /** + * Reduces the number of events to the defined minimum and maximum range + * + * @return void + */ + protected function reduceEventsToMinMaxRange() + { + $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array(); + + if (!empty($events)) { + foreach ($events as $key => $anEvent) { + if ($anEvent === null) { + unset($events[$key]); + + continue; + } + + if ($this->doesEventStartOutsideWindow($anEvent)) { + $this->eventCount--; + + unset($events[$key]); + + continue; + } + } + + $this->cal['VEVENT'] = $events; + } + } + + /** + * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`. + * Returns `true` for invalid dates. + * + * @param array $event + * @return boolean + */ + protected function doesEventStartOutsideWindow(array $event) + { + return !$this->isValidDate($event['DTSTART']) || $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp); + } + + /** + * Determines whether a valid iCalendar date is within a given range + * + * @param string $calendarDate + * @param integer $minTimestamp + * @param integer $maxTimestamp + * @return boolean + */ + protected function isOutOfRange($calendarDate, $minTimestamp, $maxTimestamp) + { + $timestamp = strtotime(explode('T', $calendarDate)[0]); + + return $timestamp < $minTimestamp || $timestamp > $maxTimestamp; + } + + /** + * Unfolds an iCal file in preparation for parsing + * (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html) + * + * @param array $lines + * @return array + */ + protected function unfold(array $lines) + { + $string = implode(PHP_EOL, $lines); + $string = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string); + + $lines = explode(PHP_EOL, $string); + + return $lines; + } + + /** + * Add one key and value pair to the `$this->cal` array + * + * @param string $component + * @param string|boolean $keyword + * @param string $value + * @return void + */ + protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value) + { + if ($keyword == false) { + $keyword = $this->lastKeyword; + } + + switch ($component) { + case 'VALARM': + $key1 = 'VEVENT'; + $key2 = ($this->eventCount - 1); + $key3 = $component; + + if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) { + $this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array(); + } + + if (is_array($value)) { + // Add array of properties to the end + array_push($this->cal[$key1][$key2][$key3]["{$keyword}_array"], $value); + } else { + if (!isset($this->cal[$key1][$key2][$key3][$keyword])) { + $this->cal[$key1][$key2][$key3][$keyword] = $value; + } + + if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) { + $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value; + } + } + break; + + case 'VEVENT': + $key1 = $component; + $key2 = ($this->eventCount - 1); + + if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) { + $this->cal[$key1][$key2]["{$keyword}_array"] = array(); + } + + if (is_array($value)) { + // Add array of properties to the end + array_push($this->cal[$key1][$key2]["{$keyword}_array"], $value); + } else { + if (!isset($this->cal[$key1][$key2][$keyword])) { + $this->cal[$key1][$key2][$keyword] = $value; + } + + if ($keyword === 'EXDATE') { + if (trim($value) === $value) { + $array = array_filter(explode(',', $value)); + $this->cal[$key1][$key2]["{$keyword}_array"][] = $array; + } else { + $value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value)); + $this->cal[$key1][$key2]["{$keyword}_array"][1] = $value; + } + } else { + $this->cal[$key1][$key2]["{$keyword}_array"][] = $value; + + if ($keyword === 'DURATION') { + try { + $duration = new DateInterval($value); + array_push($this->cal[$key1][$key2]["{$keyword}_array"], $duration); + } catch (Exception $e) { + error_log('Ignoring invalid duration ' . $value); + } + } + } + + if ($this->cal[$key1][$key2][$keyword] !== $value) { + $this->cal[$key1][$key2][$keyword] .= ',' . $value; + } + } + break; + + case 'VFREEBUSY': + $key1 = $component; + $key2 = ($this->freeBusyIndex - 1); + $key3 = $keyword; + + if ($keyword === 'FREEBUSY') { + if (is_array($value)) { + $this->cal[$key1][$key2][$key3][][] = $value; + } else { + $this->freeBusyCount++; + + end($this->cal[$key1][$key2][$key3]); + $key = key($this->cal[$key1][$key2][$key3]); + + $value = explode('/', $value); + $this->cal[$key1][$key2][$key3][$key][] = $value; + } + } else { + $this->cal[$key1][$key2][$key3][] = $value; + } + break; + + case 'VTODO': + $this->cal[$component][$this->todoCount - 1][$keyword] = $value; + + break; + + default: + $this->cal[$component][$keyword] = $value; + + break; + } + + $this->lastKeyword = $keyword; + } + + /** + * Gets the key value pair from an iCal string + * + * @param string $text + * @return array|boolean + */ + protected function keyValueFromString($text) + { + $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); + + $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 = ''; + + foreach (array_keys($matches) as $key) { + if ($key === 0) { + if (!empty($before)) { + $matches[$key] = $before . '"' . $matches[$key] . '"'; + } + } else { + if ($key > 1) { + $combinedValue .= ':'; + } + + $combinedValue .= $matches[$key]; + } + } + + $matches = array_slice($matches, 0, 2); + $matches[1] = $combinedValue; + array_unshift($matches, $before . $text); + } + + 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; + } else { + return false; // Ignore this match + } + } + + /** + * Returns a `DateTime` object from an iCal date time format + * + * @param string $icalDate + * @return DateTime + */ + public function iCalDateToDateTime($icalDate) + { + /** + * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html) + * + * UTC: Has a trailing 'Z' + * Floating: No time zone reference specified, no trailing 'Z', use local time + * TZID: Set time zone as specified + * + * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`. + * Must have a local time zone set to process floating times. + */ + $pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone + $pattern .= ':?'; // Time zone delimiter + $pattern .= '([0-9]{8})'; // [2]: YYYYMMDD + $pattern .= 'T?'; // Time delimiter + $pattern .= '(?(?<=T)([0-9]{6}))'; // [3]: HHMMSS (filled if delimiter present) + $pattern .= '(Z?)/'; // [4]: UTC flag + + preg_match($pattern, $icalDate, $date); + + if (empty($date)) { + error_log('Invalid iCal date format: ' . $icalDate); + return new Datetime('1970-08-08'); // Return something far in the past + } + + // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970. + // PHP, on the other hand, uses negative numbers for that. Thus we don't + // need to special case them. + + if ($date[4] === 'Z') { + $dateTimeZone = new DateTimeZone(self::TIME_ZONE_UTC); + } elseif (!empty($date[1])) { + $dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]); + } else { + $dateTimeZone = new DateTimeZone($this->defaultTimeZone); + } + + // The exclamation mark at the start of the format string indicates that if a + // time portion is not included, the time in the returned DateTime should be + // set to 00:00:00. Without it, the time would be set to the current system time. + $dateFormat = '!Ymd'; + $dateBasic = $date[2]; + if (!empty($date[3])) { + $dateBasic .= "T{$date[3]}"; + $dateFormat .= '\THis'; + } + + return DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone); + } + + /** + * Returns a Unix timestamp from an iCal date time format + * + * @param string $icalDate + * @return integer + */ + public function iCalDateToUnixTimestamp($icalDate) + { + return $this->iCalDateToDateTime($icalDate)->getTimestamp(); + } + + /** + * 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 + */ + public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT) + { + if (!isset($event["{$key}_array"]) || !isset($event[$key])) { + return false; + } + + $dateArray = $event["{$key}_array"]; + + if ($key === 'DURATION') { + $dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2], null); + } else { + // When constructing from a Unix Timestamp, no time zone needs passing. + $dateTime = new DateTime("@{$dateArray[2]}"); + } + + // Set the time zone we wish to use when running `$dateTime->format`. + $dateTime->setTimezone(new DateTimeZone($this->calendarTimeZone())); + + if (is_null($format)) { + return $dateTime; + } + + return $dateTime->format($format); + } + + /** + * Performs admin tasks on all events as read from the iCal file. + * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays + * Tracks modified recurrence instances + * + * @return void + */ + protected function processEvents() + { + if (empty($this->cal['VEVENT'])) + return; + $events =& $this->cal['VEVENT']; + $checks = null; + + foreach ($events as $key => $anEvent) { + foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) { + if (isset($anEvent[$type])) { + $date = $anEvent["{$type}_array"][1]; + + if (isset($anEvent["{$type}_array"][0]['TZID'])) { + $timeZone = $this->escapeParamText($anEvent["{$type}_array"][0]['TZID']); + $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date; + } + + $anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date); + $anEvent["{$type}_array"][3] = $date; + } + } + + if (isset($anEvent['RECURRENCE-ID'])) { + $uid = $anEvent['UID']; + + if (!isset($this->alteredRecurrenceInstances[$uid])) { + $this->alteredRecurrenceInstances[$uid] = array(); + } + + $recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3]); + $this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc; + } + + $events[$key] = $anEvent; + } + + $eventKeysToRemove = array(); + + foreach ($events as $key => $event) { + $checks[] = !isset($event['RECURRENCE-ID']); + $checks[] = isset($event['UID']); + $checks[] = isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]); + + if ((bool)array_product($checks)) { + $eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]); + + // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition + if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']])) !== false) { + $eventKeysToRemove[] = $alteredEventKey; + + $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]); + $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent); + } + } + + unset($checks); + } + + foreach ($eventKeysToRemove as $eventKeyToRemove) { + $events[$eventKeyToRemove] = null; + } + } + + /** + * Processes recurrence rules + * + * @return void + */ + protected function processRecurrences() + { + // If there are no events, then we have nothing to process. + if (empty($this->cal['VEVENT'])) + return; + $events =& $this->cal['VEVENT']; + + $allEventRecurrences = array(); + $eventKeysToRemove = array(); + + foreach ($events as $key => $anEvent) { + if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') { + continue; + } + + // Tag as generated by a recurrence rule + $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT; + + // Create new initial starting point. + $initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]); + + // Separate the RRULE stanzas, and explode the values that are lists. + $rrules = array(); + foreach (explode(';', $anEvent['RRULE']) as $s) { + list($k, $v) = explode('=', $s); + if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'))) { + $rrules[$k] = explode(',', $v); + } else { + $rrules[$k] = $v; + } + } + + // Get frequency + $frequency = $rrules['FREQ']; + + // Reject RRULE if BYDAY stanza is invalid: + // > The BYDAY rule part MUST NOT be specified with a numeric value + // > when the FREQ rule part is not set to MONTHLY or YEARLY. + if (isset($rrules['BYDAY']) && !in_array($frequency, array('MONTHLY', 'YEARLY'))) { + $allByDayStanzasValid = array_reduce($rrules['BYDAY'], function ($carry, $weekday) { + return $carry && substr($weekday, -2) === $weekday; + }, true); + + if (!$allByDayStanzasValid) { + error_log("ICal::ProcessRecurrences: A \"{$frequency}\" RRULE should not contain BYDAY values with numeric prefixes"); + + continue; + } + } + + // Get Interval + $interval = (empty($rrules['INTERVAL'])) ? 1 : $rrules['INTERVAL']; + + // Throw an error if this isn't an integer. + if (!is_int($this->defaultSpan)) { + trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE); + } + + // Compute EXDATEs + $exdates = $this->parseExdates($anEvent); + + // Determine if the initial date is also an EXDATE + $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) { + return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp(); + }, false); + + if ($initialDateIsExdate) { + $eventKeysToRemove[] = $key; + } + + /** + * Determine at what point we should stop calculating recurrences + * by looking at the UNTIL or COUNT rrule stanza, or, if neither + * if set, using a fallback. + * + * If the initial date is also an EXDATE, it shouldn't be included + * in the count. + * + * Syntax: + * UNTIL={enddate} + * COUNT= + * + * Where: + * enddate = || + */ + $count = 1; + $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : 0; + $until = date_create()->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp(); + + if (isset($rrules['UNTIL'])) { + $until = min($until, $this->iCalDateToUnixTimestamp($rrules['UNTIL'])); + } + $until = min($until, $this->windowMaxTimestamp); + + $eventRecurrences = array(); + + $frequencyRecurringDateTime = clone $initialEventDate; + while ($frequencyRecurringDateTime->getTimestamp() <= $until) { + $candidateDateTimes = array(); + + // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault + switch ($frequency) { + case 'DAILY': + $candidateDateTimes[] = clone $frequencyRecurringDateTime; + + break; + + case 'WEEKLY': + $initialDayOfWeek = $frequencyRecurringDateTime->format('N'); + $matchingDays = array($initialDayOfWeek); + + if (!empty($rrules['BYDAY'])) { + // setISODate() below uses the ISO-8601 specification of weeks: start on + // a Monday, end on a Sunday. However, RRULEs (or the caller of the + // parser) may state an alternate WeeKSTart. + $wkstTransition = 7; + + if (empty($rrules['WKST'])) { + if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) { + $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays)); + } + } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) { + $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays)); + } + + $matchingDays = array_map( + function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) { + $day = array_search($weekday, array_keys($this->weekdays)); + + if ($day < $initialDayOfWeek) { + $day += 7; + } + + if ($day >= $wkstTransition) { + $day += 7 * ($interval - 1); + } + + // Ignoring alternate week starts, $day at this point will have a + // value between 0 and 6. But setISODate() expects a value of 1 to 7. + // Even with alternate week starts, we still need to +1 to set the + // correct weekday. + $day++; + + return $day; + }, + $rrules['BYDAY'] + ); + } + + sort($matchingDays); + + foreach ($matchingDays as $day) { + $clonedDateTime = clone $frequencyRecurringDateTime; + $candidateDateTimes[] = $clonedDateTime->setISODate( + $frequencyRecurringDateTime->format('o'), + $frequencyRecurringDateTime->format('W'), + $day + ); + } + break; + + case 'MONTHLY': + $matchingDays = array(); + + if (!empty($rrules['BYMONTHDAY'])) { + $matchingDays = $rrules['BYMONTHDAY']; + } elseif (!empty($rrules['BYDAY'])) { + $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime); + } + + if (!empty($rrules['BYSETPOS'])) { + $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays); + } + + foreach ($matchingDays as $day) { + // Skip invalid dates (e.g. 30th February) + if ($day > $frequencyRecurringDateTime->format('t')) { + continue; + } + + $clonedDateTime = clone $frequencyRecurringDateTime; + $candidateDateTimes[] = $clonedDateTime->setDate( + $frequencyRecurringDateTime->format('Y'), + $frequencyRecurringDateTime->format('m'), + $day + ); + } + break; + + case 'YEARLY': + if (!empty($rrules['BYMONTH'])) { + foreach ($rrules['BYMONTH'] as $byMonth) { + $clonedDateTime = clone $frequencyRecurringDateTime; + $bymonthRecurringDatetime = $clonedDateTime->setDate( + $frequencyRecurringDateTime->format('Y'), + $byMonth, + $frequencyRecurringDateTime->format('d') + ); + + if (!empty($rrules['BYDAY'])) { + // Get all days of the month that match the BYDAY rule. + $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime); + + // And add each of them to the list of recurrences + foreach ($matchingDays as $day) { + $clonedDateTime = clone $bymonthRecurringDatetime; + $candidateDateTimes[] = $clonedDateTime->setDate( + $frequencyRecurringDateTime->format('Y'), + $bymonthRecurringDatetime->format('m'), + $day + ); + } + } else { + $candidateDateTimes[] = clone $bymonthRecurringDatetime; + } + } + } else { + $candidateDateTimes[] = clone $frequencyRecurringDateTime; + } + break; + } + + foreach ($candidateDateTimes as $candidate) { + $timestamp = $candidate->getTimestamp(); + if ($timestamp <= $initialEventDate->getTimestamp()) { + continue; + } + + if ($timestamp > $until) { + break; + } + + // Exclusions + $isExcluded = ($this->shouldFilterByWindow && $timestamp + 160000 < $this->windowMinTimestamp); + + if (!$isExcluded) { + $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) { + return $exdate->getTimestamp() == $timestamp; + }); + } + + if (!$isExcluded && isset($this->alteredRecurrenceInstances[$anEvent['UID']])) { + if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { + $isExcluded = true; + } + } + + if (!$isExcluded) { + $eventRecurrences[] = $candidate; + $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; + } + } + } + + // Move forwards $interval $frequency. + $monthPreMove = $frequencyRecurringDateTime->format('m'); + $frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}"); + + // As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php, + // there are some occasions where adding months doesn't give the month you might + // expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap + // year.) The following code crudely rectifies this. + if ($frequency === 'MONTHLY') { + $monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove; + + if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) { + $frequencyRecurringDateTime->modify('-1 month'); + } + } + } + + // Determine event length + $eventLength = 0; + if (isset($anEvent['DURATION'])) { + $clonedDateTime = clone $initialEventDate; + $endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]); + $eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2]; + } elseif (isset($anEvent['DTEND_array'])) { + $eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2]; + } + + // Whether or not the initial date was UTC + $initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z'; + + // Build the param array + $dateParamArray = array(); + if ( + !$initialDateWasUTC + && isset($anEvent['DTSTART_array'][0]['TZID']) + && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID']) + ) { + $dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID']; + } + + // Populate the `DT{START|END}[_array]`s + $eventRecurrences = array_map( + function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) { + $tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : ''; + + foreach (array('DTSTART', 'DTEND') as $dtkey) { + $anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : ''); + + $anEvent["{$dtkey}_array"] = array( + $dateParamArray, // [0] Array of params (incl. TZID) + $anEvent[$dtkey], // [1] ICalDateTime string w/o TZID + $recurringDatetime->getTimestamp(), // [2] Unix Timestamp + "{$tzidPrefix}{$anEvent[$dtkey]}", // [3] Full ICalDateTime string + ); + + if ($dtkey !== 'DTEND') { + $recurringDatetime->modify("{$eventLength} seconds"); + } + } + + return $anEvent; + }, + $eventRecurrences + ); + + $allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences); + } + + // Nullify the initial events that are also EXDATEs + foreach ($eventKeysToRemove as $eventKeyToRemove) { + $events[$eventKeyToRemove] = null; + } + + $events = array_merge($events, $allEventRecurrences); + } + + /** + * Find all days of a month that match the BYDAY stanza of an RRULE. + * + * With no {ordwk}, then return the day number of every {weekday} + * within the month. + * + * With a +ve {ordwk}, then return the {ordwk} {weekday} within the + * month. + * + * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday} + * within the month. + * + * RRule Syntax: + * BYDAY={bywdaylist} + * + * Where: + * bywdaylist = {weekdaynum}[,{weekdaynum}...] + * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday} + * ordwk = 1 to 53 + * weekday = SU || MO || TU || WE || TH || FR || SA + * + * @param array $byDays + * @param DateTime $initialDateTime + * @return array + */ + protected function getDaysOfMonthMatchingByDayRRule(array $byDays, $initialDateTime) + { + $matchingDays = array(); + + foreach ($byDays as $weekday) { + $bydayDateTime = clone $initialDateTime; + + $ordwk = intval(substr($weekday, 0, -2)); + + // Quantise the date to the first instance of the requested day in a month + // (Or last if we have a -ve {ordwk}) + $bydayDateTime->modify( + (($ordwk < 0) ? 'Last' : 'First') + . ' ' + . $this->weekdays[substr($weekday, -2)] // e.g. "Monday" + . ' of ' . $initialDateTime->format('F') // e.g. "June" + ); + + if ($ordwk < 0) { // -ve {ordwk} + $bydayDateTime->modify((++$ordwk) . ' week'); + $matchingDays[] = $bydayDateTime->format('j'); + } elseif ($ordwk > 0) { // +ve {ordwk} + $bydayDateTime->modify((--$ordwk) . ' week'); + $matchingDays[] = $bydayDateTime->format('j'); + } else { // No {ordwk} + while ($bydayDateTime->format('n') === $initialDateTime->format('n')) { + $matchingDays[] = $bydayDateTime->format('j'); + $bydayDateTime->modify('+1 week'); + } + } + } + + // Sort into ascending order. + sort($matchingDays); + + return $matchingDays; + } + + /** + * Filters a provided values-list by applying a BYSETPOS RRule. + * + * Where a +ve {daynum} is provided, the {ordday} position'd value as + * measured from the start of the list of values should be retained. + * + * Where a -ve {daynum} is provided, the {ordday} position'd value as + * measured from the end of the list of values should be retained. + * + * RRule Syntax: + * BYSETPOS={bysplist} + * + * Where: + * bysplist = {setposday}[,{setposday}...] + * setposday = {daynum} + * daynum = [+ || -] {ordday} + * ordday = 1 to 366 + * + * @param array $bySetPos + * @param array $valuesList + * @return array + */ + protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList) + { + $filteredMatches = array(); + + foreach ($bySetPos as $setPosition) { + if ($setPosition < 0) { + $setPosition = count($valuesList) + ++$setPosition; + } + + // Positioning starts at 1, array indexes start at 0 + if (isset($valuesList[$setPosition - 1])) { + $filteredMatches[] = $valuesList[$setPosition - 1]; + } + } + + return $filteredMatches; + } + + /** + * Processes date conversions using the time zone + * + * Add keys `DTSTART_tz` and `DTEND_tz` to each Event + * These keys contain dates adapted to the calendar + * time zone depending on the event `TZID`. + * + * @return void + */ + protected function processDateConversions() + { + if (empty($this->cal['VEVENT'])) + return; + + $events =& $this->cal['VEVENT']; + foreach ($events as $key => $anEvent) { + if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) { + unset($events[$key]); + $this->eventCount--; + + continue; + } + + $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART'); + + if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) { + $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND'); + } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) { + $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION'); + } else { + $events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz']; + } + } + } + + /** + * Returns an array of Events. + * Every event is a class with the event + * details being properties within it. + * + * @return ICalEvent[] + */ + public function events() + { + if (empty($this->cal) || empty($this->cal['VEVENT'])) + return []; + + $events = array(); + foreach ($this->cal['VEVENT'] as $event) { + $events[] = new ICalEvent($event); + } + + return $events; + } + + /** + * Returns the calendar name + * + * @return string + */ + public function calendarName() + { + return isset($this->cal['VCALENDAR']['X-WR-CALNAME']) ? $this->cal['VCALENDAR']['X-WR-CALNAME'] : ''; + } + + /** + * Returns the calendar description + * + * @return string + */ + public function calendarDescription() + { + return isset($this->cal['VCALENDAR']['X-WR-CALDESC']) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : ''; + } + + /** + * Returns the calendar time zone + * + * @param boolean $ignoreUtc + * @return string + */ + public function calendarTimeZone($ignoreUtc = false) + { + if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) { + $timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE']; + } elseif (isset($this->cal['VTIMEZONE']['TZID'])) { + $timeZone = $this->cal['VTIMEZONE']['TZID']; + } else { + $timeZone = $this->defaultTimeZone; + } + + // Validate the time zone, falling back to the time zone set in the PHP environment. + $timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName(); + + if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) { + return null; + } + + return $timeZone; + } + + /** + * Returns an array of arrays with all free/busy events. + * Every event is an associative array and each property + * is an element it. + * + * @return array + */ + public function freeBusyEvents() + { + $array = $this->cal; + + return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : array(); + } + + /** + * Returns a sorted array of the events in a given range, + * or an empty array if no events exist in the range. + * + * Events will be returned if the start or end date is contained within the + * range (inclusive), or if the event starts before and end after the range. + * + * If a start date is not specified or of a valid format, then the start + * of the range will default to the current time and date of the server. + * + * If an end date is not specified or of a valid format, then the end of + * the range will default to the current time and date of the server, + * plus 20 years. + * + * Note that this function makes use of Unix timestamps. This might be a + * problem for events on, during, or after 29 Jan 2038. + * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number + * + * @param string|null $rangeStart + * @param string|null $rangeEnd + * @return array + * @throws Exception + */ + public function eventsFromRange($rangeStart = null, $rangeEnd = null) + { + // Sort events before processing range + $events = $this->sortEventsWithOrder($this->events()); + + if (empty($events)) { + return array(); + } + + $extendedEvents = array(); + + if (!is_null($rangeStart)) { + try { + $rangeStart = new DateTime($rangeStart, new DateTimeZone($this->defaultTimeZone)); + } catch (Exception $exception) { + error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})"); + $rangeStart = false; + } + } else { + $rangeStart = new DateTime('now', new DateTimeZone($this->defaultTimeZone)); + } + + if (!is_null($rangeEnd)) { + try { + $rangeEnd = new DateTime($rangeEnd, new DateTimeZone($this->defaultTimeZone)); + } catch (Exception $exception) { + error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})"); + $rangeEnd = false; + } + } else { + $rangeEnd = new DateTime('now', new DateTimeZone($this->defaultTimeZone)); + $rangeEnd->modify('+20 years'); + } + + // If start and end are identical and are dates with no times... + if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() === $rangeEnd->getTimestamp()) { + $rangeEnd->modify('+1 day'); + } + + $rangeStart = $rangeStart->getTimestamp(); + $rangeEnd = $rangeEnd->getTimestamp(); + + foreach ($events as $anEvent) { + $eventStart = $anEvent->dtstart_array[2]; + $eventEnd = (isset($anEvent->dtend_array[2])) ? $anEvent->dtend_array[2] : null; + + if ( + ($eventStart >= $rangeStart && $eventStart < $rangeEnd) // Event start date contained in the range + || ($eventEnd !== null + && ( + ($eventEnd > $rangeStart && $eventEnd <= $rangeEnd) // Event end date contained in the range + || ($eventStart < $rangeStart && $eventEnd > $rangeEnd) // Event starts before and finishes after range + ) + ) + ) { + $extendedEvents[] = $anEvent; + } + } + + if (empty($extendedEvents)) { + return array(); + } + + return $extendedEvents; + } + + /** + * Sorts events based on a given sort order + * + * @param array $events + * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING + * @return array + */ + public function sortEventsWithOrder(array $events, $sortOrder = SORT_ASC) + { + $extendedEvents = array(); + $timestamp = array(); + + foreach ($events as $key => $anEvent) { + $extendedEvents[] = $anEvent; + $timestamp[$key] = $anEvent->dtstart_array[2]; + } + + array_multisort($timestamp, $sortOrder, $extendedEvents); + + return $extendedEvents; + } + + /** + * Checks if a time zone is valid (IANA, CLDR, or Windows) + * + * @param string $timeZone + * @return boolean + */ + protected function isValidTimeZoneId($timeZone) + { + return $this->isValidIanaTimeZoneId($timeZone) !== false + || $this->isValidCldrTimeZoneId($timeZone) !== false + || $this->isValidWindowsTimeZoneId($timeZone) !== false; + } + + /** + * Checks if a time zone is a valid IANA time zone + * + * @param string $timeZone + * @return boolean + */ + protected function isValidIanaTimeZoneId($timeZone) + { + if (in_array($timeZone, $this->validIanaTimeZones)) { + return true; + } + + $valid = array(); + $tza = timezone_abbreviations_list(); + + foreach ($tza as $zone) { + foreach ($zone as $item) { + $valid[$item['timezone_id']] = true; + } + } + + unset($valid['']); + + if (isset($valid[$timeZone]) || in_array($timeZone, timezone_identifiers_list(DateTimeZone::ALL_WITH_BC))) { + $this->validIanaTimeZones[] = $timeZone; + + return true; + } + + return false; + } + + /** + * Checks if a time zone is a valid CLDR time zone + * + * @param string $timeZone + * @return boolean + */ + public function isValidCldrTimeZoneId($timeZone) + { + return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap); + } + + /** + * Checks if a time zone is a recognised Windows (non-CLDR) time zone + * + * @param string $timeZone + * @return boolean + */ + public function isValidWindowsTimeZoneId($timeZone) + { + return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap); + } + + /** + * Parses a duration and applies it to a date + * + * @param string $date + * @param DateInterval $duration + * @param string $format + * @return integer|DateTime + */ + protected function parseDuration($date, $duration, $format = self::UNIX_FORMAT) + { + $dateTime = date_create($date); + $dateTime->modify("{$duration->y} year"); + $dateTime->modify("{$duration->m} month"); + $dateTime->modify("{$duration->d} day"); + $dateTime->modify("{$duration->h} hour"); + $dateTime->modify("{$duration->i} minute"); + $dateTime->modify("{$duration->s} second"); + + if (is_null($format)) { + $output = $dateTime; + } elseif ($format === self::UNIX_FORMAT) { + $output = $dateTime->getTimestamp(); + } else { + $output = $dateTime->format($format); + } + + return $output; + } + + /** + * Removes unprintable ASCII and UTF-8 characters + * + * @param string $data + * @return string + */ + protected function removeUnprintableChars($data) + { + return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data); + } + + /** + * Places double-quotes around texts that have characters not permitted + * in parameter-texts, but are permitted in quoted-texts. + * + * @param string $candidateText + * @return string + */ + protected function escapeParamText($candidateText) + { + if (strpbrk($candidateText, ':;,') !== false) { + return '"' . $candidateText . '"'; + } + + return $candidateText; + } + + /** + * Parses a list of excluded dates + * to be applied to an Event + * + * @param array $event + * @return array + */ + public function parseExdates(array $event) + { + if (empty($event['EXDATE_array'])) { + return array(); + } else { + $exdates = $event['EXDATE_array']; + } + + $output = array(); + $currentTimeZone = $this->defaultTimeZone; + + foreach ($exdates as $subArray) { + end($subArray); + $finalKey = key($subArray); + + foreach (array_keys($subArray) as $key) { + if ($key === 'TZID') { + $currentTimeZone = $this->timeZoneStringToDateTimeZone($subArray[$key]); + } elseif (is_numeric($key)) { + $icalDate = $subArray[$key]; + + if (substr($icalDate, -1) === 'Z') { + $currentTimeZone = self::TIME_ZONE_UTC; + } + + $output[] = new DateTimeImmutable($icalDate, $currentTimeZone); + + if ($key === $finalKey) { + // Reset to default + $currentTimeZone = $this->defaultTimeZone; + } + } + } + } + + return $output; + } + + /** + * Checks if a date string is a valid date + * + * @param string $value + * @return boolean + */ + public function isValidDate($value) + { + if (!$value) { + return false; + } + + try { + new DateTime($value); + + return true; + } catch (Exception $exception) { + return false; + } + } + + /** + * Returns a `DateTimeZone` object based on a string containing a time zone name. + * Falls back to the default time zone if string passed not a recognised time zone. + * + * @param string $timeZoneString + * @return DateTimeZone + */ + public function timeZoneStringToDateTimeZone($timeZoneString) + { + // Some time zones contain characters that are not permitted in param-texts, + // but are within quoted texts. We need to remove the quotes as they're not + // actually part of the time zone. + $timeZoneString = trim($timeZoneString, '"'); + $timeZoneString = html_entity_decode($timeZoneString); + + if ($this->isValidIanaTimeZoneId($timeZoneString)) { + return new DateTimeZone($timeZoneString); + } + + if ($this->isValidCldrTimeZoneId($timeZoneString)) { + return new DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]); + } + + if ($this->isValidWindowsTimeZoneId($timeZoneString)) { + return new DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]); + } + + return new DateTimeZone($this->defaultTimeZone); + } +} diff --git a/modules-available/locationinfo/lang/de/backend-hisinone.json b/modules-available/locationinfo/lang/de/backend-hisinone.json index 6ea1a933..5a2ad69f 100644 --- a/modules-available/locationinfo/lang/de/backend-hisinone.json +++ b/modules-available/locationinfo/lang/de/backend-hisinone.json @@ -1,14 +1,6 @@ { "baseUrl": "Basis-URL", - "baseUrl_helptext": "URL zur HisInOne-Installation", - "open": "Service", - "open_helptext": "Legt den zu verwendenden Web Service fest. OpenCourseService bietet anonymisierte Belegungspl\u00e4ne und erfordert keine Authentifizierung und ist i.d.R. bevorzugt.", - "password": "Passwort", - "password_helptext": "Das Passwort, das in HisInOne verwendet wird.", - "role": "Rolle", - "role_helptext": "Die Rolle die der Nutzername in HisInOne verwendet.", - "username": "Nutzername", - "username_helptext": "Der Nutzername, der in HisInOne verwendet wird.", + "baseUrl_helptext": "URL zur HisInOne-Installation, bzw. die URL, um einen Raumbelegungsplan als iCal-Datei herunterzuladen, z.B. \"https:\/\/\/qisserver\/pages\/cm\/exa\/timetable\/roomScheduleCalendarExport.faces?roomId=\"", "verifyCert": "Zertifikat pr\u00fcfen", "verifyCert_helptext": "Wenn das Zertifikat abgelaufen ist, oder von keiner bekannten CA ausgestellt wurde, wird die Verbindung abgelehnt.", "verifyHostname": "Hostnamen pr\u00fcfen", diff --git a/modules-available/locationinfo/lang/en/backend-hisinone.json b/modules-available/locationinfo/lang/en/backend-hisinone.json index 616b4c83..ee44a62e 100644 --- a/modules-available/locationinfo/lang/en/backend-hisinone.json +++ b/modules-available/locationinfo/lang/en/backend-hisinone.json @@ -1,16 +1,8 @@ { "baseUrl": "Base URL", - "baseUrl_helptext": "URL to HisInOne installation", - "open": "Service", - "open_helptext": "Sets the Web Service to use. OpenCourseService is anonymized and doesn't require authentication, so it's usually the preferred way.", - "password": "Password", - "password_helptext": "Account password. Only required if using CourseService", - "role": "Role", - "role_helptext": "Role of the user accessing the CourseService.", - "username": "Username", - "username_helptext": "Authenticating user (only required for CourseService).", + "baseUrl_helptext": "URL to HisInOne installation, or more precisely the URL to download a room's events as an iCal file, usually something like \"https:\/\/\/qisserver\/pages\/cm\/exa\/timetable\/roomScheduleCalendarExport.faces?roomId=\"", "verifyCert": "Verify certificate", "verifyCert_helptext": "If the certificate expired or was not signed by a known CA, the connection will be aborted.", "verifyHostname": "Verify host name", "verifyHostname_helptext": "The certificate's host name must match the host name given in the URL, otherwise the connection will be aborted." -} +} \ No newline at end of file diff --git a/modules-available/locationinfo/page.inc.php b/modules-available/locationinfo/page.inc.php index 0885f446..e6269857 100644 --- a/modules-available/locationinfo/page.inc.php +++ b/modules-available/locationinfo/page.inc.php @@ -752,8 +752,9 @@ class Page_LocationInfo extends Page ); $backend['credentials'] = $backendInstance->getCredentialDefinitions(); foreach ($backend['credentials'] as $cred) { + /* @var BackendProperty $cred */ if ($backend['active'] && isset($oldCredentials[$cred->property])) { - $cred->initForRender($oldCredentials[$cred->property]); + $cred->initForRender($backendInstance->mangleProperty($cred->property, $oldCredentials[$cred->property])); } else { $cred->initForRender(); } -- cgit v1.2.3-55-g7522