neuwies-kalender-generator-.../ICal/ICal.php

1859 lines
79 KiB
PHP
Raw Permalink Normal View History

2024-03-17 17:19:16 +00:00
<?php
/**
* This PHP class will read an iCal file (*.ics), parse it and return an
* array with its content.
*
* PHP 5 ( 5.3.0)
*
* @author Jonathan Goode <https://github.com/u01jmg3>, John Grogg <john.grogg@gmail.com>, Martin Thoma <info@martin-thoma.de>
* @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("<h1>Server-Fehler (Timeout)</h1><p>Der Churchtool-Server ist zur Zeit leider nicht erreichbar.<br>Bitte versuche es später noch einmal.<br>Dies ist ein Fehler, welcher vom Churchtool-Team behoben werden muss.</p>");
// 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<br>\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;
}
}
}