diff options
Diffstat (limited to 'modules-available/locationinfo/inc')
12 files changed, 2812 insertions, 480 deletions
diff --git a/modules-available/locationinfo/inc/coursebackend.inc.php b/modules-available/locationinfo/inc/coursebackend.inc.php index a66d35a3..ea1bebac 100644 --- a/modules-available/locationinfo/inc/coursebackend.inc.php +++ b/modules-available/locationinfo/inc/coursebackend.inc.php @@ -39,7 +39,7 @@ abstract class CourseBackend $this->errors = []; } - protected final function addError($message, $fatal) + protected final function addError(string $message, bool $fatal) { $this->errors[] = ['time' => time(), 'message' => $message, 'fatal' => $fatal]; } @@ -55,7 +55,7 @@ abstract class CourseBackend self::$backendTypes = array(); foreach (glob(dirname(__FILE__) . '/coursebackend/coursebackend_*.inc.php', GLOB_NOSORT) as $file) { require_once $file; - preg_match('#coursebackend_([^/\.]+)\.inc\.php$#i', $file, $out); + preg_match('#coursebackend_([^/.]+)\.inc\.php$#i', $file, $out); $className = 'CourseBackend_' . $out[1]; if (!class_exists($className)) { trigger_error("Backend type source unit $file doesn't seem to define class $className", E_USER_ERROR); @@ -71,13 +71,13 @@ abstract class CourseBackend * * @return array list of backends */ - public static function getList() + public static function getList(): array { self::loadDb(); return array_keys(self::$backendTypes); } - public static function exists($backendType) + public static function exists($backendType): bool { self::loadDb(); return isset(self::$backendTypes[$backendType]); @@ -89,7 +89,7 @@ abstract class CourseBackend * @param string $backendType name of module type * @return \CourseBackend|false module instance */ - public static function getInstance($backendType) + public static function getInstance(string $backendType) { self::loadDb(); if (!isset(self::$backendTypes[$backendType])) { @@ -106,18 +106,18 @@ abstract class CourseBackend /** * @return string return display name of backend */ - public abstract function getDisplayName(); + public abstract function getDisplayName(): string; /** * @returns \BackendProperty[] list of properties that need to be set */ - public abstract function getCredentialDefinitions(); + public abstract function getCredentialDefinitions(): array; /** * @return boolean true if the connection works, false otherwise */ - public abstract function checkConnection(); + public abstract function checkConnection(): bool; /** * uses json to setCredentials, the json must follow the form given in @@ -126,36 +126,45 @@ abstract class CourseBackend * @param array $data assoc array with data required by backend * @returns bool if the credentials were in the correct format */ - public abstract function setCredentialsInternal($data); + public abstract function setCredentialsInternal(array $data): bool; /** * @return int desired caching time of results, in seconds. 0 = no caching */ - public abstract function getCacheTime(); + public abstract function getCacheTime(): int; /** * @return int age after which timetables are no longer refreshed should be * greater then CacheTime */ - public abstract function getRefreshTime(); + public abstract function getRefreshTime(): int; /** * Internal version of fetch, to be overridden by subclasses. * - * @param $roomIds array with remote IDs for wanted rooms + * @param $requestedRoomIds array with remote IDs for wanted rooms * @return array a recursive array that uses the roomID as key * and has the schedule array as value. A schedule array contains an array in this format: * ["start"=>'JJJJ-MM-DD"T"HH:MM:SS',"end"=>'JJJJ-MM-DD"T"HH:MM:SS',"title"=>string] */ - protected abstract function fetchSchedulesInternal($roomId); + protected abstract function fetchSchedulesInternal(array $requestedRoomIds): array; - private static function fixTime(&$start, &$end) + /** + * In case you want to sanitize or otherwise mangle a property for your backend, + * override this. + */ + public function mangleProperty(string $prop, $value) + { + return $value; + } + + private static function fixTime(string &$start, string &$end): bool { - 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); @@ -166,14 +175,10 @@ abstract class CourseBackend * Method for fetching the schedule of the given rooms on a server. * * @param array $requestedLocationIds array of room ID to fetch - * @return array|bool array containing the timetables as value and roomid as key as result, or false on error + * @return array array containing the timetables as value and roomid as key as result, or false on error */ - public final function fetchSchedule($requestedLocationIds) + public final function fetchSchedule(array $requestedLocationIds): array { - if (!is_array($requestedLocationIds)) { - $this->addError('No array of roomids was given to fetchSchedule', false); - return false; - } if (empty($requestedLocationIds)) return array(); $requestedLocationIds = array_values($requestedLocationIds); @@ -183,14 +188,13 @@ abstract class CourseBackend array('locations' => $requestedLocationIds)); $returnValue = []; $remoteIds = []; - while ($row = $dbquery1->fetch(PDO::FETCH_ASSOC)) { - //Check if in cache if lastUpdate is null then it is interpreted as 1970 - if ($row['lastcalendarupdate'] + $this->getCacheTime() > $NOW) { - $returnValue[$row['locationid']] = json_decode($row['calendar']); - } else { + foreach ($dbquery1 as $row) { + // Check if in cache - if lastUpdate is null then it is interpreted as 1970 + if ($row['lastcalendarupdate'] + $this->getCacheTime() < $NOW) { $remoteIds[$row['locationid']] = $row['serverlocationid']; } - + // Always add to return value - if updating fails, we better use the stale data than nothing + $returnValue[$row['locationid']] = json_decode($row['calendar'], true); } // No need for additional round trips to backend if (empty($remoteIds)) { @@ -211,7 +215,7 @@ abstract class CourseBackend 'lastuse' => $NOW - $this->getRefreshTime(), 'minage' => $NOW - $this->getCacheTime(), )); - while ($row = $dbquery4->fetch(PDO::FETCH_ASSOC)) { + foreach ($dbquery4 as $row) { $remoteIds[$row['locationid']] = $row['serverlocationid']; } } @@ -222,15 +226,12 @@ abstract class CourseBackend // if, nothing bad will happen... Database::exec("UPDATE locationinfo_locationconfig SET lastcalendarupdate = :time WHERE lastcalendarupdate < :time AND serverid = :serverid AND serverlocationid IN (:slocs)", [ - 'time' => $NOW - $this->getCacheTime() / 2, + 'time' => $NOW - ($this->getCacheTime() - 60), // Protect for one minute max. 'serverid' => $this->serverId, 'slocs' => array_values($remoteIds), ]); } $backendResponse = $this->fetchSchedulesInternal(array_unique($remoteIds)); - if ($backendResponse === false) { - return false; - } // Fetching might have taken a while, get current time again $NOW = time(); @@ -254,15 +255,15 @@ abstract class CourseBackend 'serverid' => $this->serverId, 'serverlocationid' => $serverRoomId, 'ttable' => $value, - 'now' => $NOW + 'now' => $NOW, // Set real "lastupdate" here )); } - - unset($calendar); } + unset($calendar); // Add rooms that were requested to the final return value foreach ($remoteIds as $location => $serverRoomId) { - if (isset($backendResponse[$serverRoomId]) && in_array($location, $requestedLocationIds)) { + if (isset($backendResponse[$serverRoomId]) && is_array($backendResponse[$serverRoomId]) + && in_array($location, $requestedLocationIds)) { // Only add if we can map it back to our location id AND it was not an unsolicited coalesced refresh $returnValue[$location] = $backendResponse[$serverRoomId]; } @@ -271,7 +272,7 @@ abstract class CourseBackend return $returnValue; } - public final function setCredentials($serverId, $data) + public final function setCredentials(int $serverId, array $data): bool { foreach ($this->getCredentialDefinitions() as $prop) { if (!isset($data[$prop->property])) { @@ -291,18 +292,9 @@ abstract class CourseBackend } /** - * @return false if there was no error string with error message if there was one - */ - public final function getError() - { - trigger_error('getError() is legacy; use getErrors()'); - return $this->error; - } - - /** * @return array list of errors that occurred during processing. */ - public final function getErrors() + public final function getErrors(): array { return $this->errors; } @@ -367,7 +359,7 @@ abstract class CourseBackend * @param string $response xml document to convert * @return bool|array array representation of the xml if possible, false otherwise */ - protected function xmlStringToArray($response, &$error) + protected function xmlStringToArray(string $response, &$error) { $cleanresponse = preg_replace('/(<\/?)(\w+):([^>]*>)/', '$1$2$3', $response); try { @@ -379,8 +371,7 @@ abstract class CourseBackend } return false; } - $array = json_decode(json_encode((array)$xml), true); - return $array; + return json_decode(json_encode((array)$xml), true); } } @@ -403,7 +394,6 @@ class BackendProperty { * Initialize additional fields of this class that are only required * for rendering the server configuration dialog. * - * @param string $backendId target backend id * @param mixed $current current value of this property. */ public function initForRender($current = null) { diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php index 07c8457d..786ab459 100644 --- a/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php @@ -11,7 +11,7 @@ class CourseBackend_Davinci extends CourseBackend */ private $curlHandle = false; - public function setCredentialsInternal($data) + public function setCredentialsInternal(array $data): bool { if (empty($data['baseUrl'])) { $this->addError("No url is given", true); @@ -24,7 +24,7 @@ class CourseBackend_Davinci extends CourseBackend return true; } - public function checkConnection() + public function checkConnection(): bool { if (empty($this->location)) { $this->addError("Credentials are not set", true); @@ -40,7 +40,7 @@ class CourseBackend_Davinci extends CourseBackend return true; } - public function getCredentialDefinitions() + public function getCredentialDefinitions(): array { return [ new BackendProperty('baseUrl', 'string'), @@ -49,17 +49,17 @@ class CourseBackend_Davinci extends CourseBackend ]; } - public function getDisplayName() + public function getDisplayName(): string { return 'Davinci'; } - public function getCacheTime() + public function getCacheTime(): int { return 30 * 60; } - public function getRefreshTime() + public function getRefreshTime(): int { return 0; } @@ -68,9 +68,9 @@ class CourseBackend_Davinci extends CourseBackend * @param string $roomId unique name of the room, as used by davinci * @param \DateTime $startDate start date to fetch * @param \DateTime $endDate end date of range to fetch - * @return array|bool if successful the arrayrepresentation of the timetable + * @return false|string if successful the array representation of the timetable */ - private function fetchRoomRaw($roomId, $startDate, $endDate) + private function fetchRoomRaw(string $roomId, DateTime $startDate, DateTime $endDate) { $url = $this->location . "content=xml&type=room&name=" . urlencode($roomId) . "&startdate=" . $startDate->format('d.m.Y') . "&enddate=" . $endDate->format('d.m.Y'); @@ -97,7 +97,7 @@ class CourseBackend_Davinci extends CourseBackend } - public function fetchSchedulesInternal($requestedRoomIds) + public function fetchSchedulesInternal(array $requestedRoomIds): array { $startDate = new DateTime('last Monday 0:00'); $endDate = new DateTime('+14 days 0:00'); @@ -134,7 +134,7 @@ class CourseBackend_Davinci extends CourseBackend $start = substr($start, 0, 2) . ':' . substr($start, 2, 2); $end = $lesson['Finish']; $end = substr($end, 0, 2) . ':' . substr($end, 2, 2); - $subject = isset($lesson['Subject']) ? $lesson['Subject'] : '???'; + $subject = $lesson['Subject'] ?? '???'; $timetable[] = array( 'title' => $subject, 'start' => $date . "T" . $start . ':00', diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php index 2cb2be18..4588bf7c 100644 --- a/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php @@ -15,9 +15,9 @@ class CourseBackend_Dummy extends CourseBackend * @param int $serverId ID of the server * @returns bool if the credentials were in the correct format */ - public function setCredentialsInternal($json) + public function setCredentialsInternal(array $data): bool { - $x = $json; + $x = $data; $this->pw = $x['password']; if ($this->pw === "mfg") { @@ -30,7 +30,7 @@ class CourseBackend_Dummy extends CourseBackend /** * @return boolean true if the connection works, false otherwise */ - public function checkConnection() + public function checkConnection(): bool { if ($this->pw == "mfg") { return true; @@ -42,7 +42,7 @@ class CourseBackend_Dummy extends CourseBackend /** * @returns array with parameter name as key and and an array with type, help text and mask as value */ - public function getCredentialDefinitions() + public function getCredentialDefinitions(): array { $options = ["opt1", "opt2", "opt3", "opt4", "opt5", "opt6", "opt7", "opt8"]; return [ @@ -58,7 +58,7 @@ class CourseBackend_Dummy extends CourseBackend /** * @return string return display name of backend */ - public function getDisplayName() + public function getDisplayName(): string { return 'Dummy with array'; } @@ -66,7 +66,7 @@ class CourseBackend_Dummy extends CourseBackend /** * @return int desired caching time of results, in seconds. 0 = no caching */ - public function getCacheTime() + public function getCacheTime(): int { return 0; } @@ -75,7 +75,7 @@ class CourseBackend_Dummy extends CourseBackend * @return int age after which timetables are no longer refreshed should be * greater then CacheTime */ - public function getRefreshTime() + public function getRefreshTime(): int { return 0; } @@ -83,15 +83,15 @@ class CourseBackend_Dummy extends CourseBackend /** * Internal version of fetch, to be overridden by subclasses. * - * @param $roomIds array with local ID as key and serverId as value + * @param $requestedRoomIds array with local ID as key and serverId as value * @return array a recursive array that uses the roomID as key * and has the schedule array as value. A schedule array contains an array in this format: * ["start"=>'YYYY-MM-DD<T>HH:MM:SS',"end"=>'YYYY-MM-DD<T>HH:MM:SS',"title"=>string] */ - public function fetchSchedulesInternal($roomId) + public function fetchSchedulesInternal(array $requestedRoomIds): array { $a = array(); - foreach ($roomId as $id) { + foreach ($requestedRoomIds as $id) { if ($id == 1) { $now = time(); return array($id => array( diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php index 44847ce2..df33dadd 100755 --- a/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php @@ -12,6 +12,7 @@ spl_autoload_register(function ($class) { require_once $file; }); +use jamesiarmes\PhpEws\ArrayType\NonEmptyArrayOfBaseFolderIdsType; use jamesiarmes\PhpEws\Client; use jamesiarmes\PhpEws\Enumeration\DefaultShapeNamesType; use jamesiarmes\PhpEws\Enumeration\DistinguishedFolderIdNameType; @@ -38,7 +39,7 @@ class CourseBackend_Exchange extends CourseBackend /** * @return string return display name of backend */ - public function getDisplayName() + public function getDisplayName(): string { return "Microsoft Exchange"; } @@ -46,7 +47,7 @@ class CourseBackend_Exchange extends CourseBackend /** * @returns \BackendProperty[] list of properties that need to be set */ - public function getCredentialDefinitions() + public function getCredentialDefinitions(): array { $options = [ Client::VERSION_2007, @@ -72,7 +73,7 @@ class CourseBackend_Exchange extends CourseBackend /** * @return boolean true if the connection works, false otherwise */ - public function checkConnection() + public function checkConnection(): bool { $client = $this->getClient(); $request = new ResolveNamesType(); @@ -104,7 +105,7 @@ class CourseBackend_Exchange extends CourseBackend * @param array $data assoc array with data required by backend * @returns bool if the credentials were in the correct format */ - public function setCredentialsInternal($data) + public function setCredentialsInternal(array $data): bool { foreach (['username', 'password'] as $field) { if (empty($data[$field])) { @@ -133,7 +134,7 @@ class CourseBackend_Exchange extends CourseBackend /** * @return int desired caching time of results, in seconds. 0 = no caching */ - public function getCacheTime() + public function getCacheTime(): int { return 15 * 60; } @@ -142,7 +143,7 @@ class CourseBackend_Exchange extends CourseBackend * @return int age after which timetables are no longer refreshed. should be * greater than CacheTime. */ - public function getRefreshTime() + public function getRefreshTime(): int { return 30 * 60; } @@ -150,12 +151,12 @@ class CourseBackend_Exchange extends CourseBackend /** * Internal version of fetch, to be overridden by subclasses. * - * @param $roomIds array with local ID as key and serverId as value + * @param $requestedRoomIds array with local ID as key and serverId as value * @return array a recursive array that uses the roomID as key * and has the schedule array as value. A schedule array contains an array in this format: * ["start"=>'JJJJ-MM-DD HH:MM:SS',"end"=>'JJJJ-MM-DD HH:MM:SS',"title"=>string] */ - protected function fetchSchedulesInternal($requestedRoomIds) + protected function fetchSchedulesInternal(array $requestedRoomIds): array { $startDate = new DateTime('last Monday 0:00'); $endDate = new DateTime('+14 days 0:00'); @@ -172,8 +173,13 @@ class CourseBackend_Exchange extends CourseBackend // Iterate over the events that were found, printing some data for each. foreach ($items as $item) { - $start = new DateTime($item->Start); - $end = new DateTime($item->End); + try { + $start = new DateTime($item->Start); + $end = new DateTime($item->End); + } catch (Exception $e) { + $this->addError("Invalid date range: '{$item->Start}' -> '{$item->End}'", false); + continue; + } $schedules[$roomId][] = array( 'title' => $item->Subject, @@ -186,13 +192,9 @@ class CourseBackend_Exchange extends CourseBackend } /** - * @param \jamesiarmes\PhpEws\Client $client - * @param \DateTime $startDate - * @param \DateTime $endDate - * @param string $roomAddress * @return \jamesiarmes\PhpEws\Type\CalendarItemType[] */ - public function findEventsForRoom($client, $startDate, $endDate, $roomAddress) + public function findEventsForRoom(Client $client, DateTime $startDate, DateTime $endDate, string $roomAddress): array { $request = new FindItemType(); $request->Traversal = ItemQueryTraversalType::SHALLOW; @@ -206,12 +208,20 @@ class CourseBackend_Exchange extends CourseBackend $folderId->Id = DistinguishedFolderIdNameType::CALENDAR; $folderId->Mailbox = new EmailAddressType(); $folderId->Mailbox->EmailAddress = $roomAddress; + $request->ParentFolderIds = new NonEmptyArrayOfBaseFolderIdsType(); $request->ParentFolderIds->DistinguishedFolderId[] = $folderId; - $response = $client->FindItem($request); - $response_messages = $response->ResponseMessages->FindItemResponseMessage; - + try { + $response = $client->FindItem($request); + } catch (Exception $e) { + $this->addError('Exception calling FindItem: ' . $e->getMessage(), true); + return []; + } + if (!is_object($response->ResponseMessages)) { + $this->addError('FindItem returned response without ResponseMessages', true); + return []; + } $items = []; - foreach ($response_messages as $response_message) { + foreach ($response->ResponseMessages->FindItemResponseMessage as $response_message) { // Make sure the request succeeded. if ($response_message->ResponseClass !== ResponseClassType::SUCCESS) { $code = $response_message->ResponseCode; @@ -224,10 +234,7 @@ class CourseBackend_Exchange extends CourseBackend return $items; } - /** - * @return \jamesiarmes\PhpEws\Client - */ - public function getClient() + public function getClient(): Client { $client = new Client($this->serverAddress, $this->username, $this->password, $this->clientVersion); $client->setTimezone($this->timezone); diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php index 4664a011..55d5ed4b 100644 --- a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php @@ -1,391 +1,84 @@ <?php -class CourseBackend_HisInOne extends CourseBackend +class CourseBackend_HisInOne extends ICalCourseBackend { - private $username = ''; - private $password = ''; - private $open = true; - private $location; - private $verifyHostname = true; - private $verifyCert = true; - /** - * @var bool|resource - */ - private $curlHandle = false; - - public function setCredentialsInternal($data) + public function setCredentialsInternal(array $data): bool { - 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->verifyHostname = $data['verifyHostname']; - $this->verifyCert = $data['verifyCert']; + $this->init($this->mangleProperty('baseUrl', $data['baseUrl']), + $data['verifyCert'], $data['verifyHostname']); return true; } - public function getCredentialDefinitions() + public function getCredentialDefinitions(): array { 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(string $prop, $value) { - if (empty($this->location)) { - $this->addError("Credentials are not set", true); - return false; - } - return $this->findUnit(123456789, date('Y-m-d'), true) !== false; - } - - /** - * @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) - { - $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)); - 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)); + 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=%ID%'; + } elseif (preg_match(',(.*[/=])\d*$', $value, $out)) { + $value = $out[1] . '%ID%'; + } elseif (substr_count($value, '/') <= 3) { + if (substr($value, -1) !== '/') { + $value .= '/'; + } + $value .= 'qisserver/pages/cm/exa/timetable/roomScheduleCalendarExport.faces?roomId=%ID%'; + } } - return $idList; + return $value; } - /** - * @param $doc DOMDocument - * @return DOMElement - */ - private function getHeader($doc) + protected function toTitle(ICalEvent $event): string { - $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; + $title = parent::toTitle($event); + // His in one seems to prefix *some* (but *not* all) of the lectures by their ID/("Nummer") + // No clue what that format is supposed to be, this regex is some guesswork after observing this for a while + return preg_replace('#^[0-9][0-9A-ZÄÖÜ]{3,9}-[A-Za-z0-9/_ÄÖÜäöüß.-]{4,30}\s+#u', '', $title); } - /** - * @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) + public function checkConnection(): bool { - $header = array( - 'Content-type: text/xml;charset="utf-8"', - 'SOAPAction: "' . $action . '"', - ); - - if ($this->curlHandle === false) { - $this->curlHandle = curl_init(); - } - - $options = array( - CURLOPT_RETURNTRANSFER => true, - 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, - ); - - curl_setopt_array($this->curlHandle, $options); - - $output = curl_exec($this->curlHandle); - - if ($output === false) { - $this->addError('Curl error: ' . curl_error($this->curlHandle), false); - } - return $output; + if (!$this->isOK()) + return false; + // 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 false; } - public function getCacheTime() + public function getCacheTime(): int { return 30 * 60; } - - public function getRefreshTime() + public function getRefreshTime(): int { return 60 * 60; } - public function getDisplayName() + public function getDisplayName(): string { return "HisInOne"; } - public function fetchSchedulesInternal($requestedRoomIds) - { - 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)); - 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, - ); - } - } - } - } - 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 - */ - public function readUnit($unit) - { - $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; - } - 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')); - } - return $returnValue; - } - - public function __destruct() - { - if ($this->curlHandle !== false) { - curl_close($this->curlHandle); - } - } - } diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php new file mode 100644 index 00000000..f1791c4e --- /dev/null +++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php @@ -0,0 +1,61 @@ +<?php + +class CourseBackend_ICal extends ICalCourseBackend +{ + + /** @var string room ID for testing connection */ + private $testId; + + public function setCredentialsInternal(array $data): bool + { + if (empty($data['baseUrl'])) { + $this->addError("No url is given", true); + return false; + } + + $this->init($data['baseUrl'], $data['verifyCert'], $data['verifyHostname'], $data['authMethod'], + $data['user'], $data['pass']); + $this->testId = $data['testId']; + + return true; + } + + public function getCredentialDefinitions(): array + { + return [ + new BackendProperty('baseUrl', 'string'), + new BackendProperty('verifyCert', 'bool', true), + new BackendProperty('verifyHostname', 'bool', true), + new BackendProperty('testId', 'string'), + new BackendProperty('authMethod', ['NONE', 'BASIC', 'DIGEST', 'GSSNEGOTIATE', 'NTLM'], 'NONE'), + new BackendProperty('user', 'string'), + new BackendProperty('pass', 'string'), + ]; + } + + public function checkConnection(): bool + { + if (!$this->isOK()) + return false; + if (empty($this->testId)) + return true; + return ($this->downloadIcal($this->testId) !== null); + } + + public function getCacheTime(): int + { + return 30 * 60; + } + + public function getRefreshTime(): int + { + return 60 * 60; + } + + + public function getDisplayName(): string + { + return "iCal"; + } + +} diff --git a/modules-available/locationinfo/inc/icalcoursebackend.inc.php b/modules-available/locationinfo/inc/icalcoursebackend.inc.php new file mode 100644 index 00000000..838d18b7 --- /dev/null +++ b/modules-available/locationinfo/inc/icalcoursebackend.inc.php @@ -0,0 +1,148 @@ +<?php + +abstract class ICalCourseBackend extends CourseBackend +{ + + /** @var string */ + private $location; + /** @var string */ + private $authMethod; + /** @var string */ + private $user; + /** @var string */ + private $pass; + /** @var bool */ + private $verifyHostname; + /** @var bool */ + private $verifyCert; + /** @var bool|resource */ + private $curlHandle = false; + + protected function init( + string $location, bool $verifyCert, bool $verifyHostname, + string $authMethod = 'NONE', string $user = '', string $pass = '') + { + $this->verifyCert = $verifyCert; + $this->verifyHostname = $verifyHostname; + if (strpos($location, '%ID%') === false) { + $location .= '%ID%'; + } + $this->location = $location; + $this->authMethod = $authMethod; + $this->user = $user; + $this->pass = $pass; + } + + /** + * @param string $roomId room id + * @param callable $errorFunc + * @return ICalEvent[]|null all events for this room in the range -7 days to +7 days, or NULL on error + */ + protected function downloadIcal(string $roomId): ?array + { + if (!$this->isOK()) + return null; + + try { + $ical = new ICalParser(['filterDaysBefore' => 7, 'filterDaysAfter' => 7]); + } catch (Exception $e) { + $this->addError('Error instantiating ICalParser: ' . $e->getMessage(), true); + return null; + } + + if ($this->curlHandle === false) { + $this->curlHandle = curl_init(); + } + + $options = [ + 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 => str_replace('%ID%', $roomId, $this->location), + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 4, + ]; + if ($this->authMethod !== 'NONE' && defined('CURLAUTH_' . $this->authMethod)) { + $options[CURLOPT_HTTPAUTH] = constant('CURLAUTH_' . $this->authMethod); + $options[CURLOPT_USERPWD] = $this->user; + if (!empty($this->pass)) { + $options[CURLOPT_USERPWD] .= ':' . $this->pass; + } + } + + curl_setopt_array($this->curlHandle, $options); + + $curlRet = curl_exec($this->curlHandle); + if (!$curlRet) { + $this->addError('Curl error: ' . curl_error($this->curlHandle) . ' for ' . $roomId, false); + } + $ical->finish(); + if (!$ical->isValid()) { + if ($curlRet) { + $this->addError("Did not find a VCALENDAR in returned data for $roomId", false); + } + return null; + } + return $ical->events(); + } + + public function fetchSchedulesInternal(array $requestedRoomIds): array + { + if (empty($requestedRoomIds) || !$this->isOK()) { + return array(); + } + $tTables = []; + foreach ($requestedRoomIds as $roomId) { + $data = $this->downloadIcal($roomId); + if ($data === null) { + $this->addError("Downloading ical for $roomId failed", false); + continue; + } + foreach ($data as $event) { + $tTables[$roomId][] = array( + 'title' => $this->toTitle($event), + 'start' => $event->dtstart, + 'end' => $event->dtend, + 'cancelled' => false, // ??? How + ); + } + } + return $tTables; + } + + /** + * Get a usable title from either SUMMARY or DESCRIPTION + */ + protected function toTitle(ICalEvent $event): string + { + $title = $event->summary; + if (empty($title)) { + $title = $event->description; + } + if (empty($title)) { + $title = 'Unknown'; + } + return (string)preg_replace([',(\s*<br\s*/?>\s*|\r|\n|\\\r|\\\n)+,', '/\\\\([,;:])/'], ["\n", '$1'], $title); + } + + protected function isOK(): bool + { + if (empty($this->location)) { + $this->addError("Credentials are not set", true); + return false; + } + return true; + } + + public function __destruct() + { + if ($this->curlHandle !== false) { + curl_close($this->curlHandle); + } + } + +}
\ No newline at end of file diff --git a/modules-available/locationinfo/inc/icalevent.inc.php b/modules-available/locationinfo/inc/icalevent.inc.php new file mode 100644 index 00000000..c5aea349 --- /dev/null +++ b/modules-available/locationinfo/inc/icalevent.inc.php @@ -0,0 +1,254 @@ +<?php + +class ICalEvent +{ + // phpcs:disable Generic.Arrays.DisallowLongArraySyntax + + const HTML_TEMPLATE = '<p>%s: %s</p>'; + + /** + * https://www.kanzaki.com/docs/ical/summary.html + * + * @var string + */ + public $summary; + + /** + * https://www.kanzaki.com/docs/ical/dtstart.html + * + * @var string + */ + public $dtstart; + + /** + * https://www.kanzaki.com/docs/ical/dtend.html + * + * @var string + */ + public $dtend; + + /** + * https://www.kanzaki.com/docs/ical/duration.html + * + * @var string + */ + public $duration; + + /** + * https://www.kanzaki.com/docs/ical/dtstamp.html + * + * @var string + */ + public $dtstamp; + + /** + * When the event starts, represented as a timezone-adjusted string + * + * @var string + */ + public $dtstart_tz; + + /** + * When the event ends, represented as a timezone-adjusted string + * + * @var string + */ + public $dtend_tz; + + /** + * https://www.kanzaki.com/docs/ical/uid.html + * + * @var string + */ + public $uid; + + /** + * https://www.kanzaki.com/docs/ical/created.html + * + * @var string + */ + public $created; + + /** + * https://www.kanzaki.com/docs/ical/lastModified.html + * + * @var string + */ + public $last_modified; + + /** + * https://www.kanzaki.com/docs/ical/description.html + * + * @var string + */ + public $description; + + /** + * https://www.kanzaki.com/docs/ical/location.html + * + * @var string + */ + public $location; + + /** + * https://www.kanzaki.com/docs/ical/sequence.html + * + * @var string + */ + public $sequence; + + /** + * https://www.kanzaki.com/docs/ical/status.html + * + * @var string + */ + public $status; + + /** + * https://www.kanzaki.com/docs/ical/transp.html + * + * @var string + */ + public $transp; + + /** + * https://www.kanzaki.com/docs/ical/organizer.html + * + * @var string + */ + public $organizer; + + /** + * https://www.kanzaki.com/docs/ical/attendee.html + * + * @var string + */ + public $attendee; + + /** + * Manage additional properties + * + * @var array<string, mixed> + */ + private $additionalProperties = array(); + + /** + * Creates the Event object + * + * @param array $data + * @return void + */ + public function __construct(array $data = array()) + { + foreach ($data as $key => $value) { + $variable = self::snakeCase($key); + 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(string $additionalPropertyName) + { + if (array_key_exists($additionalPropertyName, $this->additionalProperties)) { + return $this->additionalProperties[$additionalPropertyName]; + } + + return null; + } + + /** + * Magic isset method + */ + public function __isset(string $name): bool + { + return is_null($this->$name) === false; + } + + /** + * 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))); + } + + if (is_array($value)) { + return array_map(function ($value) { + return $this->prepareData($value); + }, $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, + 'DTSTART_TZ' => $this->dtstart_tz, + 'DTEND_TZ' => $this->dtend_tz, + 'DURATION' => $this->duration, + 'DTSTAMP' => $this->dtstamp, + 'UID' => $this->uid, + 'CREATED' => $this->created, + 'LAST-MODIFIED' => $this->last_modified, + '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..eacb67b1 --- /dev/null +++ b/modules-available/locationinfo/inc/icalparser.inc.php @@ -0,0 +1,2052 @@ +<?php + +/* + * Modified for slx-admin to support streaming, some functions removed that are not needed, + * Carbon removed, Honor window size when calculating recurring events, ... + */ + +/** + * 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) + * + * @author Jonathan Goode <https://github.com/u01jmg3> + * @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_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 array $options + * @return void + * @throws Exception + */ + public function __construct(array $options = array()) + { + 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(); + } + + $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(string $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(): bool + { + return $this->hasSeenStart; + } + + /** + * Process next completed line from file + * + * @param string $line + */ + protected function handleLine(string $line) + { + $line = rtrim($line); // Trim trailing whitespace + $line = $this->removeUnprintableChars($line); + + if (empty($line)) { + return; + } + + $add = $this->keyValueFromString($line); + + if ($add === null) { + return; + } + + $keyword = $add[0]; // string + $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 + $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]); + } elseif ($this->doesEventStartOutsideWindow($anEvent)) { + $this->eventCount--; + unset($events[$key]); + } + } + + $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): bool + { + return !isset($event['DTSTART']) || !$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(string $calendarDate, int $minTimestamp, int $maxTimestamp): bool + { + $timestamp = strtotime(explode('T', $calendarDate)[0]); + + return $timestamp < $minTimestamp || $timestamp > $maxTimestamp; + } + + /** + * Add one key and value pair to the `$this->cal` array + * + * @param string $component + * @param string $keyword + * @param string|string[] $value + * @return void + */ + protected function addCalendarComponentWithKeyAndValue(string $component, string $keyword, $value) + { + 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 + $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 + $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); + $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; + } + + // Remove? + $this->lastKeyword = $keyword; + } + + /** + * Gets the key value pair from an iCal string + * + * @param string $text + * @return ?array + */ + protected function keyValueFromString(string $text): ?array + { + $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 null; + } + + 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; + } + return null; // Ignore this match + } + + /** + * Returns a `DateTime` object from an iCal date time format + * + * @param string $icalDate + * @return DateTime + */ + public function iCalDateToDateTime(string $icalDate): DateTime + { + /** + * 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(string $icalDate): int + { + 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, string $key, string $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']; + + 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']) + && isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]); + + if ($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($event, $events[$alteredEventKey]); + $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent); + } + } + } + + 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=<positive integer> + * + * Where: + * enddate = <icalDate> || <icalDateTime> + */ + $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, DateTime $initialDateTime): array + { + $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): array + { + $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(): array + { + 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(): string + { + return $this->cal['VCALENDAR']['X-WR-CALNAME'] ?? ''; + } + + /** + * Returns the calendar description + * + * @return string + */ + public function calendarDescription(): string + { + return $this->cal['VCALENDAR']['X-WR-CALDESC'] ?? ''; + } + + /** + * Returns the calendar time zone + * + * @param boolean $ignoreUtc + * @return string + */ + public function calendarTimeZone(bool $ignoreUtc = false): ?string + { + 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 + { + $array = $this->cal; + + return $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(string $rangeStart = null, string $rangeEnd = null): array + { + // 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, int $sortOrder = SORT_ASC): array + { + $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(string $timeZone): bool + { + 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(string $timeZone): bool + { + 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(string $timeZone): bool + { + 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(string $timeZone): bool + { + return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap); + } + + /** + * Parses a duration and applies it to a date + * + * @return integer|DateTime + */ + protected function parseDuration(string $date, DateInterval $duration, ?string $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(string $data): string + { + 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(string $candidateText): string + { + 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): array + { + if (empty($event['EXDATE_array'])) { + return array(); + } + $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 = $subArray[$key]; + } elseif (is_numeric($key)) { + $icalDate = $subArray[$key]; + + if (substr($icalDate, -1) === 'Z') { + $currentTimeZone = self::TIME_ZONE_UTC; + } + + $output[] = new DateTimeImmutable($icalDate, $this->timeZoneStringToDateTimeZone($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(string $value): bool + { + 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 DateTimeZone|string $timeZoneString + * @return DateTimeZone + */ + public function timeZoneStringToDateTimeZone($timeZoneString): DateTimeZone + { + if ($timeZoneString instanceof DateTimeZone) + return $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/inc/infopanel.inc.php b/modules-available/locationinfo/inc/infopanel.inc.php index 001f31ea..1a0e9b67 100644 --- a/modules-available/locationinfo/inc/infopanel.inc.php +++ b/modules-available/locationinfo/inc/infopanel.inc.php @@ -7,16 +7,16 @@ class InfoPanel * Gets the config of the location. * * @param int $locationID ID of the location - * @param mixed $config the panel config will be returned here - * @return string|bool paneltype, false if not exists + * @param ?array $config the panel config will be returned here + * @return ?string panel type, null if not exists */ - public static function getConfig($paneluuid, &$config) + public static function getConfig(string $paneluuid, ?array &$config): ?string { $panel = Database::queryFirst('SELECT panelname, panelconfig, paneltype, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid', compact('paneluuid')); if ($panel === false) { - return false; + return null; } $config = LocationInfo::defaultPanelConfig($panel['paneltype']); @@ -87,13 +87,10 @@ class InfoPanel * @param array $array location list to populate with machine data * @param bool $withPosition Defines if coords should be included or not. */ - public static function appendMachineData(&$array, $idList = false, $withPosition = false, $withHostname = false) + public static function appendMachineData(array &$array, array $idList, bool $withPosition = false, bool $withHostname = false): void { - if (empty($array) && $idList === false) + if (empty($idList)) return; - if ($idList === false) { - $idList = array_keys($array); - } $ignoreList = array(); if (Module::isAvailable('runmode')) { @@ -108,12 +105,14 @@ class InfoPanel if ($withHostname) { $extraCols .= 'm.hostname,'; } - $query = "SELECT m.locationid, m.machineuuid, $extraCols m.logintime, m.lastseen, m.lastboot, m.state, m.currentrunmode FROM machine m + $query = "SELECT m.locationid, m.fixedlocationid, m.machineuuid, $extraCols m.logintime, + m.lastseen, m.lastboot, m.state, m.currentrunmode + FROM machine m WHERE m.locationid IN (:idlist)"; $dbquery = Database::simpleQuery($query, array('idlist' => $idList)); // Iterate over matching machines - while ($row = $dbquery->fetch(PDO::FETCH_ASSOC)) { + foreach ($dbquery as $row) { if (isset($ignoreList[$row['machineuuid']])) continue; settype($row['locationid'], 'int'); @@ -125,7 +124,8 @@ class InfoPanel } // Compact the pc data in one array. $pc = array('id' => $row['machineuuid']); - if ($withPosition && !empty($row['position'])) { + if ($withPosition && $row['locationid'] == $row['fixedlocationid'] && !empty($row['position'])) { + // check fixed* == locationid to ignore stale position data in relocated clients $position = json_decode($row['position'], true); if (isset($position['gridCol']) && isset($position['gridRow'])) { $pc['x'] = $position['gridCol']; @@ -162,17 +162,17 @@ class InfoPanel * @param array $array list of locations, indexed by locationId * @param int[] $idList list of locations */ - public static function appendOpeningTimes(&$array, $idList) + public static function appendOpeningTimes(array &$array, array $idList): void { // First, lets get all the parent ids for the given locations // in case we need to get inherited opening times $allIds = self::getLocationsWithParents($idList); if (empty($allIds)) return; - $res = Database::simpleQuery("SELECT locationid, openingtime FROM locationinfo_locationconfig + $res = Database::simpleQuery("SELECT locationid, openingtime FROM location WHERE locationid IN (:lids)", array('lids' => $allIds)); $openingTimes = array(); - while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + foreach ($res as $row) { $openingTimes[(int)$row['locationid']] = $row; } // Now we got all the calendars for locations and parents @@ -204,7 +204,6 @@ class InfoPanel $currentId = $locations[$currentId]['parentlocationid']; } } - return; } @@ -215,12 +214,12 @@ class InfoPanel * @param int[] $idList location ids * @return int[] more location ids */ - private static function getLocationsWithParents($idList) + private static function getLocationsWithParents(array $idList): array { $locations = Location::getLocationsAssoc(); $allIds = $idList; foreach ($idList as $id) { - if (isset($locations[$id]) && isset($locations[$id]['parents'])) { + if (isset($locations[$id]['parents'])) { $allIds = array_merge($allIds, $locations[$id]['parents']); } } @@ -236,9 +235,9 @@ class InfoPanel * 'HourClose' => hh, 'MinutesClose' => mm } * * @param array $openingtime The opening time in the db saved format. - * @return mixed The opening time in the frontend needed format. + * @return array The opening time in the frontend needed format. */ - private static function formatOpeningtime($openingtime) + private static function formatOpeningtime(array $openingtime): array { $result = array(); foreach ($openingtime as $entry) { diff --git a/modules-available/locationinfo/inc/locationinfo.inc.php b/modules-available/locationinfo/inc/locationinfo.inc.php index 5a66f15a..42829a18 100644 --- a/modules-available/locationinfo/inc/locationinfo.inc.php +++ b/modules-available/locationinfo/inc/locationinfo.inc.php @@ -7,14 +7,14 @@ class LocationInfo * Gets the pc data and returns it's state. * * @param array $pc The pc data from the db. Array('state' => xx, 'lastseen' => xxx) - * @return int pc state + * @return string pc state */ - public static function getPcState($pc) + public static function getPcState(array $pc): string { $lastseen = (int)$pc['lastseen']; $NOW = time(); - if ($pc['state'] === 'OFFLINE' && $NOW - $lastseen > 21 * 86400) { + if ($pc['state'] === 'OFFLINE' && $NOW - $lastseen > 30 * 86400) { return "BROKEN"; } return $pc['state']; @@ -22,11 +22,12 @@ class LocationInfo /** * Return list of locationids associated with given panel. + * * @param string $paneluuid panel * @param bool $recursive if true and paneltype == SUMMARY the result is recursive with all child room ids. * @return int[] locationIds */ - public static function getLocationsOr404($paneluuid, $recursive = true) + public static function getLocationsOr404(string $paneluuid, bool $recursive = true): array { $panel = Database::queryFirst('SELECT paneltype, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid', compact('paneluuid')); @@ -48,7 +49,7 @@ class LocationInfo * @param int $serverId id of server * @param string|array $message error message to set, array of error message struct, null or false clears error. */ - public static function setServerError($serverId, $message) + public static function setServerError(int $serverId, $message): void { if (is_array($message)) { $fatal = false; @@ -86,7 +87,7 @@ class LocationInfo * * @return array Return a default config. */ - public static function defaultPanelConfig($type) + public static function defaultPanelConfig(string $type): array { if ($type === 'DEFAULT') { return array( @@ -97,6 +98,7 @@ class LocationInfo 'prettytime' => true, 'roomplanner' => true, 'scaledaysauto' => true, + 'startday' => 0, 'daystoshow' => 7, 'rotation' => 0, 'scale' => 50, @@ -118,17 +120,120 @@ class LocationInfo } if ($type === 'URL') { return array( - 'iswhitelist' => 0, - 'urllist' => '', + 'whitelist' => '*', + 'blacklist' => '', 'insecure-ssl' => 0, 'reload-minutes' => 0, 'split-login' => 0, 'browser' => 'slx-browser', 'interactive' => 0, 'bookmarks' => '', + 'allow-tty' => '', + 'url' => '', + 'zoom-factor' => 100, ); } return array(); } + /** + * Gets the calendar of the given ids. + * + * @param int[] $idList list with the location ids. + * @return array Calendar. + */ + public static function getCalendar(array $idList, bool $forceCached = false): array + { + if (empty($idList)) + return []; + + $resultArray = array(); + + if ($forceCached) { + $res = Database::simpleQuery("SELECT locationid, calendar FROM locationinfo_locationconfig + WHERE Length(calendar) > 10 AND lastcalendarupdate > UNIX_TIMESTAMP() - 86400*3"); + foreach ($res as $row) { + $resultArray[] = [ + 'id' => (int)$row['locationid'], + 'calendar' => json_decode($row['calendar'], true), + ]; + } + return $resultArray; + } + + // Build SQL query for multiple ids. + $query = "SELECT l.locationid, l.serverid, l.serverlocationid, s.servertype, s.credentials + FROM `locationinfo_locationconfig` AS l + INNER JOIN locationinfo_coursebackend AS s ON (s.serverid = l.serverid) + WHERE l.locationid IN (:idlist) + ORDER BY s.servertype ASC"; + $dbquery = Database::simpleQuery($query, array('idlist' => array_values($idList))); + + $serverList = array(); + foreach ($dbquery as $dbresult) { + if (!isset($serverList[$dbresult['serverid']])) { + $serverList[$dbresult['serverid']] = array( + 'credentials' => (array)json_decode($dbresult['credentials'], true), + 'type' => $dbresult['servertype'], + 'idlist' => array() + ); + } + $serverList[$dbresult['serverid']]['idlist'][] = $dbresult['locationid']; + } + + foreach ($serverList as $serverid => $server) { + $serverInstance = CourseBackend::getInstance($server['type']); + if ($serverInstance === false) { + EventLog::warning('Cannot fetch schedule for location (' . implode(', ', $server['idlist']) . ')' + . ': Backend type ' . $server['type'] . ' unknown. Disabling location.'); + Database::exec("UPDATE locationinfo_locationconfig SET serverid = NULL WHERE locationid IN (:lid)", + array('lid' => $server['idlist'])); + continue; + } + $credentialsOk = $serverInstance->setCredentials($serverid, $server['credentials']); + + if ($credentialsOk) { + $calendarFromBackend = $serverInstance->fetchSchedule($server['idlist']); + } else { + $calendarFromBackend = array(); + } + + LocationInfo::setServerError($serverid, $serverInstance->getErrors()); + + if (is_array($calendarFromBackend)) { + foreach ($calendarFromBackend as $key => $value) { + $resultArray[] = array( + 'id' => (int)$key, + 'calendar' => $value, + ); + } + } + } + return $resultArray; + } + + public static function getAllCalendars(bool $forceCached): array + { + $locations = Database::queryColumnArray("SELECT locationid FROM location"); + $calendars = []; + foreach (LocationInfo::getCalendar($locations, $forceCached) as $cal) { + if (empty($cal['calendar'])) + continue; + $calendars[$cal['id']] = $cal['calendar']; + } + return $calendars; + } + + public static function extractCurrentEvent(array $calendar): string + { + $NOW = time(); + foreach ($calendar as $event) { + $start = strtotime($event['start']); + $end = strtotime($event['end']) + 60; + if ($NOW >= $start && $NOW <= $end) + return $event['title']; + } + return ''; + } + } diff --git a/modules-available/locationinfo/inc/locationinfohooks.inc.php b/modules-available/locationinfo/inc/locationinfohooks.inc.php index 15c4dc19..8ec217cc 100644 --- a/modules-available/locationinfo/inc/locationinfohooks.inc.php +++ b/modules-available/locationinfo/inc/locationinfohooks.inc.php @@ -5,9 +5,9 @@ class LocationInfoHooks /** * @param string $uuid panel uuid - * @return bool|string panel name if exists, false otherwise + * @return false|string panel name if exists, false otherwise */ - public static function getPanelName($uuid) + public static function getPanelName(string $uuid) { $ret = Database::queryFirst('SELECT panelname FROM locationinfo_panel WHERE paneluuid = :uuid', compact('uuid')); if ($ret === false) @@ -18,14 +18,11 @@ class LocationInfoHooks /** * Hook called by runmode module where we should modify the client config according to our * needs. Disable standby/logout timeouts, enable autologin, set URL. - * - * @param $machineUuid - * @param $panelUuid */ - public static function configHook($machineUuid, $panelUuid) + public static function configHook(string $machineUuid, string $panelUuid): void { $type = InfoPanel::getConfig($panelUuid, $data); - if ($type === false) + if ($type === null) return; // TODO: Invalid panel - what should we do? if ($type === 'URL') { // Check if we should set the insecure SSL mode (accept invalid/self signed certs etc.) @@ -36,14 +33,22 @@ class LocationInfoHooks ConfigHolder::add('SLX_BROWSER_RELOAD_SECS', $data['reload-minutes'] * 60); } ConfigHolder::add('SLX_BROWSER_URL', $data['url']); - ConfigHolder::add('SLX_BROWSER_URLLIST', $data['urllist']); - ConfigHolder::add('SLX_BROWSER_IS_WHITELIST', $data['iswhitelist']); + // Mangle non-upgraded panels + if (empty($data['blacklist']) && $data['whitelist'] === '*' && !empty($data['urllist'])) { + if ($data['iswhitelist']) { + $data['whitelist'] = str_replace(' ', "\n", $data['urllist']); + } else { + $data['blacklist'] = str_replace(' ', "\n", $data['urllist']); + } + } + ConfigHolder::add('SLX_BROWSER_WHITELIST', self::mangleList($data['whitelist'])); + ConfigHolder::add('SLX_BROWSER_BLACKLIST', self::mangleList($data['blacklist'])); // Additionally, update runmode "isclient" flag depending on whether split-login is allowed or not if (isset($data['split-login']) && $data['split-login']) { RunMode::updateClientFlag($machineUuid, 'locationinfo', true); } else { // Automatic login RunMode::updateClientFlag($machineUuid, 'locationinfo', false); - ConfigHolder::add('SLX_AUTOLOGIN', '1', 1000); + ConfigHolder::add('SLX_AUTOLOGIN', 'ON', 1000); ConfigHolder::add('SLX_ADDONS', '', 1000); } if (!empty($data['browser'])) { @@ -61,16 +66,34 @@ class LocationInfoHooks if (!empty($data['bookmarks'])) { ConfigHolder::add('SLX_BROWSER_BOOKMARKS', $data['bookmarks'], 1000); } + if ($data['allow-tty'] === 'yes' || $data['allow-tty'] === 'no') { + ConfigHolder::add('SLX_TTY_SWITCH', $data['allow-tty'], 1000); + } + if (($data['zoom-factor'] ?? 100) != 100) { + ConfigHolder::add('SLX_BROWSER_ZOOM', $data['zoom-factor']); + } } else { // Not URL panel ConfigHolder::add('SLX_BROWSER_URL', 'http://' . $_SERVER['SERVER_ADDR'] . '/panel/' . $panelUuid); - ConfigHolder::add('SLX_BROWSER_INSECURE', '1'); // TODO: Sat server might redirect to HTTPS, which in turn could have a self-signed cert - push to client - ConfigHolder::add('SLX_AUTOLOGIN', '1', 1000); + ConfigHolder::add('SLX_AUTOLOGIN', 'ON', 1000); ConfigHolder::add('SLX_ADDONS', '', 1000); } + $al = ConfigHolder::get('SLX_AUTOLOGIN'); + if (!empty($al) && $al !== 'OFF' && $al != 0) { + ConfigHolder::add('SLX_SHUTDOWN_TIMEOUT', '', 1000); + } ConfigHolder::add('SLX_LOGOUT_TIMEOUT', '', 1000); ConfigHolder::add('SLX_SCREEN_STANDBY_TIMEOUT', '', 1000); ConfigHolder::add('SLX_SYSTEM_STANDBY_TIMEOUT', '', 1000); } + /** + * Turn multiline list into space separated list, removing any + * comments (starting with #) + */ + private static function mangleList(string $list): string + { + return preg_replace('/\s*(#[^\n]*)?(\n|$)/', ' ', $list); + } + }
\ No newline at end of file |