, John Grogg , Martin Thoma * @license http://www.opensource.org/licenses/mit-license.php MIT License * @version 2.0.5 */ namespace ICal; use ICal\Event; class ICal { const DATE_TIME_FORMAT = 'Ymd\THis'; const DEFAULT_TIMEZONE = 'UTC'; const RECURRENCE_EVENT = 'Generated recurrence event'; const TIME_FORMAT = 'His'; const UNIX_MIN_YEAR = 1970; /** * Track the number of events in the current iCal feed * * @var integer */ public $eventCount = 0; /** * Track the free/busy count in the current iCal feed * * @var integer */ public $freebusyCount = 0; /** * Track 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; /** * The two letter representation of the first day of the week * * @var string */ public $defaultWeekStart = 'MO'; /** * Toggle whether to skip the parsing recurrence rules * * @var boolean */ public $skipRecurrence = false; /** * Toggle whether to use time zone info when parsing recurrence rules * * @var boolean */ public $useTimeZoneWithRRules = false; /** * The parsed calendar * * @var array */ protected $cal; /** * Variable to track the previous keyword * * @var string */ protected $lastKeyword; /** * Event recurrence instances that have been altered * * @var array */ protected $alteredRecurrenceInstances = array(); /** * An associative array containing ordinal data * * @var array */ protected $dayOrdinals = array( 1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth', 5 => 'fifth', ); /** * An associative array containing weekday conversion data * * @var array */ protected $weekdays = array( 'SU' => 'sunday', 'MO' => 'monday', 'TU' => 'tuesday', 'WE' => 'wednesday', 'TH' => 'thursday', 'FR' => 'friday', 'SA' => 'saturday', ); /** * An associative array containing month names * * @var array */ protected $monthNames = array( 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December', ); /** * An associative array containing frequency conversion terms * * @var array */ protected $frequencyConversion = array( 'DAILY' => 'day', 'WEEKLY' => 'week', 'MONTHLY' => 'month', 'YEARLY' => 'year', ); /** * Creates the ICal object * * @param mixed $filename The path to the iCal file or an array of lines from an iCal file * @param array $settings Default settings to apply * @return mixed */ public function __construct($filename = false, array $settings = array()) { if (!$filename) { return false; } // If PHP is not properly recognising the line endings when reading files either // on or created by a Macintosh computer, enabling the `auto_detect_line_endings` // run-time configuration option may help resolve the problem. ini_set('auto_detect_line_endings', '1'); if (is_array($filename)) { $lines = $filename; } else { $lines = file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); // $options = array( 'http' => array( // 'user_agent' => 'Firefox wannabe', // 'max_redirects' => 3, // 'timeout' => 5, // ) ); // $context = stream_context_create( $options ); // $content = @file_get_contents( $filename, false, $context ); // if($content === FALSE) { // An error occred, most likely a timeout // echo("

Server-Fehler (Timeout)

Der Churchtool-Server ist zur Zeit leider nicht erreichbar.
Bitte versuche es später noch einmal.
Dies ist ein Fehler, welcher vom Churchtool-Team behoben werden muss.

