From cbf67272bec1faa3b8eb4b0ab2d717d4f32d84e4 Mon Sep 17 00:00:00 2001 From: gruinelli Date: Sun, 17 Mar 2024 17:19:16 +0000 Subject: [PATCH] Dateien nach "ICal" hochladen --- ICal/ICal.php | 1858 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1858 insertions(+) create mode 100644 ICal/ICal.php diff --git a/ICal/ICal.php b/ICal/ICal.php new file mode 100644 index 0000000..0291f1f --- /dev/null +++ b/ICal/ICal.php @@ -0,0 +1,1858 @@ +, 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; + } + } +}