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

1859 lines
79 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}
}