"); // exit(); // } // $lines = explode(PHP_EOL, $content); // $lines = preg_split("/\\r\\n|\\r|\\n/", $content); } foreach ($settings as $setting => $value) { if (in_array($setting, array('defaultSpan', 'defaultWeekStart', 'skipRecurrence', 'useTimeZoneWithRRules'))) { $this->{$setting} = $value; } } $this->initLines($lines); } /** * Unfold an iCal file in preparation for parsing * https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html * * @param array $lines The contents of the iCal string to unfold * @return string */ protected function unfold(array $lines) { for($i = 0; $i < count($lines); $i++){ // Merge multi line values to a singe line if($lines[$i][0] == ' '){ $lines[$i-1] = $lines[$i-1] . trim($lines[$i]); $lines[$i] = ""; } } $string = implode(PHP_EOL, $lines); $string = str_replace(array("\r\n ", "\r\n\t"), '', $string); $lines = explode(PHP_EOL, $string); return $lines; } /** * Initialises lines from a string * * @param string $string The contents of the iCal file to initialise * @return ICal */ public function initString($string) { $lines = explode(PHP_EOL, $string); return $this->initLines($lines); } /** * Initialises lines from a URL * * @param string $url The url of the iCal file to download and initialise * @return ICal */ public function initUrl($url) { $contents = file_get_contents($url); $lines = explode(PHP_EOL, $contents); return $this->initLines($lines); } /** * Initialises lines from a file * * @param array $lines The lines to initialise * @return void */ protected function initLines(array $lines) { $lines = $this->unfold($lines); if (stristr($lines[0], 'BEGIN:VCALENDAR') !== false) { $component = ''; foreach ($lines as $line) { $line = rtrim($line); // Trim trailing whitespace $line = $this->removeUnprintableChars($line); $line = $this->cleanData($line); $add = $this->keyValueFromString($line); $keyword = $add[0]; $values = $add[1]; // May be an array containing multiple values if (!is_array($values)) { if (!empty($values)) { $values = array($values); // Make an array as not already $blankArray = array(); // Empty placeholder array array_push($values, $blankArray); } else { $values = array(); // Use blank array to ignore this line } } else if (empty($values[0])) { $values = array(); // Use blank array to ignore this line } $values = array_reverse($values); // Reverse so that our array of properties is processed first foreach ($values as $value) { switch ($line) { // http://www.kanzaki.com/docs/ical/vtodo.html case 'BEGIN:VTODO': $this->todoCount++; $component = 'VTODO'; break; // http://www.kanzaki.com/docs/ical/vevent.html case 'BEGIN:VEVENT': if (!is_array($value)) { $this->eventCount++; } $component = 'VEVENT'; break; // http://www.kanzaki.com/docs/ical/vfreebusy.html case 'BEGIN:VFREEBUSY': $this->freebusyCount++; $component = 'VFREEBUSY'; break; case 'BEGIN:DAYLIGHT': case 'BEGIN:STANDARD': case 'BEGIN:VALARM': case 'BEGIN:VCALENDAR': case 'BEGIN:VTIMEZONE': $component = $value; break; case 'END:DAYLIGHT': case 'END:STANDARD': case 'END:VALARM': case 'END:VCALENDAR': case 'END:VEVENT': case 'END:VFREEBUSY': case 'END:VTIMEZONE': case 'END:VTODO': $component = 'VCALENDAR'; break; default: $this->addCalendarComponentWithKeyAndValue($component, $keyword, $value); break; } } } $this->processEvents(); if (!$this->skipRecurrence) { $this->processRecurrences(); } $this->processDateConversions(); } } /** * Add to $this->ical array one value and key. * * @param string $component This could be VTODO, VEVENT, VCALENDAR, ... * @param string|boolean $keyword The keyword, for example DTSTART * @param string $value The value, for example 20110105T090000Z * @return void */ protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value) { if ($keyword == false) { $keyword = $this->lastKeyword; } switch ($component) { case 'VTODO': $this->cal[$component][$this->todoCount - 1][$keyword] = $value; break; case 'VEVENT': if (!isset($this->cal[$component][$this->eventCount - 1][$keyword . '_array'])) { $this->cal[$component][$this->eventCount - 1][$keyword . '_array'] = array(); } if (is_array($value)) { // Add array of properties to the end array_push($this->cal[$component][$this->eventCount - 1][$keyword . '_array'], $value); } else { if (!isset($this->cal[$component][$this->eventCount - 1][$keyword])) { $this->cal[$component][$this->eventCount - 1][$keyword] = $value; } if ($keyword === 'EXDATE') { if (trim($value) === $value) { $array = array_filter(explode(',', $value)); $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][] = $array; } else { $value = explode(',', implode(',', $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][1]) . trim($value)); $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][1] = $value; } } else { $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][] = $value; if ($keyword === 'DURATION') { $duration = new \DateInterval($value); array_push($this->cal[$component][$this->eventCount - 1][$keyword . '_array'], $duration); } } if ($this->cal[$component][$this->eventCount - 1][$keyword] !== $value) { $this->cal[$component][$this->eventCount - 1][$keyword] .= ',' . $value; } } break; case 'VFREEBUSY': $this->cal[$component][$this->freebusyCount - 1][$keyword] = $value; break; default: $this->cal[$component][$keyword] = $value; break; } $this->lastKeyword = $keyword; } /** * Get the key-value pair from an iCal string. * * @param string $text * @return array */ protected function keyValueFromString($text) { $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); $colon = strpos($text, ':'); $quote = strpos($text, '"'); if ($colon === false) { $matches = array(); } else if ($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 ($matches as $key => $match) { if ($key === 0) { if (!empty($before)) { $matches[$key] = $before . '"' . $matches[$key] . '"'; } } else { if ($key > 1) { $combinedValue .= ':'; } $combinedValue .= $matches[$key]; } } $matches = array_slice($matches, 0, 2); $matches[1] = $combinedValue; array_unshift($matches, $before . $text); } if (count($matches) === 0) { return false; } if (preg_match('/^([A-Z-]+)([;][\w\W]*)?$/', $matches[1])) { $matches = array_splice($matches, 1, 2); // Remove first match and re-align ordering // Process properties if (preg_match('/([A-Z-]+)[;]([\w\W]*)/', $matches[0], $properties)) { // Remove first match array_shift($properties); // Fix to ignore everything in keyword after a ; (e.g. Language, TZID, etc.) $matches[0] = $properties[0]; array_shift($properties); // Repeat removing first match $formatted = array(); foreach ($properties as $property) { // Match semicolon separator outside of quoted substrings preg_match_all('~[^' . PHP_EOL . '";]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '";]*)*~', $property, $attributes); // Remove multi-dimensional array and use the first key $attributes = (sizeof($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 = (sizeof($values) == 0) ? null : reset($values); if (is_array($value) && isset($value[1])) { // Remove double quotes from beginning and end only $formatted[$value[0]] = trim($value[1], '"'); } } } } // Assign the keyword property information $properties[0] = $formatted; // Add match to beginning of array array_unshift($properties, $matches[1]); $matches[1] = $properties; } return $matches; } else { return false; // Ignore this match } } /** * Return Unix timestamp from iCal date time format * * @param string $icalDate A Date in the format YYYYMMDD[T]HHMMSS[Z] or * YYYYMMDD[T]HHMMSS or * TZID=Timezone:YYYYMMDD[T]HHMMSS * @return integer */ public function iCalDateToUnixTimestamp($icalDate) { /** * iCal times may be in 3 formats, ref http://www.kanzaki.com/docs/ical/dateTime.html * UTC: Has a trailing 'Z' * Floating: No timezone reference specified, no trailing 'Z', use local time * TZID: Set timezone as specified * Use DateTime class objects to get around limitations with mktime and gmmktime. Must have a local timezone set * to process floating times. */ $pattern = '/\AT?Z?I?D?=?(.*):?'; // 1: TimeZone $pattern .= '([0-9]{4})'; // 2: YYYY $pattern .= '([0-9]{2})'; // 3: MM $pattern .= '([0-9]{2})'; // 4: DD $pattern .= 'T?'; // Time delimiter $pattern .= '([0-9]{0,2})'; // 5: HH $pattern .= '([0-9]{0,2})'; // 6: MM $pattern .= '([0-9]{0,2})'; // 7: SS $pattern .= '(Z?)/'; // 8: UTC flag preg_match($pattern, $icalDate, $date); if (empty($date)) { return $icalDate; } if (isset($date[1])) { $eventTimeZone = rtrim($date[1], ':'); } // Unix timestamp can't represent dates before 1970 if ($date[2] <= self::UNIX_MIN_YEAR) { $date = new \DateTime($icalDate, new \DateTimeZone(self::DEFAULT_TIMEZONE)); return date_timestamp_get($date); } $convDate = new \DateTime('now', new \DateTimeZone(self::DEFAULT_TIMEZONE)); $convDate->setDate((int) $date[2], (int) $date[3], (int) $date[4]); $convDate->setTime((int) $date[5], (int) $date[6], (int) $date[7]); // Unix timestamps after 03:14:07 UTC 2038-01-19 might cause an overflow // if 32 bit integers are used. if ($date[8] !== 'Z' && isset($eventTimeZone) && $this->isValidTimeZoneId($eventTimeZone)) { $convDate->setTimezone(new \DateTimeZone($eventTimeZone)); } $timestamp = $convDate->getTimestamp(); $timestamp += $convDate->getOffset(); return $timestamp; } /** * Return a date adapted to the calendar timezone depending on the event TZID * * @param array $event An event * @param string $key An event parameter (DTSTART or DTEND) * @param string $forceTimeZone Whether to force a timezone even if Zulu time is specified * @return string Ymd\THis date */ public function iCalDateWithTimeZone(array $event, $key, $forceTimeZone = false) { $offset = 0; if (!isset($event[$key . '_array']) || !isset($event[$key])) { return false; } $dateArray = $event[$key . '_array']; $date = $event[$key]; if ($key === 'DURATION') { $duration = end($dateArray); $timestamp = $this->parseDuration($event['DTSTART'], $duration); $dateTime = \DateTime::createFromFormat('U', $timestamp); $date = $dateTime->format(self::DATE_TIME_FORMAT); } else { $dateTime = new \DateTime($date, new \DateTimeZone(self::DEFAULT_TIMEZONE)); } $timeZone = $this->calendarTimeZone(); if (isset($dateArray[0]['TZID']) && preg_match('/[a-z]*\/[a-z_]*/i', $dateArray[0]['TZID'])) { $tzid = $dateArray[0]['TZID']; // TimeZone attached to the date is valid // and has been applied so return if ($this->isValidTimeZoneId($tzid)) { return $dateTime->format(self::DATE_TIME_FORMAT); } } if (!$forceTimeZone && substr($date, -1) === 'Z') { $tz = new \DateTimeZone(self::DEFAULT_TIMEZONE); $offset = timezone_offset_get($tz, $dateTime); } else { $tz = new \DateTimeZone($timeZone); $offset = timezone_offset_get($tz, $dateTime); } if ($offset < 0) { $dateTime->sub(new \DateInterval('PT' . ($offset * -1) . 'S')); } else { $dateTime->add(new \DateInterval('PT' . $offset . 'S')); } return $dateTime->format(self::DATE_TIME_FORMAT); } /** * Performs some admin tasks on all events as taken straight from the ics file. * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays * Makes a note of modified recurrence-instances * * @return mixed */ protected function processEvents() { $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array(); if (empty($events)) { return false; } 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'])) { $date = 'TZID=' . $anEvent[$type . '_array'][0]['TZID'] . ':' . $date; } $anEvent[$type . '_array'][2] = $this->iCalDateToUnixTimestamp($date); } } if (isset($anEvent['RECURRENCE-ID'])) { $uid = $anEvent['UID']; if (!isset($this->alteredRecurrenceInstances[$uid])) { $this->alteredRecurrenceInstances[$uid] = array(); } $this->alteredRecurrenceInstances[$uid][] = $anEvent['RECURRENCE-ID_array'][2]; } $events[$key] = $anEvent; } $this->cal['VEVENT'] = $events; } /** * Processes recurrence rules * * @return mixed */ protected function processRecurrences() { $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array(); if (empty($events)) { return false; } foreach ($events as $anEvent) { if (isset($anEvent['RRULE']) && $anEvent['RRULE'] !== '') { if (isset($anEvent['DTSTART_array'][0]['TZID']) && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])) { $initialStartTimeZone = $anEvent['DTSTART_array'][0]['TZID']; } else { unset($initialStartTimeZone); } // Tag as generated by a recurrence rule $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT; $isAllDayEvent = (strlen($anEvent['DTSTART_array'][1]) === 8) ? true : false; $initialStart = new \DateTime($anEvent['DTSTART_array'][1], ($this->useTimeZoneWithRRules && isset($initialStartTimeZone)) ? new \DateTimeZone($initialStartTimeZone) : new \DateTimeZone(self::DEFAULT_TIMEZONE)); $initialStartOffset = $initialStart->getOffset(); $initialStartTimeZoneName = $initialStart->getTimezone()->getName(); if (isset($anEvent['DTEND'])) { if (isset($anEvent['DTEND_array'][0]['TZID']) && $this->isValidTimeZoneId($anEvent['DTEND_array'][0]['TZID'])) { $initialEndTimeZone = $anEvent['DTEND_array'][0]['TZID']; } else { unset($initialEndTimeZone); } $initialEnd = new \DateTime($anEvent['DTEND_array'][1], ($this->useTimeZoneWithRRules && isset($initialEndTimeZone)) ? new \DateTimeZone($initialEndTimeZone) : new \DateTimeZone(self::DEFAULT_TIMEZONE)); $initialEndOffset = $initialEnd->getOffset(); $initialEndTimeZoneName = $initialEnd->getTimezone()->getName(); } else { $initialEndTimeZoneName = $initialStartTimeZoneName; } // Recurring event, parse RRULE and add appropriate duplicate events $rrules = array(); $rruleStrings = explode(';', $anEvent['RRULE']); foreach ($rruleStrings as $s) { list($k, $v) = explode('=', $s); $rrules[$k] = $v; } // Get frequency $frequency = $rrules['FREQ']; // Get Start timestamp $startTimestamp = $initialStart->getTimeStamp(); if (isset($anEvent['DTEND'])) { $endTimestamp = $initialEnd->getTimestamp(); } else if (isset($anEvent['DURATION'])) { $duration = end($anEvent['DURATION_array']); $endTimestamp = $this->parseDuration($anEvent['DTSTART'], $duration); } else { $endTimestamp = $anEvent['DTSTART_array'][2]; } $eventTimestampOffset = $endTimestamp - $startTimestamp; // Get Interval $interval = (isset($rrules['INTERVAL']) && $rrules['INTERVAL'] !== '') ? $rrules['INTERVAL'] : 1; $dayNumber = null; $weekday = null; if (in_array($frequency, array('MONTHLY', 'YEARLY')) && isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '' ) { // Deal with BYDAY $byDay = $rrules['BYDAY']; $dayNumber = intval($byDay); if (empty($dayNumber)) { // Returns 0 when no number defined in BYDAY if (!isset($rrules['BYSETPOS'])) { $dayNumber = 1; // Set first as default } else if (is_numeric($rrules['BYSETPOS'])) { $dayNumber = $rrules['BYSETPOS']; } } $weekday = substr($byDay, -2); } $untilDefault = date_create('now'); $untilDefault->modify($this->defaultSpan . ' year'); $untilDefault->setTime(23, 59, 59); // End of the day // Compute exdates $exdates = $this->parseExdates($anEvent); if (isset($rrules['UNTIL'])) { // Get Until $until = strtotime($rrules['UNTIL']); } else if (isset($rrules['COUNT'])) { $countOrig = (is_numeric($rrules['COUNT']) && $rrules['COUNT'] > 1) ? $rrules['COUNT'] : 0; // Increment count by the number of excluded dates $countOrig += sizeof($exdates); // Remove one to exclude the occurrence that initialises the rule $count = ($countOrig - 1); if ($interval >= 2) { $count += ($count > 0) ? ($count * $interval) : 0; } $countNb = 1; $offset = "+{$count} " . $this->frequencyConversion[$frequency]; $until = strtotime($offset, $startTimestamp); if (in_array($frequency, array('MONTHLY', 'YEARLY')) && isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '' ) { $dtstart = date_create($anEvent['DTSTART']); for ($i = 1; $i <= $count; $i++) { $dtstartClone = clone $dtstart; $dtstartClone->modify('next ' . $this->frequencyConversion[$frequency]); $offset = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $dtstartClone)} {$this->weekdays[$weekday]} of " . $dtstartClone->format('F Y H:i:01'); $dtstart->modify($offset); } /** * Jumping X months forwards doesn't mean * the end date will fall on the same day defined in BYDAY * Use the largest of these to ensure we are going far enough * in the future to capture our final end day */ $until = max($until, $dtstart->format('U')); } unset($offset); } else { $until = $untilDefault->getTimestamp(); } // Decide how often to add events and do so switch ($frequency) { case 'DAILY': // Simply add a new event each interval of days until UNTIL is reached $offset = "+{$interval} day"; $recurringTimestamp = strtotime($offset, $startTimestamp); while ($recurringTimestamp <= $until) { $dayRecurringTimestamp = $recurringTimestamp; // Adjust timezone from initial event $dayRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $recurringTimeZone = \DateTime::createFromFormat('U', $dayRecurringTimestamp); $recurringTimeZone->setTimezone($initialStart->getTimezone()); $dayRecurringOffset = $recurringTimeZone->getOffset(); $dayRecurringTimestamp += $dayRecurringOffset; } // Add event $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $dayRecurringTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $dayRecurringTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $dayRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $dayRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($dayRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break; } } } // Move forwards $recurringTimestamp = strtotime($offset, $recurringTimestamp); } break; case 'WEEKLY': // Create offset $offset = "+{$interval} week"; // Use RRULE['WKST'] setting or a default week start (UK = SU, Europe = MO) $weeks = array( 'SA' => array('SA', 'SU', 'MO', 'TU', 'WE', 'TH', 'FR'), 'SU' => array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'), 'MO' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'), ); $wkst = (isset($rrules['WKST']) && in_array($rrules['WKST'], array('SA', 'SU', 'MO'))) ? $rrules['WKST'] : $this->defaultWeekStart; $aWeek = $weeks[$wkst]; $days = array('SA' => 'Saturday', 'SU' => 'Sunday', 'MO' => 'Monday'); // Build list of days of week to add events $weekdays = $aWeek; if (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') { $byDays = explode(',', $rrules['BYDAY']); } else { // A textual representation of a day, two letters (e.g. SU) $byDays = array(mb_substr(strtoupper($initialStart->format('D')), 0, 2)); } // Get timestamp of first day of start week $weekRecurringTimestamp = (strcasecmp($initialStart->format('l'), $this->weekdays[$wkst]) === 0) ? $startTimestamp : strtotime("last {$days[$wkst]} " . $initialStart->format('H:i:s'), $startTimestamp); // Step through weeks while ($weekRecurringTimestamp <= $until) { $dayRecurringTimestamp = $weekRecurringTimestamp; // Adjust timezone from initial event $dayRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $dayRecurringTimeZone = \DateTime::createFromFormat('U', $dayRecurringTimestamp); $dayRecurringTimeZone->setTimezone($initialStart->getTimezone()); $dayRecurringOffset = $dayRecurringTimeZone->getOffset(); $dayRecurringTimestamp += $dayRecurringOffset; } foreach ($weekdays as $day) { // Check if day should be added if (in_array($day, $byDays) && $dayRecurringTimestamp > $startTimestamp && $dayRecurringTimestamp <= $until ) { // Add event $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $dayRecurringTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $dayRecurringTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $dayRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $dayRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($dayRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break 2; } } } } // Move forwards a day $dayRecurringTimestamp = strtotime('+1 day', $dayRecurringTimestamp); } // Move forwards $interval weeks $weekRecurringTimestamp = strtotime($offset, $weekRecurringTimestamp); } break; case 'MONTHLY': // Create offset $recurringTimestamp = $startTimestamp; $offset = "+{$interval} month"; if (isset($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== '') { // Deal with BYMONTHDAY $monthdays = explode(',', $rrules['BYMONTHDAY']); while ($recurringTimestamp <= $until) { foreach ($monthdays as $key => $monthday) { if ($key === 0) { // Ensure original event conforms to monthday rule $anEvent['DTSTART'] = gmdate( 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT, strtotime($anEvent['DTSTART']) ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND'] = gmdate( 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT, isset($anEvent['DURATION']) ? $this->parseDuration($anEvent['DTSTART'], end($anEvent['DURATION_array'])) : strtotime($anEvent['DTEND']) ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $this->iCalDateToUnixTimestamp($anEvent['DTSTART']); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; $anEvent['DTEND_array'][2] = $this->iCalDateToUnixTimestamp($anEvent['DTEND']); // Ensure recurring timestamp confirms to BYMONTHDAY rule $monthRecurringTimestamp = $this->iCalDateToUnixTimestamp( gmdate( 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT, $recurringTimestamp ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '') ); } // Adjust timezone from initial event $monthRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $recurringTimeZone = \DateTime::createFromFormat('U', $monthRecurringTimestamp); $recurringTimeZone->setTimezone($initialStart->getTimezone()); $monthRecurringOffset = $recurringTimeZone->getOffset(); $monthRecurringTimestamp += $monthRecurringOffset; } // Add event $anEvent['DTSTART'] = date( 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT, $monthRecurringTimestamp ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $monthRecurringTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $monthRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $monthRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($monthRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break 2; } } } } // Move forwards $recurringTimestamp = strtotime($offset, $recurringTimestamp); } } else if (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') { while ($recurringTimestamp <= $until) { $monthRecurringTimestamp = $recurringTimestamp; // Adjust timezone from initial event $monthRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $recurringTimeZone = \DateTime::createFromFormat('U', $monthRecurringTimestamp); $recurringTimeZone->setTimezone($initialStart->getTimezone()); $monthRecurringOffset = $recurringTimeZone->getOffset(); $monthRecurringTimestamp += $monthRecurringOffset; } $eventStartDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $monthRecurringTimestamp)} {$this->weekdays[$weekday]} of " . gmdate('F Y H:i:s', $monthRecurringTimestamp); $eventStartTimestamp = strtotime($eventStartDesc); if (intval($rrules['BYDAY']) === 0) { $lastDayDesc = "last {$this->weekdays[$weekday]} of" . gmdate('F Y H:i:s', $monthRecurringTimestamp); } else { $lastDayDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $monthRecurringTimestamp)} {$this->weekdays[$weekday]} of" . gmdate('F Y H:i:s', $monthRecurringTimestamp); } $lastDayTimestamp = strtotime($lastDayDesc); do { // Prevent 5th day of a month from showing up on the next month // If BYDAY and the event falls outside the current month, skip the event $compareCurrentMonth = date('F', $monthRecurringTimestamp); $compareEventMonth = date('F', $eventStartTimestamp); if ($compareCurrentMonth != $compareEventMonth) { $monthRecurringTimestamp = strtotime($offset, $monthRecurringTimestamp); continue; } if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) { $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $eventStartTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $monthRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $monthRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($monthRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break 2; } } } } $eventStartTimestamp += 7 * 86400; } while ($eventStartTimestamp <= $lastDayTimestamp); // Move forwards $recurringTimestamp = strtotime($offset, $recurringTimestamp); } } break; case 'YEARLY': // Create offset $recurringTimestamp = $startTimestamp; $offset = "+{$interval} year"; // Deal with BYMONTH if (isset($rrules['BYMONTH']) && $rrules['BYMONTH'] !== '') { $bymonths = explode(',', $rrules['BYMONTH']); } // Check if BYDAY rule exists if (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') { while ($recurringTimestamp <= $until) { $yearRecurringTimestamp = $recurringTimestamp; // Adjust timezone from initial event $yearRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $recurringTimeZone = \DateTime::createFromFormat('U', $yearRecurringTimestamp); $recurringTimeZone->setTimezone($initialStart->getTimezone()); $yearRecurringOffset = $recurringTimeZone->getOffset(); $yearRecurringTimestamp += $yearRecurringOffset; } foreach ($bymonths as $bymonth) { $eventStartDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $yearRecurringTimestamp)} {$this->weekdays[$weekday]}" . " of {$this->monthNames[$bymonth]} " . gmdate('Y H:i:s', $yearRecurringTimestamp); $eventStartTimestamp = strtotime($eventStartDesc); if (intval($rrules['BYDAY']) === 0) { $lastDayDesc = "last {$this->weekdays[$weekday]}" . " of {$this->monthNames[$bymonth]} " . gmdate('Y H:i:s', $yearRecurringTimestamp); } else { $lastDayDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $yearRecurringTimestamp)} {$this->weekdays[$weekday]}" . " of {$this->monthNames[$bymonth]} " . gmdate('Y H:i:s', $yearRecurringTimestamp); } $lastDayTimestamp = strtotime($lastDayDesc); do { if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) { $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $eventStartTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $yearRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $yearRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($yearRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break 3; } } } } $eventStartTimestamp += 7 * 86400; } while ($eventStartTimestamp <= $lastDayTimestamp); } // Move forwards $recurringTimestamp = strtotime($offset, $recurringTimestamp); } } else { $day = $initialStart->format('d'); // Step through years while ($recurringTimestamp <= $until) { $yearRecurringTimestamp = $recurringTimestamp; // Adjust timezone from initial event $yearRecurringOffset = 0; if ($this->useTimeZoneWithRRules) { $recurringTimeZone = \DateTime::createFromFormat('U', $yearRecurringTimestamp); $recurringTimeZone->setTimezone($initialStart->getTimezone()); $yearRecurringOffset = $recurringTimeZone->getOffset(); $yearRecurringTimestamp += $yearRecurringOffset; } $eventStartDescs = array(); if (isset($rrules['BYMONTH']) && $rrules['BYMONTH'] !== '') { foreach ($bymonths as $bymonth) { array_push($eventStartDescs, "$day {$this->monthNames[$bymonth]} " . gmdate('Y H:i:s', $yearRecurringTimestamp)); } } else { array_push($eventStartDescs, $day . gmdate('F Y H:i:s', $yearRecurringTimestamp)); } foreach ($eventStartDescs as $eventStartDesc) { $eventStartTimestamp = strtotime($eventStartDesc); if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) { $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTSTART_array'][1] = $anEvent['DTSTART']; $anEvent['DTSTART_array'][2] = $eventStartTimestamp; $anEvent['DTEND_array'] = $anEvent['DTSTART_array']; $anEvent['DTEND_array'][2] += $eventTimestampOffset; $anEvent['DTEND'] = date( self::DATE_TIME_FORMAT, $anEvent['DTEND_array'][2] ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : ''); $anEvent['DTEND_array'][1] = $anEvent['DTEND']; // Exclusions $searchDate = $anEvent['DTSTART']; if (isset($anEvent['DTSTART_array'][0]['TZID'])) { $searchDate = 'TZID=' . $anEvent['DTSTART_array'][0]['TZID'] . ':' . $searchDate; } $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $yearRecurringOffset) { $a = $this->iCalDateToUnixTimestamp($searchDate); $b = ($exdate + $yearRecurringOffset); return $a === $b; }); if (isset($anEvent['UID'])) { if (isset($this->alteredRecurrenceInstances[$anEvent['UID']]) && in_array($yearRecurringTimestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) { $isExcluded = true; } } if (!$isExcluded) { $events[] = $anEvent; $this->eventCount++; // If RRULE[COUNT] is reached then break if (isset($rrules['COUNT'])) { $countNb++; if ($countNb >= $countOrig) { break 2; } } } } } // Move forwards $recurringTimestamp = strtotime($offset, $recurringTimestamp); } } break; $events = (isset($countOrig) && sizeof($events) > $countOrig) ? array_slice($events, 0, $countOrig) : $events; // Ensure we abide by COUNT if defined } } } $this->cal['VEVENT'] = $events; } /** * Processes date conversions using the timezone * * Add fields DTSTART_tz and DTEND_tz to each Event * These fields contain dates adapted to the calendar * timezone depending on the event TZID * * @return mixed */ protected function processDateConversions() { $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array(); if (empty($events)) { return false; } foreach ($events as $key => $anEvent) { if (!$this->isValidDate($anEvent['DTSTART'])) { unset($events[$key]); $this->eventCount--; continue; } if ($this->useTimeZoneWithRRules && isset($anEvent['RRULE_array'][2]) && $anEvent['RRULE_array'][2] === self::RECURRENCE_EVENT) { $events[$key]['DTSTART_tz'] = $anEvent['DTSTART']; $events[$key]['DTEND_tz'] = $anEvent['DTEND']; } else { $forceTimeZone = true; $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART', $forceTimeZone); if ($this->iCalDateWithTimeZone($anEvent, 'DTEND', $forceTimeZone)) { $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND', $forceTimeZone); } else if ($this->iCalDateWithTimeZone($anEvent, 'DURATION', $forceTimeZone)) { $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION', $forceTimeZone); } } } $this->cal['VEVENT'] = $events; } /** * Returns an array of Events. Every event is a class * with the event details being properties within it. * * @return array of Events */ public function events() { $array = $this->cal; $array = isset($array['VEVENT']) ? $array['VEVENT'] : array(); $events = array(); if (!empty($array)) { foreach ($array as $event) { $events[] = new Event($event); } } return $events; } /** * Returns the calendar name * * @return string */ public function calendarName() { return isset($this->cal['VCALENDAR']['X-WR-CALNAME']) ? $this->cal['VCALENDAR']['X-WR-CALNAME'] : ''; } /** * Returns the calendar description * * @return calendar description */ public function calendarDescription() { return isset($this->cal['VCALENDAR']['X-WR-CALDESC']) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : ''; } /** * Returns the calendar timezone * * @return calendar timezone */ public function calendarTimeZone() { $defaultTimeZone = date_default_timezone_get(); if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) { $timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE']; } else if (isset($this->cal['VTIMEZONE']['TZID'])) { $timeZone = $this->cal['VTIMEZONE']['TZID']; } else { return $defaultTimeZone; } // Use default timezone if defined is invalid if (!$this->isValidTimeZoneId($timeZone)) { return $defaultTimeZone; } return $timeZone; } /** * Returns an array of arrays with all free/busy events. Every event is * an associative array and each property is an element it. * * @return array */ public function freeBusyEvents() { $array = $this->cal; return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : ''; } /** * Returns a boolean value whether the current calendar has events or not * * @return boolean */ public function hasEvents() { return (count($this->events()) > 0) ? true : false; } /** * Returns a sorted array of the events in a given range, * or false 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, the 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 January the 29th, 2038. * See http://en.wikipedia.org/wiki/Unix_time#Representing_the_number * * @param string $rangeStart Start date of the search range. * @param string $rangeEnd End date of the search range. * @return array of Events */ public function eventsFromRange($rangeStart = false, $rangeEnd = false) { // Sort events before processing range $events = $this->sortEventsWithOrder($this->events(), SORT_ASC); if (empty($events)) { return array(); } $extendedEvents = array(); if ($rangeStart) { try { $rangeStart = new \DateTime($rangeStart, new \DateTimeZone(self::DEFAULT_TIMEZONE)); } catch (\Exception $e) { error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})"); $rangeStart = false; } } else { $rangeStart = new \DateTime('now', new \DateTimeZone(self::DEFAULT_TIMEZONE)); } if ($rangeEnd) { try { $rangeEnd = new \DateTime($rangeEnd, new \DateTimeZone(self::DEFAULT_TIMEZONE)); } catch (\Exception $e) { error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})"); $rangeEnd = false; } } else { $rangeEnd = new \DateTime('now', new \DateTimeZone(self::DEFAULT_TIMEZONE)); $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; } /** * Returns a sorted array of the events following a given string, * or false if no events exist in the range. * * @param string $interval * @return array of Events */ public function eventsFromInterval($interval) { $rangeStart = new \DateTime('now', new \DateTimeZone(self::DEFAULT_TIMEZONE)); $rangeEnd = new \DateTime('now', new \DateTimeZone(self::DEFAULT_TIMEZONE)); $dateInterval = \DateInterval::createFromDateString($interval); $rangeEnd->add($dateInterval); return $this->eventsFromRange($rangeStart->format('Y-m-d'), $rangeEnd->format('Y-m-d')); } /** * Sort events based on a given sort order * * @param array $events An array of Events * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING * @return sorted array of Events */ public function sortEventsWithOrder(array $events, $sortOrder = SORT_ASC) { $extendedEvents = array(); $timestamp = array(); foreach ($events as $key => $anEvent) { $extendedEvents[] = $anEvent; $timestamp[$key] = $anEvent->dtstart_array[2]; } array_multisort($timestamp, $sortOrder, $extendedEvents); return $extendedEvents; } /** * Check if a timezone is valid * * @param string $timeZone A timezone * @return boolean */ protected function isValidTimeZoneId($timeZone) { $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))) { return true; } return false; } /** * Parse a duration and apply it to a date * * @param string $date A date to add a duration to * @param string $duration A duration to parse * @return integer Unix timestamp */ protected function parseDuration($date, $duration) { $timestamp = date_create($date); $timestamp->modify($duration->y . ' year'); $timestamp->modify($duration->m . ' month'); $timestamp->modify($duration->d . ' day'); $timestamp->modify($duration->h . ' hour'); $timestamp->modify($duration->i . ' minute'); $timestamp->modify($duration->s . ' second'); return $timestamp->format('U'); } /** * Get the number of days between a * start and end date * * @param integer $days * @param integer $start * @param integer $end * @return integer */ protected function numberOfDays($days, $start, $end) { $w = array(date('w', $start), date('w', $end)); $oneWeek = 604800; // 7 * 24 * 60 * 60 $x = floor(($end - $start) / $oneWeek); $sum = 0; for ($day = 0; $day < 7; ++$day) { if ($days & pow(2, $day)) { $sum += $x + (($w[0] > $w[1]) ? $w[0] <= $day || $day <= $w[1] : $w[0] <= $day && $day <= $w[1]); } } return $sum; } /** * Convert a negative day ordinal to * its equivalent positive form * * @param integer $dayNumber * @param integer $weekday * @param integer $timestamp * @return string */ protected function convertDayOrdinalToPositive($dayNumber, $weekday, $timestamp) { $dayNumber = empty($dayNumber) ? 1 : $dayNumber; // Returns 0 when no number defined in BYDAY $dayOrdinals = $this->dayOrdinals; // We only care about negative BYDAY values if ($dayNumber >= 1) { return $dayOrdinals[$dayNumber]; } $timestamp = (is_object($timestamp)) ? $timestamp : \DateTime::createFromFormat('U', $timestamp); $start = strtotime('first day of ' . $timestamp->format('F Y H:i:s')); $end = strtotime('last day of ' . $timestamp->format('F Y H:i:s')); // Used with pow(2, X) so pow(2, 4) is THURSDAY $weekdays = array('SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6); $numberOfDays = $this->numberOfDays(pow(2, $weekdays[$weekday]), $start, $end); // Create subset $dayOrdinals = array_slice($dayOrdinals, 0, $numberOfDays, true); //Reverse only the values $dayOrdinals = array_combine(array_keys($dayOrdinals), array_reverse(array_values($dayOrdinals))); return $dayOrdinals[$dayNumber * -1]; } /** * Remove unprintable ASCII and UTF-8 characters * * @param string $data * @return string */ protected function removeUnprintableChars($data) { return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data); } /** * Replace all occurrences of the search string with the replacement string. Multibyte safe. * * @param string|array $search The value being searched for, otherwise known as the needle. An array may be used to designate multiple needles. * @param string|array $replace The replacement value that replaces found search values. An array may be used to designate multiple replacements. * @param string|array $subject The string or array being searched and replaced on, otherwise known as the haystack. * If subject is an array, then the search and replace is performed with every entry of subject, and the return value is an array as well. * @param integer $count If passed, this will be set to the number of replacements performed. * @return array|string */ protected function mb_str_replace($search, $replace, $subject, &$count = 0) { // if (!is_array($subject)) { // // Normalize $search and $replace so they are both arrays of the same length // $searches = is_array($search) ? array_values($search) : array($search); // $replacements = is_array($replace) ? array_values($replace) : array($replace); // $replacements = array_pad($replacements, count($searches), ''); // // foreach ($searches as $key => $search) { // // // // echo("$search, $subject
\n"); // $parts = mb_split(preg_quote($search), $subject); // // // // $count += count($parts) - 1; // $subject = implode($replacements[$key], $parts); // } // } else { // // Call mb_str_replace for each subject in array, recursively // foreach ($subject as $key => $value) { // $subject[$key] = $this->mb_str_replace($search, $replace, $value, $count); // } // } return $subject; } /** * Replace curly quotes and other special characters * with their standard equivalents. * * @param string $data * @return string */ protected function cleanData($data) { $replacementChars = array( "\xe2\x80\x98" => "'", // ‘ "\xe2\x80\x99" => "'", // ’ "\xe2\x80\x9a" => "'", // ‚ "\xe2\x80\x9b" => "'", // ‛ "\xe2\x80\x9c" => '"', // “ "\xe2\x80\x9d" => '"', // ” "\xe2\x80\x9e" => '"', // „ "\xe2\x80\x9f" => '"', // ‟ "\xe2\x80\x93" => '-', // – "\xe2\x80\x94" => '--', // — "\xe2\x80\xa6" => '...', // … "\xc2\xa0" => ' ', ); // Replace UTF-8 characters $cleanedData = strtr($data, $replacementChars); // Replace Windows-1252 equivalents $cleanedData = $this->mb_str_replace(array(chr(145), chr(146), chr(147), chr(148), chr(150), chr(151), chr(133), chr(194)), $replacementChars, $cleanedData); return $cleanedData; } /** * Parse a list of excluded dates * to be applied to an Event * * @param array $event * @return array */ public function parseExdates(array $event) { if (empty($event['EXDATE_array'])) { return array(); } else { $exdates = $event['EXDATE_array']; } $output = array(); $currentTimeZone = self::DEFAULT_TIMEZONE; foreach ($exdates as $subArray) { end($subArray); $finalKey = key($subArray); foreach ($subArray as $key => $value) { if ($key === 'TZID') { $currentTimeZone = $subArray[$key]; } else { $icalDate = 'TZID=' . $currentTimeZone . ':' . $subArray[$key]; $output[] = $this->iCalDateToUnixTimestamp($icalDate); if ($key === $finalKey) { // Reset to default $currentTimeZone = self::DEFAULT_TIMEZONE; } } } } return $output; } /** * Check if a date string is a valid date * * @param string $value * @return boolean */ public function isValidDate($value) { if (!$value) { return false; } try { new \DateTime($value); return true; } catch (\Exception $e) { return false; } } }