diff --git a/alpha/apps/kaltura/lib/dateUtils.class.php b/alpha/apps/kaltura/lib/dateUtils.class.php index 1640495d0f2..47a0949bc17 100644 --- a/alpha/apps/kaltura/lib/dateUtils.class.php +++ b/alpha/apps/kaltura/lib/dateUtils.class.php @@ -146,5 +146,51 @@ public static function kDate (string $format, $timestamp = null) return date($format, $timestamp); } + + // Format the offset from secs to +hhmm format + public static function formatOffset(int $offset) + { + $hours = floor($offset / 3600); + $minutes = floor(abs($offset) % 3600 / 60); + return sprintf('%+03d%02d', $hours, $minutes); + } + + public static function convertWeekDay(int $timestamp) + { + $date = new DateTime('@' . $timestamp); + $dayOfWeek = $date->format('w'); // Get the day of the week (0 for Sunday, 6 for Saturday) + $dayOfMonth = (int) $date->format('j'); // Get the day of the month + $occurrenceOfSpecificDay = ceil($dayOfMonth / 7); + + // Find the first day of the month + $firstDayOfMonth = new DateTime($date->format('Y-m-01')); + + // Count how many times the same weekday has occurred up to the given day + $occurrenceOfWeekDay = 0; + for ($day = 1; $day <= $dayOfMonth; $day++) + { + $currentDayOfWeek = $firstDayOfMonth->format('w'); + if ($currentDayOfWeek == $dayOfWeek) + { + $occurrenceOfWeekDay++; + } + $firstDayOfMonth->modify('+1 day'); + } + + // Weekday abbreviations + $weekDays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + + // Return the occurrence and the weekday abbreviation, e.g., "1MO" for first Monday + $occurrenceOfSpecificDay = ($occurrenceOfSpecificDay == $occurrenceOfWeekDay) ? -1 : $occurrenceOfSpecificDay; + return $occurrenceOfSpecificDay . $weekDays[$dayOfWeek]; + } + + public static function getDateOnPreviousYear($timestamp) + { + $date = new DateTime(); + $date->setTimestamp($timestamp); + $date->modify('-1 year'); + return $date->getTimestamp(); + } } ?> \ No newline at end of file diff --git a/plugins/schedule/base/lib/KalturaICalSerializer.php b/plugins/schedule/base/lib/KalturaICalSerializer.php index 377d4e3f4b9..416cce70fef 100644 --- a/plugins/schedule/base/lib/KalturaICalSerializer.php +++ b/plugins/schedule/base/lib/KalturaICalSerializer.php @@ -3,10 +3,13 @@ class KalturaICalSerializer extends KalturaSerializer { private $calendar; - + + protected $timeZoneBlockArray; + public function __construct() { $this->calendar = new kSchedulingICalCalendar(); + $this->timeZoneBlockArray = array(); } /** * {@inheritDoc} @@ -26,6 +29,31 @@ public function getHeader() return $this->calendar->begin(); } + protected function injectTimeZoneBlocks($iCalString) + { + $position = strpos($iCalString, 'BEGIN:' . kSchedulingICal::TYPE_EVENT); + if ($position !== false) + { + $iCalBeforeEvents = substr($iCalString, 0, $position); + $iCalWithEvents = substr($iCalString, $position); + // Clean array from duplicated transitions + $this->timeZoneBlockArray = array_unique($this->timeZoneBlockArray); + // Add BEGIN/END timezone tags + array_unshift($this->timeZoneBlockArray, "BEGIN:VTIMEZONE\r\n"); + $this->timeZoneBlockArray[] = "END:VTIMEZONE\r\n"; + //Inject to the iCal + $timeZoneBlocksCollection = implode('', $this->timeZoneBlockArray); + return $iCalBeforeEvents . $timeZoneBlocksCollection . $iCalWithEvents; + } + return $iCalString; + } + + protected function innerSerialize($object) + { + $event = kSchedulingICalEvent::fromObject($object); + return $event->write($object, $this->timeZoneBlockArray); + } + /** * {@inheritDoc} @@ -35,17 +63,18 @@ public function serialize($object) { if($object instanceof KalturaScheduleEvent) { - $event = kSchedulingICalEvent::fromObject($object); - return $event->write(); + $scheduleEventArray = new KalturaScheduleEventArray(); + $scheduleEventArray[] = $object; + return $this->serialize($scheduleEventArray); } elseif($object instanceof KalturaScheduleEventArray) { $ret = ''; foreach($object as $item) { - $ret .= $this->serialize($item); + $ret .= $this->innerSerialize($item); } - return $ret; + return $this->injectTimeZoneBlocks($ret); } elseif($object instanceof KalturaScheduleEventListResponse) { diff --git a/plugins/schedule/base/lib/iCal/kSchedulingICal.php b/plugins/schedule/base/lib/iCal/kSchedulingICal.php index 7c7093d9c96..8de192a0f1a 100644 --- a/plugins/schedule/base/lib/iCal/kSchedulingICal.php +++ b/plugins/schedule/base/lib/iCal/kSchedulingICal.php @@ -3,6 +3,7 @@ class kSchedulingICal { const TIME_FORMAT = 'Ymd\THis\Z'; + const TIME_FORMAT_NO_TIME_ZONE = 'Ymd\THis'; const TIME_PARSE = '%Y%m%dT%H%i%sZ'; const TIME_PARSE_NO_TIME_ZONE = '%Y%m%dT%H%i%s'; @@ -56,11 +57,19 @@ public static function parseComponent($type, array &$lines) return $component; } - public static function formatDate($time) + public static function formatDate($time, $timeZoneId = null) { $original = date_default_timezone_get(); - date_default_timezone_set('UTC'); - $date = date(kSchedulingICal::TIME_FORMAT, $time); + if ($timeZoneId) + { + date_default_timezone_set($timeZoneId); + $date = date(kSchedulingICal::TIME_FORMAT_NO_TIME_ZONE, $time); + } + else + { + date_default_timezone_set('UTC'); + $date = date(kSchedulingICal::TIME_FORMAT, $time); + } date_default_timezone_set($original); return $date; } @@ -207,4 +216,10 @@ public static function loadTzMaps($timezoneMapName) self::$timezoneMap = array_merge( include __DIR__ . '/../../../../../infra/general/timezones/' . $timezoneMapName . '.php'); } + +// Prepare date format for ICS (e.g. 20240925T115352Z) + public static function formatTransitionDate($time) + { + return gmdate(self::TIME_FORMAT_NO_TIME_ZONE, $time); + } } diff --git a/plugins/schedule/base/lib/iCal/kSchedulingICalCalendar.php b/plugins/schedule/base/lib/iCal/kSchedulingICalCalendar.php index 65a897e1bad..b8b2619a6ad 100644 --- a/plugins/schedule/base/lib/iCal/kSchedulingICalCalendar.php +++ b/plugins/schedule/base/lib/iCal/kSchedulingICalCalendar.php @@ -2,6 +2,9 @@ class kSchedulingICalCalendar extends kSchedulingICalComponent { + const VERSION = '2.0'; + const PRODID_PREFIX = '-//Kaltura Inc//Kaltura Server '; + const PRODID_POSTFIX = '//EN'; /** * @param string $data * @param KalturaScheduleEventType $eventsType @@ -20,4 +23,12 @@ protected function getType() { return kSchedulingICal::TYPE_CALENDAR; } + + public function begin() + { + $ret = $this->writeField('BEGIN', $this->getType()); + $ret .= $this->writeField('PRODID', self::PRODID_PREFIX . mySystemUtils::getVersion() . self::PRODID_POSTFIX); + $ret .= $this->writeField('VERSION', self::VERSION); + return $ret; + } } diff --git a/plugins/schedule/base/lib/iCal/kSchedulingICalComponent.php b/plugins/schedule/base/lib/iCal/kSchedulingICalComponent.php index b58f2babe45..beb505365b2 100644 --- a/plugins/schedule/base/lib/iCal/kSchedulingICalComponent.php +++ b/plugins/schedule/base/lib/iCal/kSchedulingICalComponent.php @@ -158,9 +158,9 @@ public function getComponents() return $this->components; } - public function setField($field, $value) + public function setField($field, $value, $subFieldVal = null) { - $this->fields[strtoupper($field)] = $value; + $this->fields[strtoupper($field) . $subFieldVal] = $value; } public function getField($field) @@ -248,14 +248,23 @@ public function end() return $this->writeField('END', $this->getType()); } - public function write() + public function write($object = null, &$timeZoneBlockArray = null) { $ret = ''; + $this->addVtimeZoneBlockIfApplicable($object, $timeZoneBlockArray); $ret .= $this->begin(); $ret .= $this->writeBody(); $ret .= $this->end(); return $ret; } + + protected function addVtimeZoneBlockIfApplicable($object = null, &$timeZoneBlockArray = null): void + { + if ($this->getType() === kSchedulingICal::TYPE_EVENT && $this instanceof kSchedulingICalEvent && $this->getTimeZoneId()) + { + $this->addVtimeZoneBlock($object, $timeZoneBlockArray); + } + } } diff --git a/plugins/schedule/base/lib/iCal/kSchedulingICalEvent.php b/plugins/schedule/base/lib/iCal/kSchedulingICalEvent.php index a33803315fc..58164f4ea2f 100644 --- a/plugins/schedule/base/lib/iCal/kSchedulingICalEvent.php +++ b/plugins/schedule/base/lib/iCal/kSchedulingICalEvent.php @@ -2,6 +2,13 @@ class kSchedulingICalEvent extends kSchedulingICalComponent { + const SEC_IN_WEEK = 604800; + const SEC_IN_MONTH = 2678400; + const SEC_IN_YEAR = 31556926; + const SEC_IN_DAY = 86400; + const SEC_IN_HOUR = 3600; + const SEC_IN_MINUTE = 60; + /** * @var kSchedulingICalRule */ @@ -27,6 +34,9 @@ class kSchedulingICalEvent extends kSchedulingICalComponent 'endDate' => 'dtend', ); + protected static $timeZoneField = 'tzid'; + protected $timeZoneId = ''; + protected static function formatDurationString($durationStringInSeconds) { $duration = 'PT'; @@ -51,6 +61,28 @@ protected function getType() return kSchedulingICal::TYPE_EVENT; } + protected static function getUidFromEventId($id) + { + $hash = sha1($id); + + // Format the hash as a UUID + $uuid = sprintf( + '%08s-%04s-%04x-%04x-%12s', + // 32 bits for "time_low" + substr($hash, 0, 8), + // 16 bits for "time_mid" + substr($hash, 8, 4), + // 16 bits for "time_hi_and_version", with version 4 UUID + (hexdec(substr($hash, 12, 4)) & 0x0fff) | 0x4000, + // 16 bits for "clk_seq_hi_res", with variant UUID + (hexdec(substr($hash, 16, 4)) & 0x3fff) | 0x8000, + // 48 bits for "node" + substr($hash, 20, 12) + ); + + return $uuid; + } + public function getUid() { return $this->getField('uid'); @@ -88,7 +120,9 @@ protected function writeBody() $ret = parent::writeBody(); if ($this->rule) + { $ret .= $this->writeField('RRULE', $this->rule->getBody()); + } return $ret; } @@ -125,7 +159,9 @@ public function toObject() { $event->$string = $this->getField($string); if ( $string == 'duration') - $event->$string = $this->formatDuration($event->$string); + { + $event->$string = $this->formatDuration($event->$string); + } } foreach (self::$dateFields as $date => $field) @@ -137,12 +173,17 @@ public function toObject() if (preg_match('/"([^"]+)"/', $configurationField, $matches)) { if (isset($matches[1])) + { $timezoneFormat = $matches[1]; + } - } elseif (preg_match('/=([^"]+)/', $configurationField, $matches)) + } + elseif (preg_match('/=([^"]+)/', $configurationField, $matches)) { if (isset($matches[1])) + { $timezoneFormat = $matches[1]; + } } } $val = kSchedulingICal::parseDate($this->getField($field), $timezoneFormat); @@ -157,14 +198,17 @@ public function toObject() $classificationType = $this->getField('class'); if (isset($classificationTypes[$classificationType])) + { $event->classificationType = $classificationTypes[$classificationType]; + } $rule = $this->getRule(); if ($rule) { $event->recurrenceType = KalturaScheduleEventRecurrenceType::RECURRING; $event->recurrence = $rule->toObject(); - } else + } + else { $event->recurrenceType = KalturaScheduleEventRecurrenceType::NONE; } @@ -193,7 +237,31 @@ public static function fromObject(KalturaScheduleEvent $event) $resourceIds = array(); if ($event->referenceId) + { $object->setField('uid', $event->referenceId); + } + else + { + $object->setField('uid', self::getUidFromEventId($event->id)); + } + + if ($event->recurrence && $event->recurrence->timeZone) + { + $timeZones = DateTimeZone::listIdentifiers(); + + if (in_array($event->recurrence->timeZone, $timeZones)) + { + $object->timeZoneId = $event->recurrence->timeZone; + } + } + + $resources = ScheduleEventResourcePeer::retrieveByEventIdOrItsParentId($event->id); + foreach ($resources as $resource) + { + /* @var $resource ScheduleEventResource */ + $resourceIds[] = $resource->getResourceId(); + } + $resourceIds = array_diff($resourceIds, array(0)); //resource 0 should not be exported outside of kaltura BE. foreach (self::$stringFields as $string) { @@ -201,10 +269,46 @@ public static function fromObject(KalturaScheduleEvent $event) { if ($string == 'duration') { + if (!is_null($event->endDate)) + { + continue; + } $duration = self::formatDurationString($event->$string); $object->setField($string, $duration); - } else + } + elseif ($string == 'status') + { + if ($event->$string == ScheduleEventStatus::ACTIVE) + { + $object->setField($string, 'CONFIRMED'); + } + else + { + $object->setField($string, 'CANCELLED'); + } + } + else + { $object->setField($string, $event->$string); + } + } + elseif ($string == 'location') + { + if ($resourceIds) + { + $resourcesNames = array(); + $c = new Criteria(); + $c->add(ScheduleResourcePeer::PARTNER_ID, $event->partnerId); + foreach ($resourceIds as $resourceId) + { + /* @var $resource ScheduleResource */ + $c->add(ScheduleResourcePeer::ID, $resourceId); + $resource = ScheduleResourcePeer::doSelectOne($c); + $resourcesNames[] = $resource->getName(); + $c->remove(ScheduleResourcePeer::ID); + } + $object->setField($string, implode(',', $resourcesNames)); + } } } @@ -212,7 +316,15 @@ public static function fromObject(KalturaScheduleEvent $event) { if ($event->$date) { - $object->setField($field, kSchedulingICal::formatDate($event->$date)); + if ($object->timeZoneId !== '') + { + $fieldToUpperCase = $field . ";" . self::$timeZoneField . "="; + $object->setField($fieldToUpperCase, kSchedulingICal::formatDate($event->$date, $object->timeZoneId), $object->timeZoneId); + } + else + { + $object->setField($field, kSchedulingICal::formatDate($event->$date)); + } } } @@ -223,7 +335,9 @@ public static function fromObject(KalturaScheduleEvent $event) ); if ($event->classificationType && isset($classificationTypes[$event->classificationType])) + { $classificationType = $object->setField('class', $classificationTypes[$event->classificationType]); + } if ($event->recurrence) { @@ -238,14 +352,6 @@ public static function fromObject(KalturaScheduleEvent $event) $object->setField('x-kaltura-status', $event->status); $object->setField('x-kaltura-owner-id', $event->ownerId); - - $resources = ScheduleEventResourcePeer::retrieveByEventId($event->id); - foreach ($resources as $resource) - { - /* @var $resource ScheduleEventResource */ - $resourceIds[] = $resource->getResourceId(); - } - if ($event->parentId) { $parent = ScheduleEventPeer::retrieveByPK($event->parentId); @@ -267,20 +373,27 @@ public static function fromObject(KalturaScheduleEvent $event) } } - $resourceIds = array_diff($resourceIds, array(0)); //resource 0 should not be exported outside of kaltura BE. if (count($resourceIds)) + { $object->setField('x-kaltura-resource-ids', implode(',', $resourceIds)); + } if ($event->tags) + { $object->setField('x-kaltura-tags', $event->tags); + } if ($event instanceof KalturaEntryScheduleEvent) { if ($event->templateEntryId) + { $object->setField('x-kaltura-template-entry-id', $event->templateEntryId); + } if ($event->entryIds) + { $object->setField('x-kaltura-entry-ids', $event->entryIds); + } if ($event->categoryIds) { @@ -296,7 +409,9 @@ public static function fromObject(KalturaScheduleEvent $event) $fullIds[] = $category->getFullIds(); } if (count($fullIds)) + { $object->setField('related-to', implode(';', $fullIds)); + } } } @@ -333,4 +448,122 @@ private function formatDuration($duration) } return $duration; } + + + public function getTimeZoneId() + { + return $this->timeZoneId; + } + + public function addVtimeZoneBlock(KalturaScheduleEvent $event = null, &$timeZoneBlockArray = null) + { + try + { + $dateTimeZone = new DateTimeZone($this->timeZoneId); + } + catch (Exception $e) + { + KalturaLog::err('Error while processing the time zone: ' . $e->getMessage()); + throw new KalturaAPIException('Error while processing the time zone: ' . $e->getMessage()); + } + + // Calculating until. Frequency is mandatory and also until or count + $until = (!$event->recurrence->until) ? $this->getUntilFromCount($event->recurrence->count, $event->recurrence->frequency, $event->startDate) : $event->recurrence->until; + + // In order to reduce the size of the transitions to analyze, we start querying from a year before the start of the event until the last occurrence + $transitions = $dateTimeZone->getTransitions(dateUtils::getDateOnPreviousYear($event->startDate), $until); + $relevantTransitions = array(); + $initialTransition = null; + $daylightOffset = null; + $standardOffset = null; + + // This loop filters the list of transitions to only the ones that are relevant to the recurring event, + // from the transition right before the start to the last transition during the event + foreach ($transitions as $transition) + { + // Saving the daylight and standard offsets + if ($transition['isdst']) + { + $daylightOffset = $transition['offset']; + } + else + { + $standardOffset = $transition['offset']; + } + + if ($transition['ts'] <= $event->startDate) + { + $initialTransition = $transition; + } + if ($event->startDate <= $transition['ts'] && $transition['ts'] <= $until) + { + $relevantTransitions[] = $transition; + } + } + array_unshift($relevantTransitions, $initialTransition); + + // Create VTIMEZONE block content + $timeZoneBlockArray[] = $this->writeField(strtoupper(self::$timeZoneField), $this->timeZoneId); + + // Create internal Standard/Daylight blocks + for ($i = 0; $i < count($relevantTransitions); $i++) + { + $timeZoneBlockArray[] = $this->buildTimeBlock($relevantTransitions[$i], $daylightOffset, $standardOffset); + } + } + + protected function getUntilFromCount($count, $frequency, $startDate) + { + switch ($frequency) + { + case DatesGenerator::SECONDLY: + { + return $startDate + $count; + } + case DatesGenerator::DAILY: + { + return $startDate + ($count * self::SEC_IN_DAY); + } + case DatesGenerator::MINUTELY: + { + return $startDate + ($count * self::SEC_IN_MINUTE); + } + case DatesGenerator::WEEKLY: + { + return $startDate + ($count * self::SEC_IN_WEEK); + } + case DatesGenerator::HOURLY: + { + return $startDate + ($count * self::SEC_IN_HOUR); + } + case DatesGenerator::MONTHLY: + { + return $startDate + ($count * self::SEC_IN_MONTH); + } + case DatesGenerator::YEARLY: + { + return $startDate + ($count * self::SEC_IN_YEAR); + } + default: + return $startDate; + } + } + + protected function buildTimeBlock($transition, $daylightOffset, $standardOffset) + { + $transitionTimeBlock = ''; + $timeType = ($transition['isdst']) ? 'DAYLIGHT' : 'STANDARD'; + $offsetFrom = ($timeType === 'STANDARD') ? dateUtils::formatOffset($daylightOffset) : dateUtils::formatOffset($standardOffset); + $offsetTo = ($timeType === 'STANDARD') ? dateUtils::formatOffset($standardOffset) : dateUtils::formatOffset($daylightOffset); + + $transitionTimeBlock .= $this->writeField('BEGIN',$timeType); + $transitionTimeBlock .= $this->writeField('TZOFFSETFROM', $offsetFrom); + $transitionTimeBlock .= $this->writeField('TZOFFSETTO', $offsetTo); + $transitionTimeBlock .= $this->writeField('TZNAME', $transition['abbr']); + $transitionTimeBlock .= $this->writeField('DTSTART', kSchedulingICal::formatTransitionDate($transition['ts'])); + $transitionTimeBlock .= $this->writeField('RRULE', "FREQ=YEARLY;BYMONTH=" . date('n', $transition['ts']) . ";BYDAY=" . dateUtils::convertWeekDay($transition['ts'])); + $transitionTimeBlock .= $this->writeField('END', $timeType); + + return $transitionTimeBlock; + } } diff --git a/plugins/schedule/base/lib/iCal/kSchedulingICalRule.php b/plugins/schedule/base/lib/iCal/kSchedulingICalRule.php index 4d17f796bbc..8bd61345c3d 100644 --- a/plugins/schedule/base/lib/iCal/kSchedulingICalRule.php +++ b/plugins/schedule/base/lib/iCal/kSchedulingICalRule.php @@ -130,7 +130,7 @@ public function getBody() * {@inheritDoc} * @see kSchedulingICalComponent::write() */ - public function write() + public function write($object = null, &$timeZoneBlockArray = null) { return $this->writeBody(); }