diff --git a/includes/ical-parser/class-coblocks-event.php b/includes/ical-parser/class-coblocks-event.php
index 23ed5849aa4..e0dd52be0d3 100644
--- a/includes/ical-parser/class-coblocks-event.php
+++ b/includes/ical-parser/class-coblocks-event.php
@@ -15,178 +15,263 @@
*
* @since 1.20.0
*/
-class CoBlocks_ICal_Event {
-
- const HTML_TEMPLATE = '
%s: %s
';
-
- /**
- * Defines a short summary or subject for the calendar component.
- * https://www.kanzaki.com/docs/ical/summary.html
- *
- * @var $summary
- */
- public $summary;
-
- /**
- * Specifies when the calendar component begins.
- * https://www.kanzaki.com/docs/ical/dtstart.html
- *
- * @var $dtstart
- */
- public $dtstart;
-
- /**
- * Specifies the date and time that a calendar component ends.
- * https://www.kanzaki.com/docs/ical/dtend.html
- *
- * @var $dtend
- */
- public $dtend;
-
- /**
- * Specifies a positive duration of time.
- * https://www.kanzaki.com/docs/ical/duration.html
- *
- * @var $duration
- */
- public $duration;
-
- /**
- * Indicates the date/time that the instance of the iCalendar object was created.
- * https://www.kanzaki.com/docs/ical/dtstamp.html
- *
- * @var $dtstamp
- */
- public $dtstamp;
-
- /**
- * Defines the persistent, globally unique identifier for the calendar component.
- * https://www.kanzaki.com/docs/ical/uid.html
- *
- * @var $uid
- */
- public $uid;
-
- /**
- * Specifies the date and time that the calendar information was created by
- * the calendar user agent in the calendar store.
- * https://www.kanzaki.com/docs/ical/created.html
- *
- * @var $created
- */
- public $created;
-
- /**
- * Specifies the date and time that the information associated with the
- * calendar component was last revised in the calendar store.
- * https://www.kanzaki.com/docs/ical/lastModified.html
- *
- * @var $lastmodified
- */
- public $lastmodified;
-
- /**
- * Provides a more complete description of the calendar component, than that
- * provided by the "SUMMARY" property.
- * https://www.kanzaki.com/docs/ical/description.html
- *
- * @var $description
- */
- public $description;
-
- /**
- * Defines the intended venue for the activity defined by a calendar component.
- * https://www.kanzaki.com/docs/ical/location.html
- *
- * @var $location
- */
- public $location;
-
- /**
- * Defines the revision sequence number of the calendar component within a
- * sequence of revisions.
- * https://www.kanzaki.com/docs/ical/sequence.html
- *
- * @var $sequence
- */
- public $sequence;
-
- /**
- * Defines the overall status or confirmation for the calendar component.
- * https://www.kanzaki.com/docs/ical/status.html
- *
- * @var $status
- */
- public $status;
-
- /**
- * Defines whether an event is transparent or not to busy time searches.
- * https://www.kanzaki.com/docs/ical/transp.html
- *
- * @var $transp
- */
- public $transp;
-
- /**
- * Defines the organizer for a calendar component.
- * https://www.kanzaki.com/docs/ical/organizer.html
- *
- * @var $organizer
- */
- public $organizer;
-
- /**
- * Defines an "Attendee" within a calendar component.
- * https://www.kanzaki.com/docs/ical/attendee.html
- *
- * @var $attendee
- */
- public $attendee;
-
- /**
- * Creates the Event object
- *
- * @param array $data Calendar data.
- * @return void
- */
- public function __construct( array $data = array() ) {
- if ( ! empty( $data ) ) {
- foreach ( $data as $key => $value ) {
- $variable = self::snake_case( $key );
- $this->{$variable} = self::prepare_data( $value );
- }
- }
- }
-
- /**
- * Prepares the data for output
- *
- * @param mixed $value Value to prepare.
- *
- * @return mixed
- */
- protected function prepare_data( $value ) {
- if ( is_string( $value ) ) {
- return stripslashes( trim( str_replace( '\n', "\n", $value ) ) );
- } elseif ( is_array( $value ) ) {
- return array_map( 'self::prepare_data', $value );
- }
- return $value;
- }
-
- /**
- * Converts the given input to snake_case
- *
- * @param string $input Input value.
- * @param string $glue Character to join values with.
- * @param string $separator Character to separate values with.
- *
- * @return string
- */
- protected static function snake_case( $input, $glue = '_', $separator = '-' ) {
- $input = preg_split( '/(?<=[a-z])(?=[A-Z])/x', $input );
- $input = implode( $glue, $input );
- $input = str_replace( $separator, $glue, $input );
- return strtolower( $input );
- }
-}
+class CoBlocks_ICal_Event
+{
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
+ const HTML_TEMPLATE = '%s: %s
';
+
+ /**
+ * https://www.kanzaki.com/docs/ical/summary.html
+ *
+ * @var string
+ */
+ public $summary;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtstart.html
+ *
+ * @var string
+ */
+ public $dtstart;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtend.html
+ *
+ * @var string
+ */
+ public $dtend;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/duration.html
+ *
+ * @var string|null
+ */
+ public $duration;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtstamp.html
+ *
+ * @var string
+ */
+ public $dtstamp;
+
+ /**
+ * When the event starts, represented as a timezone-adjusted string
+ *
+ * @var string
+ */
+ public $dtstart_tz;
+
+ /**
+ * When the event ends, represented as a timezone-adjusted string
+ *
+ * @var string
+ */
+ public $dtend_tz;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/uid.html
+ *
+ * @var string
+ */
+ public $uid;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/created.html
+ *
+ * @var string
+ */
+ public $created;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/lastModified.html
+ *
+ * @var string
+ */
+ public $last_modified;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/description.html
+ *
+ * @var string|null
+ */
+ public $description;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/location.html
+ *
+ * @var string|null
+ */
+ public $location;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/sequence.html
+ *
+ * @var string
+ */
+ public $sequence;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/status.html
+ *
+ * @var string
+ */
+ public $status;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/transp.html
+ *
+ * @var string
+ */
+ public $transp;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/organizer.html
+ *
+ * @var string
+ */
+ public $organizer;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/attendee.html
+ *
+ * @var string
+ */
+ public $attendee;
+
+ /**
+ * Manage additional properties
+ *
+ * @var array
+ */
+ public $additionalProperties = array();
+
+ /**
+ * Creates the Event object
+ *
+ * @param array $data
+ * @return void
+ */
+ public function __construct(array $data = array())
+ {
+ foreach ($data as $key => $value) {
+ $variable = self::snakeCase($key);
+ if (property_exists($this, $variable)) {
+ $this->{$variable} = $this->prepareData($value);
+ } else {
+ $this->additionalProperties[$variable] = $this->prepareData($value);
+ }
+ }
+ }
+
+ /**
+ * Magic getter method
+ *
+ * @param string $additionalPropertyName
+ * @return mixed
+ */
+ public function __get($additionalPropertyName)
+ {
+ if (array_key_exists($additionalPropertyName, $this->additionalProperties)) {
+ return $this->additionalProperties[$additionalPropertyName];
+ }
+
+ return null;
+ }
+
+ /**
+ * Magic isset method
+ *
+ * @param string $name
+ * @return boolean
+ */
+ public function __isset($name)
+ {
+ return is_null($this->$name) === false;
+ }
+
+ /**
+ * Prepares the data for output
+ *
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function prepareData($value)
+ {
+ if (is_string($value)) {
+ return stripslashes(trim(str_replace('\n', "\n", $value)));
+ }
+
+ if (is_array($value)) {
+ return array_map(function ($value) {
+ return $this->prepareData($value);
+ }, $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns Event data excluding anything blank
+ * within an HTML template
+ *
+ * @param string $html HTML template to use
+ * @return string
+ */
+ public function printData($html = self::HTML_TEMPLATE)
+ {
+ $data = array(
+ 'SUMMARY' => $this->summary,
+ 'DTSTART' => $this->dtstart,
+ 'DTEND' => $this->dtend,
+ 'DTSTART_TZ' => $this->dtstart_tz,
+ 'DTEND_TZ' => $this->dtend_tz,
+ 'DURATION' => $this->duration,
+ 'DTSTAMP' => $this->dtstamp,
+ 'UID' => $this->uid,
+ 'CREATED' => $this->created,
+ 'LAST-MODIFIED' => $this->last_modified,
+ 'DESCRIPTION' => $this->description,
+ 'LOCATION' => $this->location,
+ 'SEQUENCE' => $this->sequence,
+ 'STATUS' => $this->status,
+ 'TRANSP' => $this->transp,
+ 'ORGANISER' => $this->organizer,
+ 'ATTENDEE(S)' => $this->attendee,
+ );
+
+ // Remove any blank values
+ $data = array_filter($data);
+
+ $output = '';
+
+ foreach ($data as $key => $value) {
+ $output .= sprintf($html, $key, $value);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Converts the given input to snake_case
+ *
+ * @param string $input
+ * @param string $glue
+ * @param string $separator
+ * @return string
+ */
+ protected static function snakeCase($input, $glue = '_', $separator = '-')
+ {
+ $inputSplit = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input);
+
+ if ($inputSplit === false) {
+ return $input;
+ }
+
+ $inputSplit = implode($glue, $inputSplit);
+ $inputSplit = str_replace($separator, $glue, $inputSplit);
+
+ return strtolower($inputSplit);
+ }
+}
\ No newline at end of file
diff --git a/includes/ical-parser/class-coblocks-ical.php b/includes/ical-parser/class-coblocks-ical.php
index 4344732dc88..125efc37a65 100644
--- a/includes/ical-parser/class-coblocks-ical.php
+++ b/includes/ical-parser/class-coblocks-ical.php
@@ -1,13 +1,14 @@
* @license https://opensource.org/licenses/mit-license.php MIT License
- * @version 2.1.12
+ * @version 3.2.0
* @package CoBlocks
*/
@@ -16,2787 +17,2683 @@
exit;
}
-/**
- * CoBlocks iCal class.
- */
-class CoBlocks_ICal {
-
- const DATE_TIME_FORMAT = 'Ymd\THis';
- const DATE_TIME_FORMAT_PRETTY = 'F Y H:i:s';
- const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
- const RECURRENCE_EVENT = 'Generated recurrence event';
- const SECONDS_IN_A_WEEK = 604800;
- const TIME_FORMAT = 'His';
- const TIME_ZONE_UTC = 'UTC';
- const UNIX_FORMAT = 'U';
- const UNIX_MIN_YEAR = 1970;
-
- /**
- * Tracks the number of alarms in the current iCal feed
- *
- * @var integer
- */
- public $alarm_count = 0;
-
- /**
- * Tracks the number of events in the current iCal feed
- *
- * @var integer
- */
- public $event_count = 0;
-
- /**
- * Tracks the free/busy count in the current iCal feed
- *
- * @var integer
- */
- public $free_busy_count = 0;
-
- /**
- * Tracks the number of todos in the current iCal feed
- *
- * @var integer
- */
- public $todo_count = 0;
-
- /**
- * The value in years to use for indefinite, recurring events
- *
- * @var integer
- */
- public $default_span = 2;
-
- /**
- * Enables customisation of the default time zone
- *
- * @var string
- */
- public $default_time_zone;
-
- /**
- * The two letter representation of the first day of the week
- *
- * @var string
- */
- public $default_week_start = 'MO';
-
- /**
- * Toggles whether to skip the parsing of recurrence rules
- *
- * @var boolean
- */
- public $skip_recurrence = false;
-
- /**
- * Toggles whether to use time zone info when parsing recurrence rules
- *
- * @var boolean
- */
- public $use_timezone_with_r_rules = false;
-
- /**
- * Toggles whether to disable all character replacement.
- *
- * @var boolean
- */
- public $disable_character_replacement = false;
-
- /**
- * With this being non-null the parser will ignore all events more than roughly this many days after now.
- *
- * @var integer
- */
- public $filter_days_before = null;
-
- /**
- * With this being non-null the parser will ignore all events more than roughly this many days before now.
- *
- * @var integer
- */
- public $filter_days_after = null;
-
- /**
- * The parsed calendar
- *
- * @var array
- */
- public $cal = array();
-
- /**
- * Tracks the VFREEBUSY component
- *
- * @var integer
- */
- protected $free_busy_index = 0;
-
- /**
- * Variable to track the previous keyword
- *
- * @var string
- */
- protected $last_keyword;
-
- /**
- * Cache valid IANA time zone IDs to avoid unnecessary lookups
- *
- * @var array
- */
- protected $valid_iana_timezones = array();
-
- /**
- * Event recurrence instances that have been altered
- *
- * @var array
- */
- protected $altered_recurrence_instances = array();
-
- /**
- * An associative array containing ordinal data
- *
- * @var array
- */
- protected $day_ordinals;
-
- /**
- * An associative array containing weekday conversion data
- *
- * @var array
- */
- protected $weekdays;
-
- /**
- * An associative array containing week conversion data
- * (UK = SU, Europe = MO)
- *
- * @var array
- */
- protected $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' ),
- );
-
- /**
- * An associative array containing month names
- *
- * @var array
- */
- protected $month_names;
-
- /**
- * An associative array containing frequency conversion terms
- *
- * @var array
- */
- protected $frequency_conversion;
-
- /**
- * Holds the username and password for HTTP basic authentication
- *
- * @var array
- */
- protected $http_basic_auth = array();
-
- /**
- * Holds the custom User Agent string header
- *
- * @var string
- */
- protected $http_user_agent = null;
-
- /**
- * Define which variables can be configured
- *
- * @var array
- */
- private static $configurable_options = array(
- 'default_span',
- 'default_time_zone',
- 'default_week_start',
- 'disable_character_replacement',
- 'filter_days_after',
- 'filter_days_before',
- 'skip_recurrence',
- 'use_timezone_with_r_rules',
- );
-
- /**
- * CLDR time zones mapped to IANA time zones.
- *
- * @var array
- */
- private static $cldr_timezones_map = array(
- '(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
- '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
- '(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
- '(UTC-09:00) Alaska' => 'America/Anchorage',
- '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
- '(UTC-07:00) Arizona' => 'America/Phoenix',
- '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
- '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
- '(UTC-06:00) Central America' => 'America/Guatemala',
- '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
- '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
- '(UTC-06:00) Saskatchewan' => 'America/Regina',
- '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
- '(UTC-05:00) Chetumal' => 'America/Cancun',
- '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
- '(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
- '(UTC-04:00) Asuncion' => 'America/Asuncion',
- '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
- '(UTC-04:00) Caracas' => 'America/Caracas',
- '(UTC-04:00) Cuiaba' => 'America/Cuiaba',
- '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
- '(UTC-04:00) Santiago' => 'America/Santiago',
- '(UTC-03:30) Newfoundland' => 'America/St_Johns',
- '(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
- '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
- '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
- '(UTC-03:00) Greenland' => 'America/Godthab',
- '(UTC-03:00) Montevideo' => 'America/Montevideo',
- '(UTC-03:00) Salvador' => 'America/Bahia',
- '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
- '(UTC-01:00) Azores' => 'Atlantic/Azores',
- '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
- '(UTC) Coordinated Universal Time' => 'Etc/GMT',
- '(UTC+00:00) Casablanca' => 'Africa/Casablanca',
- '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
- '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
- '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
- '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
- '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
- '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
- '(UTC+01:00) West Central Africa' => 'Africa/Lagos',
- '(UTC+02:00) Amman' => 'Asia/Amman',
- '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
- '(UTC+02:00) Beirut' => 'Asia/Beirut',
- '(UTC+02:00) Cairo' => 'Africa/Cairo',
- '(UTC+02:00) Chisinau' => 'Europe/Chisinau',
- '(UTC+02:00) Damascus' => 'Asia/Damascus',
- '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
- '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
- '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
- '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
- '(UTC+02:00) Tripoli' => 'Africa/Tripoli',
- '(UTC+02:00) Windhoek' => 'Africa/Windhoek',
- '(UTC+03:00) Baghdad' => 'Asia/Baghdad',
- '(UTC+03:00) Istanbul' => 'Europe/Istanbul',
- '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
- '(UTC+03:00) Minsk' => 'Europe/Minsk',
- '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
- '(UTC+03:00) Nairobi' => 'Africa/Nairobi',
- '(UTC+03:30) Tehran' => 'Asia/Tehran',
- '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
- '(UTC+04:00) Baku' => 'Asia/Baku',
- '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
- '(UTC+04:00) Port Louis' => 'Indian/Mauritius',
- '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
- '(UTC+04:00) Yerevan' => 'Asia/Yerevan',
- '(UTC+04:30) Kabul' => 'Asia/Kabul',
- '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
- '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
- '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
- '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
- '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
- '(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
- '(UTC+06:00) Astana' => 'Asia/Almaty',
- '(UTC+06:00) Dhaka' => 'Asia/Dhaka',
- '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
- '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
- '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
- '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
- '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
- '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
- '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
- '(UTC+08:00) Perth' => 'Australia/Perth',
- '(UTC+08:00) Taipei' => 'Asia/Taipei',
- '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
- '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
- '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
- '(UTC+09:00) Seoul' => 'Asia/Seoul',
- '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
- '(UTC+09:30) Adelaide' => 'Australia/Adelaide',
- '(UTC+09:30) Darwin' => 'Australia/Darwin',
- '(UTC+10:00) Brisbane' => 'Australia/Brisbane',
- '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
- '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
- '(UTC+10:00) Hobart' => 'Australia/Hobart',
- '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
- '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
- '(UTC+11:00) Magadan' => 'Asia/Magadan',
- '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
- '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
- '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
- '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
- '(UTC+12:00) Fiji' => 'Pacific/Fiji',
- "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
- '(UTC+13:00) Samoa' => 'Pacific/Apia',
- '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
- );
-
- /**
- * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
- * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
- *
- * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
- *
- * @var array
- */
- private static $windows_timezones_map = array(
- 'AUS Central Standard Time' => 'Australia/Darwin',
- 'AUS Eastern Standard Time' => 'Australia/Sydney',
- 'Afghanistan Standard Time' => 'Asia/Kabul',
- 'Alaskan Standard Time' => 'America/Anchorage',
- 'Aleutian Standard Time' => 'America/Adak',
- 'Altai Standard Time' => 'Asia/Barnaul',
- 'Arab Standard Time' => 'Asia/Riyadh',
- 'Arabian Standard Time' => 'Asia/Dubai',
- 'Arabic Standard Time' => 'Asia/Baghdad',
- 'Argentina Standard Time' => 'America/Buenos_Aires',
- 'Astrakhan Standard Time' => 'Europe/Astrakhan',
- 'Atlantic Standard Time' => 'America/Halifax',
- 'Aus Central W. Standard Time' => 'Australia/Eucla',
- 'Azerbaijan Standard Time' => 'Asia/Baku',
- 'Azores Standard Time' => 'Atlantic/Azores',
- 'Bahia Standard Time' => 'America/Bahia',
- 'Bangladesh Standard Time' => 'Asia/Dhaka',
- 'Belarus Standard Time' => 'Europe/Minsk',
- 'Bougainville Standard Time' => 'Pacific/Bougainville',
- 'Canada Central Standard Time' => 'America/Regina',
- 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
- 'Caucasus Standard Time' => 'Asia/Yerevan',
- 'Cen. Australia Standard Time' => 'Australia/Adelaide',
- 'Central America Standard Time' => 'America/Guatemala',
- 'Central Asia Standard Time' => 'Asia/Almaty',
- 'Central Brazilian Standard Time' => 'America/Cuiaba',
- 'Central Europe Standard Time' => 'Europe/Budapest',
- 'Central European Standard Time' => 'Europe/Warsaw',
- 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
- 'Central Standard Time (Mexico)' => 'America/Mexico_City',
- 'Central Standard Time' => 'America/Chicago',
- 'Chatham Islands Standard Time' => 'Pacific/Chatham',
- 'China Standard Time' => 'Asia/Shanghai',
- 'Cuba Standard Time' => 'America/Havana',
- 'Dateline Standard Time' => 'Etc/GMT+12',
- 'E. Africa Standard Time' => 'Africa/Nairobi',
- 'E. Australia Standard Time' => 'Australia/Brisbane',
- 'E. Europe Standard Time' => 'Europe/Chisinau',
- 'E. South America Standard Time' => 'America/Sao_Paulo',
- 'Easter Island Standard Time' => 'Pacific/Easter',
- 'Eastern Standard Time (Mexico)' => 'America/Cancun',
- 'Eastern Standard Time' => 'America/New_York',
- 'Egypt Standard Time' => 'Africa/Cairo',
- 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
- 'FLE Standard Time' => 'Europe/Kiev',
- 'Fiji Standard Time' => 'Pacific/Fiji',
- 'GMT Standard Time' => 'Europe/London',
- 'GTB Standard Time' => 'Europe/Bucharest',
- 'Georgian Standard Time' => 'Asia/Tbilisi',
- 'Greenland Standard Time' => 'America/Godthab',
- 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
- 'Haiti Standard Time' => 'America/Port-au-Prince',
- 'Hawaiian Standard Time' => 'Pacific/Honolulu',
- 'India Standard Time' => 'Asia/Calcutta',
- 'Iran Standard Time' => 'Asia/Tehran',
- 'Israel Standard Time' => 'Asia/Jerusalem',
- 'Jordan Standard Time' => 'Asia/Amman',
- 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
- 'Korea Standard Time' => 'Asia/Seoul',
- 'Libya Standard Time' => 'Africa/Tripoli',
- 'Line Islands Standard Time' => 'Pacific/Kiritimati',
- 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
- 'Magadan Standard Time' => 'Asia/Magadan',
- 'Magallanes Standard Time' => 'America/Punta_Arenas',
- 'Marquesas Standard Time' => 'Pacific/Marquesas',
- 'Mauritius Standard Time' => 'Indian/Mauritius',
- 'Middle East Standard Time' => 'Asia/Beirut',
- 'Montevideo Standard Time' => 'America/Montevideo',
- 'Morocco Standard Time' => 'Africa/Casablanca',
- 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
- 'Mountain Standard Time' => 'America/Denver',
- 'Myanmar Standard Time' => 'Asia/Rangoon',
- 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
- 'Namibia Standard Time' => 'Africa/Windhoek',
- 'Nepal Standard Time' => 'Asia/Katmandu',
- 'New Zealand Standard Time' => 'Pacific/Auckland',
- 'Newfoundland Standard Time' => 'America/St_Johns',
- 'Norfolk Standard Time' => 'Pacific/Norfolk',
- 'North Asia East Standard Time' => 'Asia/Irkutsk',
- 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
- 'North Korea Standard Time' => 'Asia/Pyongyang',
- 'Omsk Standard Time' => 'Asia/Omsk',
- 'Pacific SA Standard Time' => 'America/Santiago',
- 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
- 'Pacific Standard Time' => 'America/Los_Angeles',
- 'Pakistan Standard Time' => 'Asia/Karachi',
- 'Paraguay Standard Time' => 'America/Asuncion',
- 'Romance Standard Time' => 'Europe/Paris',
- 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
- 'Russia Time Zone 11' => 'Asia/Kamchatka',
- 'Russia Time Zone 3' => 'Europe/Samara',
- 'Russian Standard Time' => 'Europe/Moscow',
- 'SA Eastern Standard Time' => 'America/Cayenne',
- 'SA Pacific Standard Time' => 'America/Bogota',
- 'SA Western Standard Time' => 'America/La_Paz',
- 'SE Asia Standard Time' => 'Asia/Bangkok',
- 'Saint Pierre Standard Time' => 'America/Miquelon',
- 'Sakhalin Standard Time' => 'Asia/Sakhalin',
- 'Samoa Standard Time' => 'Pacific/Apia',
- 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
- 'Saratov Standard Time' => 'Europe/Saratov',
- 'Singapore Standard Time' => 'Asia/Singapore',
- 'South Africa Standard Time' => 'Africa/Johannesburg',
- 'Sri Lanka Standard Time' => 'Asia/Colombo',
- 'Sudan Standard Time' => 'Africa/Tripoli',
- 'Syria Standard Time' => 'Asia/Damascus',
- 'Taipei Standard Time' => 'Asia/Taipei',
- 'Tasmania Standard Time' => 'Australia/Hobart',
- 'Tocantins Standard Time' => 'America/Araguaina',
- 'Tokyo Standard Time' => 'Asia/Tokyo',
- 'Tomsk Standard Time' => 'Asia/Tomsk',
- 'Tonga Standard Time' => 'Pacific/Tongatapu',
- 'Transbaikal Standard Time' => 'Asia/Chita',
- 'Turkey Standard Time' => 'Europe/Istanbul',
- 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
- 'US Eastern Standard Time' => 'America/Indianapolis',
- 'US Mountain Standard Time' => 'America/Phoenix',
- 'UTC' => 'Etc/GMT',
- 'UTC+12' => 'Etc/GMT-12',
- 'UTC+13' => 'Etc/GMT-13',
- 'UTC-02' => 'Etc/GMT+2',
- 'UTC-08' => 'Etc/GMT+8',
- 'UTC-09' => 'Etc/GMT+9',
- 'UTC-11' => 'Etc/GMT+11',
- 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
- 'Venezuela Standard Time' => 'America/Caracas',
- 'Vladivostok Standard Time' => 'Asia/Vladivostok',
- 'W. Australia Standard Time' => 'Australia/Perth',
- 'W. Central Africa Standard Time' => 'Africa/Lagos',
- 'W. Europe Standard Time' => 'Europe/Berlin',
- 'W. Mongolia Standard Time' => 'Asia/Hovd',
- 'West Asia Standard Time' => 'Asia/Tashkent',
- 'West Bank Standard Time' => 'Asia/Hebron',
- 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
- 'Yakutsk Standard Time' => 'Asia/Yakutsk',
- );
-
- /**
- * If `$filter_days_before` or `$filter_days_after` are set then the events are filtered according to the window defined
- * by this field and `$window_max_timestamp`.
- *
- * @var integer
- */
- private $window_min_timestamp = null;
-
- /**
- * If `$filter_days_before` or `$filter_days_after` are set then the events are filtered according to the window defined
- * by this field and `$window_min_timestamp`.
- *
- * @var integer
- */
- private $window_max_timestamp = null;
-
- /**
- * `true` if either `$filter_days_before` or `$filter_days_after` are set.
- *
- * @var boolean
- */
- private $should_filter_by_window = false;
-
- /**
- * Creates the ICal object
- *
- * @param mixed $files Files.
- * @param array $options Options array.
- *
- * @return void
- */
- public function __construct( $files = false, array $options = array() ) {
- ini_set( 'auto_detect_line_endings', '1' );
-
- // Used only for strtotime(), i18n not needed.
- $this->day_ordinals = array(
- 1 => 'first',
- 2 => 'second',
- 3 => 'third',
- 4 => 'fourth',
- 5 => 'fifth',
- );
-
- // Used only for strtotime(), i18n not needed.
- $this->weekdays = array(
- 'SU' => 'Sunday of',
- 'MO' => 'Monday of',
- 'TU' => 'Tuesday of',
- 'WE' => 'Wednesday of',
- 'TH' => 'Thursday of',
- 'FR' => 'Friday of',
- 'SA' => 'Saturday of',
- 'day' => 'day of',
- 'weekday' => 'weekday',
- );
-
- // Used only for strtotime(), i18n not needed.
- $this->month_names = 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',
- );
-
- // Used only for strtotime(), i18n not needed.
- $this->frequency_conversion = array(
- 'DAILY' => 'day',
- 'WEEKLY' => 'week',
- 'MONTHLY' => 'month',
- 'YEARLY' => 'year',
- );
-
- foreach ( $options as $option => $value ) {
- if ( in_array( $option, self::$configurable_options, true ) ) {
- $this->{$option} = $value;
- }
- }
-
- // Fallback to use the system default time zone.
- if ( ! isset( $this->default_time_zone ) || ! $this->is_valid_timezone_id( $this->default_time_zone ) ) {
- $this->default_time_zone = date_default_timezone_get();
- }
-
- $this->window_min_timestamp = is_null( $this->filter_days_before ) ? ~PHP_INT_MAX : ( new \DateTime( 'now' ) )->sub( new \DateInterval( 'P' . $this->filter_days_before . 'D' ) )->getTimestamp();
- $this->window_max_timestamp = is_null( $this->filter_days_after ) ? PHP_INT_MAX : ( new \DateTime( 'now' ) )->add( new \DateInterval( 'P' . $this->filter_days_after . 'D' ) )->getTimestamp();
-
- $this->should_filter_by_window = ! is_null( $this->filter_days_before ) || ! is_null( $this->filter_days_after );
-
- if ( false !== $files ) {
- $files = is_array( $files ) ? $files : array( $files );
-
- foreach ( $files as $file ) {
- if ( ! is_array( $file ) && $this->is_file_or_url( $file ) ) {
- $lines = $this->file_or_url( $file );
- } else {
- $lines = is_array( $file ) ? $file : array( $file );
- }
-
- $this->init_lines( $lines );
- }
- }
- }
-
- /**
- * Initialises lines from a string
- *
- * @param string $string String value.
- *
- * @return ICal
- */
- public function init_string( $string ) {
- if ( empty( $this->cal ) ) {
- $lines = explode( PHP_EOL, $string );
-
- $this->init_lines( $lines );
- }
-
- return $this;
- }
-
- /**
- * Initialises lines from a file
- *
- * @param string $file File string.
- *
- * @return ICal
- */
- public function init_file( $file ) {
- if ( empty( $this->cal ) ) {
- $lines = $this->file_or_url( $file );
-
- $this->init_lines( $lines );
- }
-
- return $this;
- }
-
- /**
- * Initialises lines from a URL
- *
- * @param string $url URL.
- * @param string $username Username.
- * @param string $password Password.
- * @param string $user_agent User agent.
- *
- * @return ICal
- */
- public function init_url( $url, $username = null, $password = null, $user_agent = null ) {
- if ( ! is_null( $username ) && ! is_null( $password ) ) {
- $this->http_basic_auth['username'] = $username;
- $this->http_basic_auth['password'] = $password;
- }
-
- if ( ! is_null( $user_agent ) ) {
- $this->http_user_agent = $user_agent;
- }
-
- $this->init_file( $url );
-
- return $this;
- }
-
- /**
- * Initialises the parser using an array
- * containing each line of iCal content
- *
- * @param array $lines Lines array.
- *
- * @return void
- */
- protected function init_lines( 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->remove_unprintable_chars( $line );
-
- if ( ! $this->disable_character_replacement ) {
- $line = $this->clean_data( $line );
- }
-
- $add = $this->key_value_from_string( $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.
- $blank_array = array(); // Empty placeholder array.
- array_push( $values, $blank_array );
- } else {
- $values = array(); // Use blank array to ignore this line.
- }
- } elseif ( empty( $values[0] ) ) {
- $values = array(); // Use blank array to ignore this line.
- }
-
- // Reverse so that our array of properties is processed first.
- $values = array_reverse( $values );
-
- foreach ( $values as $value ) {
- switch ( $line ) {
- /* https://www.kanzaki.com/docs/ical/vtodo.html */
- case 'BEGIN:VTODO':
- if ( ! is_array( $value ) ) {
- $this->todo_count++;
- }
-
- $component = 'VTODO';
- break;
-
- /* https://www.kanzaki.com/docs/ical/vevent.html */
- case 'BEGIN:VEVENT':
- if ( ! is_array( $value ) ) {
- $this->event_count++;
- }
-
- $component = 'VEVENT';
- break;
-
- /* https://www.kanzaki.com/docs/ical/vfreebusy.html */
- case 'BEGIN:VFREEBUSY':
- if ( ! is_array( $value ) ) {
- $this->free_busy_index++;
- }
-
- $component = 'VFREEBUSY';
- break;
-
- case 'BEGIN:VALARM':
- if ( ! is_array( $value ) ) {
- $this->alarm_count++;
- }
-
- $component = 'VALARM';
- break;
-
- case 'END:VALARM':
- $component = 'VEVENT';
- break;
-
- case 'BEGIN:DAYLIGHT':
- case 'BEGIN:STANDARD':
- case 'BEGIN:VCALENDAR':
- case 'BEGIN:VTIMEZONE':
- $component = $value;
- break;
-
- case 'END:DAYLIGHT':
- case 'END:STANDARD':
- case 'END:VCALENDAR':
- case 'END:VFREEBUSY':
- case 'END:VTIMEZONE':
- case 'END:VTODO':
- $component = 'VCALENDAR';
- break;
-
- case 'END:VEVENT':
- if ( $this->should_filter_by_window ) {
- $this->remove_last_event_if_outside_window_and_non_recurring();
- }
-
- $component = 'VCALENDAR';
- break;
-
- default:
- $this->add_calendar_component_with_key_and_value( $component, $keyword, $value );
- break;
- }
- }
- }
-
- $this->process_events();
-
- if ( ! $this->skip_recurrence ) {
- $this->process_recurrences();
-
- // Apply changes to altered recurrence instances.
- if ( ! empty( $this->altered_recurrence_instances ) ) {
- $events = $this->cal['VEVENT'];
-
- foreach ( $this->altered_recurrence_instances as $altered_recurrence_instance ) {
- if ( isset( $altered_recurrence_instance['altered-event'] ) ) {
- $altered_event = $altered_recurrence_instance['altered-event'];
- $key = key( $altered_event );
- $events[ $key ] = $altered_event[ $key ];
- }
- }
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- if ( $this->should_filter_by_window ) {
- $this->reduce_events_to_min_max_range();
- }
-
- $this->process_date_conversions();
- }
- }
-
- /**
- * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
- * `$window_min_timestamp` / `$window_max_timestamp`.
- *
- * @return void
- */
- protected function remove_last_event_if_outside_window_and_non_recurring() {
- $events = $this->cal['VEVENT'];
-
- if ( ! empty( $events ) ) {
- $last_index = count( $events ) - 1;
- $last_event = $events[ $last_index ];
-
- if ( ( ! isset( $last_event['RRULE'] ) || '' === $last_event['RRULE'] ) && $this->does_event_start_outside_window( $last_event ) ) {
- $this->event_count--;
-
- unset( $events[ $last_index ] );
- }
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- /**
- * Reduces the number of events to the defined minimum and maximum range
- *
- * @return void
- */
- protected function reduce_events_to_min_max_range() {
- $events = ( isset( $this->cal['VEVENT'] ) ) ? $this->cal['VEVENT'] : array();
-
- if ( ! empty( $events ) ) {
- foreach ( $events as $key => $an_event ) {
- if ( null === $an_event ) {
- unset( $events[ $key ] );
- continue;
- }
-
- if ( $this->does_event_start_outside_window( $an_event ) ) {
- $this->event_count--;
-
- unset( $events[ $key ] );
-
- continue;
- }
- }
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- /**
- * Determines whether the event start date is outside `$window_min_timestamp` / `$window_max_timestamp`.
- * Returns `true` for invalid dates.
- *
- * @param array $event Event data.
- *
- * @return boolean
- */
- protected function does_event_start_outside_window( array $event ) {
- return ! $this->is_valid_date( $event['DTSTART'] ) || $this->is_out_of_range( $event['DTSTART'], $this->window_min_timestamp, $this->window_max_timestamp );
- }
-
- /**
- * Determines whether a valid iCalendar date is within a given range
- *
- * @param string $calendar_date Calendar date.
- * @param integer $min_timestamp Minimum timestamp value.
- * @param integer $max_timestamp Maximum timestamp value.
- *
- * @return boolean
- */
- protected function is_out_of_range( $calendar_date, $min_timestamp, $max_timestamp ) {
- $timestamp = strtotime( explode( 'T', $calendar_date )[0] );
-
- return $timestamp < $min_timestamp || $timestamp > $max_timestamp;
- }
-
- /**
- * Unfolds an iCal file in preparation for parsing
- * (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html)
- *
- * @param array $lines iCal data.
- *
- * @return array
- */
- protected function unfold( array $lines ) {
- $string = implode( PHP_EOL, $lines );
- $string = preg_replace( '/' . PHP_EOL . '[ \t]/', '', $string );
- $lines = explode( PHP_EOL, $string );
-
- return $lines;
- }
-
- /**
- * Add one key and value pair to the `$this->cal` array
- *
- * @param string $component Component name.
- * @param string|boolean $keyword Keyword value.
- * @param string $value Value.
- *
- * @return void
- */
- protected function add_calendar_component_with_key_and_value( $component, $keyword, $value ) {
- if ( false === $keyword ) {
- $keyword = $this->last_keyword;
- }
-
- switch ( $component ) {
- case 'VALARM':
- $key1 = 'VEVENT';
- $key2 = ( $this->event_count - 1 );
- $key3 = $component;
-
- if ( ! isset( $this->cal[ $key1 ][ $key2 ][ $key3 ][ "{$keyword}_array" ] ) ) {
- $this->cal[ $key1 ][ $key2 ][ $key3 ][ "{$keyword}_array" ] = array();
- }
-
- if ( is_array( $value ) ) {
- // Add array of properties to the end.
- array_push( $this->cal[ $key1 ][ $key2 ][ $key3 ][ "{$keyword}_array" ], $value );
- } else {
- if ( ! isset( $this->cal[ $key1 ][ $key2 ][ $key3 ][ $keyword ] ) ) {
- $this->cal[ $key1 ][ $key2 ][ $key3 ][ $keyword ] = $value;
- }
-
- if ( $this->cal[ $key1 ][ $key2 ][ $key3 ][ $keyword ] !== $value ) {
- $this->cal[ $key1 ][ $key2 ][ $key3 ][ $keyword ] .= ',' . $value;
- }
- }
- break;
-
- case 'VEVENT':
- $key1 = $component;
- $key2 = ( $this->event_count - 1 );
-
- if ( ! isset( $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ] ) ) {
- $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ] = array();
- }
-
- if ( is_array( $value ) ) {
- // Add array of properties to the end.
- array_push( $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ], $value );
- } else {
- if ( ! isset( $this->cal[ $key1 ][ $key2 ][ $keyword ] ) ) {
- $this->cal[ $key1 ][ $key2 ][ $keyword ] = $value;
- }
-
- if ( 'EXDATE' === $keyword ) {
- if ( trim( $value ) === $value ) {
- $array = array_filter( explode( ',', $value ) );
- $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ][] = $array;
- } else {
- $value = explode( ',', implode( ',', $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ][1] ) . trim( $value ) );
- $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ][1] = $value;
- }
- } else {
- $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ][] = $value;
-
- if ( 'DURATION' === $keyword ) {
- $duration = new \DateInterval( $value );
- array_push( $this->cal[ $key1 ][ $key2 ][ "{$keyword}_array" ], $duration );
- }
- }
-
- if ( $this->cal[ $key1 ][ $key2 ][ $keyword ] !== $value ) {
- $this->cal[ $key1 ][ $key2 ][ $keyword ] .= ',' . $value;
- }
- }
- break;
-
- case 'VFREEBUSY':
- $key1 = $component;
- $key2 = ( $this->free_busy_index - 1 );
- $key3 = $keyword;
-
- if ( 'FREEBUSY' === $keyword ) {
- if ( is_array( $value ) ) {
- $this->cal[ $key1 ][ $key2 ][ $key3 ][][] = $value;
- } else {
- $this->free_busy_count++;
-
- end( $this->cal[ $key1 ][ $key2 ][ $key3 ] );
- $key = key( $this->cal[ $key1 ][ $key2 ][ $key3 ] );
-
- $value = explode( '/', $value );
- $this->cal[ $key1 ][ $key2 ][ $key3 ][ $key ][] = $value;
- }
- } else {
- $this->cal[ $key1 ][ $key2 ][ $key3 ][] = $value;
- }
- break;
-
- case 'VTODO':
- $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ] = $value;
- break;
-
- default:
- $this->cal[ $component ][ $keyword ] = $value;
- break;
- }
-
- $this->last_keyword = $keyword;
- }
-
- /**
- * Gets the key value pair from an iCal string
- *
- * @param string $text Text value.
- *
- * @return array|boolean
- */
- protected function key_value_from_string( $text ) {
- $text = htmlspecialchars( $text, ENT_NOQUOTES, 'UTF-8' );
-
- $colon = strpos( $text, ':' );
- $quote = strpos( $text, '"' );
- if ( false === $colon ) {
- $matches = array();
- } elseif ( false === $quote || $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, ':' );
- $combined_value = '';
-
- foreach ( $matches as $key => $match ) {
- if ( 0 === $key ) {
- if ( ! empty( $before ) ) {
- $matches[ $key ] = $before . '"' . $matches[ $key ] . '"';
- }
- } else {
- if ( $key > 1 ) {
- $combined_value .= ':';
- }
-
- $combined_value .= $matches[ $key ];
- }
- }
-
- $matches = array_slice( $matches, 0, 2 );
- $matches[1] = $combined_value;
- 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 = ( count( $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 = ( count( $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.
- }
- }
-
- /**
- * Returns a `DateTime` object from an iCal date time format
- *
- * @param string $ical_date iCal date format.
- *
- * @return \DateTime
- *
- * @throws \Exception Exception thrown on error.
- */
- public function ical_date_to_date_time( $ical_date ) {
- /**
- * The iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html).
- *
- * UTC: Has a trailing 'Z'
- * Floating: No time zone reference specified, no trailing 'Z', use local time
- * TZID: Set time zone as specified
- *
- * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
- * Must have a local time zone set to process floating times.
- */
- $pattern = '/^(?:TZID=)?([^:]*|".*")'; // 1: Time zone
- $pattern .= ':?'; // Time zone delimiter.
- $pattern .= '([0-9]{8})'; // 2: YYYYMMDD
- $pattern .= 'T?'; // Time delimiter.
- $pattern .= '(?(?<=T)([0-9]{6}))'; // 3: HHMMSS (filled if delimiter present)
- $pattern .= '(Z?)/'; // 4: UTC flag
-
- preg_match( $pattern, $ical_date, $date );
-
- if ( empty( $date ) ) {
- throw new \Exception( 'Invalid iCal date format.' );
- }
-
- // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
- // PHP, on the other hand, uses negative numbers for that. Thus we don't
- // need to special case them.
- if ( 'Z' === $date[4] ) {
- $date_timezone = new \DateTimeZone( self::TIME_ZONE_UTC );
- } elseif ( ! empty( $date[1] ) ) {
- $date_timezone = $this->timezone_string_to_date_timezone( $date[1] );
- } else {
- $date_timezone = new \DateTimeZone( $this->default_time_zone );
- }
-
- // The exclamation mark at the start of the format string indicates that if a
- // time portion is not included, the time in the returned DateTime should be
- // set to 00:00:00. Without it, the time would be set to the current system time.
- $date_format = '!Ymd';
- $date_basic = $date[2];
- if ( ! empty( $date[3] ) ) {
- $date_basic .= 'T' . $date[3];
- $date_format .= '\THis';
- }
-
- return \DateTime::createFromFormat( $date_format, $date_basic, $date_timezone );
- }
-
- /**
- * Returns a Unix timestamp from an iCal date time format
- *
- * @param string $ical_date iCal date value.
- *
- * @return integer
- */
- public function ical_date_to_unix_timestamp( $ical_date ) {
- return $this->ical_date_to_date_time( $ical_date )->getTimestamp();
- }
-
- /**
- * Returns a date adapted to the calendar time zone depending on the event `TZID`
- *
- * @param array $event Event array.
- * @param string $key Array key.
- * @param string $format Date format.
- *
- * @return string|boolean
- */
- public function ical_date_with_timezone( array $event, $key, $format = self::DATE_TIME_FORMAT ) {
- if ( ! isset( $event[ $key . '_array' ] ) || ! isset( $event[ $key ] ) ) {
- return false;
- }
-
- $date_array = $event[ $key . '_array' ];
-
- if ( 'DURATION' === $key ) {
- $duration = end( $date_array );
- $date_time = $this->parse_duration( $event['DTSTART'], $duration, null );
- } else {
- $date_time = new \DateTime( $date_array[1], new \DateTimeZone( self::TIME_ZONE_UTC ) );
- $date_time->setTimezone( new \DateTimeZone( $this->calendar_timezone() ) );
- }
-
- // Force time zone.
- if ( isset( $date_array[0]['TZID'] ) ) {
- $date_time->setTimezone( $this->timezone_string_to_date_timezone( $date_array[0]['TZID'] ) );
- }
-
- if ( is_null( $format ) ) {
- $output = $date_time;
- } else {
- if ( self::UNIX_FORMAT === $format ) {
- $output = $date_time->getTimestamp();
- } else {
- $output = $date_time->format( $format );
- }
- }
-
- return $output;
- }
-
- /**
- * Performs admin tasks on all events as read from the iCal file.
- * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
- * Tracks modified recurrence instances
- *
- * @return void
- */
- protected function process_events() {
- $events = ( isset( $this->cal['VEVENT'] ) ) ? $this->cal['VEVENT'] : array();
-
- if ( ! empty( $events ) ) {
- foreach ( $events as $key => $an_event ) {
- foreach ( array( 'DTSTART', 'DTEND', 'RECURRENCE-ID' ) as $type ) {
- if ( isset( $an_event[ $type ] ) ) {
- $date = $an_event[ $type . '_array' ][1];
-
- if ( isset( $an_event[ $type . '_array' ][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event[ $type . '_array' ][0]['TZID'] );
- $date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $date;
- }
-
- $an_event[ $type . '_array' ][2] = $this->ical_date_to_unix_timestamp( $date );
- $an_event[ $type . '_array' ][3] = $date;
- }
- }
-
- if ( isset( $an_event['RECURRENCE-ID'] ) ) {
- $uid = $an_event['UID'];
-
- if ( ! isset( $this->altered_recurrence_instances[ $uid ] ) ) {
- $this->altered_recurrence_instances[ $uid ] = array();
- }
-
- $recurrence_date_utc = $this->ical_date_to_unix_timestamp( $an_event['RECURRENCE-ID_array'][3] );
- $this->altered_recurrence_instances[ $uid ][ $key ] = $recurrence_date_utc;
- }
-
- $events[ $key ] = $an_event;
- }
-
- $event_keys_to_remove = array();
-
- foreach ( $events as $key => $event ) {
- $checks = array();
- $checks[] = ! isset( $event['RECURRENCE-ID'] );
- $checks[] = isset( $event['UID'] );
- $checks[] = isset( $event['UID'] ) && isset( $this->altered_recurrence_instances[ $event['UID'] ] );
-
- if ( (bool) array_product( $checks ) ) {
- $event_dt_start_unix = $this->ical_date_to_unix_timestamp( $event['DTSTART_array'][3] );
- $altered_event_key = array_search( $event_dt_start_unix, $this->altered_recurrence_instances[ $event['UID'] ], true );
-
- if ( false !== $altered_event_key ) {
- $event_keys_to_remove[] = $altered_event_key;
-
- $altered_event = array_replace_recursive( $events[ $key ], $events[ $altered_event_key ] );
- $this->altered_recurrence_instances[ $event['UID'] ]['altered-event'] = array( $key => $altered_event );
- }
- }
-
- unset( $checks );
- }
-
- if ( ! empty( $event_keys_to_remove ) ) {
- foreach ( $event_keys_to_remove as $event_key_to_remove ) {
- $events[ $event_key_to_remove ] = null;
- }
- }
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- /**
- * Processes recurrence rules
- *
- * @return void
- */
- protected function process_recurrences() {
- $events = ( isset( $this->cal['VEVENT'] ) ) ? $this->cal['VEVENT'] : array();
-
- $recurrence_events = array();
- $all_recurrence_events = array();
-
- if ( ! empty( $events ) ) {
- foreach ( $events as $an_event ) {
- if ( isset( $an_event['RRULE'] ) && '' !== $an_event['RRULE'] ) {
- // Tag as generated by a recurrence rule.
- $an_event['RRULE_array'][2] = self::RECURRENCE_EVENT;
-
- $count_nb = 0;
-
- $initial_start = new \DateTime( $an_event['DTSTART_array'][1] );
- $initial_start_timezone_name = $initial_start->getTimezone()->getName();
-
- if ( isset( $an_event['DTEND'] ) ) {
- $initial_end = new \DateTime( $an_event['DTEND_array'][1] );
- $initial_end_timezone_name = $initial_end->getTimezone()->getName();
- } else {
- $initial_end_timezone_name = $initial_start_timezone_name;
- }
-
- // Recurring event, parse RRULE and add appropriate duplicate events.
- $rrules = array();
- $rrule_strings = explode( ';', $an_event['RRULE'] );
-
- foreach ( $rrule_strings as $s ) {
- list($k, $v) = explode( '=', $s );
- $rrules[ $k ] = $v;
- }
-
- // Get frequency.
- $frequency = $rrules['FREQ'];
- // Get Start timestamp.
- $start_timestamp = $initial_start->getTimestamp();
-
- if ( isset( $an_event['DTEND'] ) ) {
- $end_timestamp = $initial_end->getTimestamp();
- } elseif ( isset( $an_event['DURATION'] ) ) {
- $duration = end( $an_event['DURATION_array'] );
- $end_timestamp = $this->parse_duration( $an_event['DTSTART'], $duration );
- } else {
- $end_timestamp = $an_event['DTSTART_array'][2];
- }
-
- $event_timestamp_offset = $end_timestamp - $start_timestamp;
- // Get Interval.
- $interval = ( isset( $rrules['INTERVAL'] ) && '' !== $rrules['INTERVAL'] ) ? $rrules['INTERVAL'] : 1;
-
- $day_number = null;
- $weekday = null;
-
- if ( in_array( $frequency, array( 'MONTHLY', 'YEARLY' ), true ) && isset( $rrules['BYDAY'] ) && '' !== $rrules['BYDAY'] ) {
- // Deal with BYDAY.
- $by_day = $rrules['BYDAY'];
- $day_number = intval( $by_day );
-
- if ( empty( $day_number ) ) { // Returns 0 when no number defined in BYDAY.
- if ( ! isset( $rrules['BYSETPOS'] ) ) {
- $day_number = 1; // Set first as default.
- } elseif ( is_numeric( $rrules['BYSETPOS'] ) ) {
- $day_number = $rrules['BYSETPOS'];
-
- $by_days_counted = array_count_values( explode( ',', $rrules['BYDAY'] ) );
-
- if ( array_count_values( $this->weeks['MO'] ) === $by_days_counted ) {
- $weekday = 'day';
- } elseif ( array_count_values( array_slice( $this->weeks['MO'], 0, 5 ) === $by_days_counted ) ) {
- $weekday = 'weekday';
- }
- }
- }
-
- if ( ! isset( $weekday ) ) {
- $weekday = substr( $by_day, -2 );
- }
- }
-
- if ( is_int( $this->default_span ) ) {
- $until_default = date_create( 'now' );
- $until_default->modify( $this->default_span . ' year' );
- $until_default->setTime( 23, 59, 59 ); // End of the day.
- }
-
- // Compute EXDATEs.
- $exdates = $this->parse_ex_dates( $an_event );
-
- $count_orig = null;
-
- if ( isset( $rrules['UNTIL'] ) ) {
- // Get Until.
- $until = strtotime( $rrules['UNTIL'] );
- if ( $until > strtotime( '+' . $this->default_span . ' years' ) ) {
- $until = strtotime( '+' . $this->default_span . ' years' );
- }
- } elseif ( isset( $rrules['COUNT'] ) ) {
- $count_orig = ( is_numeric( $rrules['COUNT'] ) && $rrules['COUNT'] > 1 ) ? $rrules['COUNT'] : 0;
-
- // Increment count by the number of excluded dates.
- $count_orig += count( $exdates );
-
- // Remove one to exclude the occurrence that initialises the rule.
- $count = ( $count_orig - 1 );
-
- if ( $interval >= 2 ) {
- $count += ( $count > 0 ) ? ( $count * $interval ) : 0;
- }
-
- $count_nb = 1;
- $offset = "+{$count} " . $this->frequency_conversion[ $frequency ];
- $until = strtotime( $offset, $start_timestamp );
-
- if ( in_array( $frequency, array( 'MONTHLY', 'YEARLY' ), true )
- && isset( $rrules['BYDAY'] ) && '' !== $rrules['BYDAY']
- ) {
- $dtstart = date_create( $an_event['DTSTART'] );
-
- if ( ! $dtstart ) {
- continue;
- }
-
- for ( $i = 1; $i <= $count; $i++ ) {
- $dtstart_clone = clone $dtstart;
- $dtstart_clone->modify( 'next ' . $this->frequency_conversion[ $frequency ] );
- $offset = "{$this->convert_day_ordinal_to_positive($day_number, $weekday, $dtstart_clone)} {$this->weekdays[$weekday]} " . $dtstart_clone->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( self::UNIX_FORMAT ) );
- }
-
- unset( $offset );
- } elseif ( isset( $until_default ) ) {
- $until = $until_default->getTimestamp();
- }
-
- $until = intval( $until );
-
- // 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";
- $recurring_timestamp = strtotime( $offset, $start_timestamp );
-
- while ( $recurring_timestamp <= $until ) {
- $dayrecurring_timestamp = $recurring_timestamp;
-
- // Adjust time zone from initial event.
- $dayrecurring_offset = 0;
- if ( $this->use_timezone_with_r_rules ) {
- $recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $dayrecurring_timestamp );
- $recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $dayrecurring_offset = $recurring_timezone->getOffset();
- $dayrecurring_timestamp += $dayrecurring_offset;
- }
-
- // Add event.
- $an_event['DTSTART'] = gmdate( self::DATE_TIME_FORMAT, $dayrecurring_timestamp ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $dayrecurring_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
-
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $dayrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $dayrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break;
- }
- }
- }
-
- // Move forwards.
- $recurring_timestamp = strtotime( $offset, $recurring_timestamp );
- }
-
- $recurrence_events = $this->trim_to_recurrence_count( $rrules, $recurrence_events );
- $all_recurrence_events = array_merge( $all_recurrence_events, $recurrence_events );
- $recurrence_events = array(); // Reset.
- break;
-
- case 'WEEKLY':
- // Create offset.
- $offset = "+{$interval} week";
-
- $wkst = ( isset( $rrules['WKST'] ) && in_array( $rrules['WKST'], array( 'SA', 'SU', 'MO' ), true ) ) ? $rrules['WKST'] : $this->default_week_start;
- $a_week = $this->weeks[ $wkst ];
- $days = array(
- 'SA' => 'Saturday',
- 'SU' => 'Sunday',
- 'MO' => 'Monday',
- );
-
- // Build list of days of week to add events.
- $weekdays = $a_week;
-
- if ( isset( $rrules['BYDAY'] ) && '' !== $rrules['BYDAY'] ) {
- $by_days = explode( ',', $rrules['BYDAY'] );
- } else {
- // A textual representation of a day, two letters (e.g. SU).
- $by_days = array( mb_substr( strtoupper( $initial_start->format( 'D' ) ), 0, 2 ) );
- }
-
- // Get timestamp of first day of start week.
- $weekrecurring_timestamp = ( strcasecmp( $initial_start->format( 'l' ), explode( ' ', $this->weekdays[ $wkst ] )[0] ) === 0 )
- ? $start_timestamp
- : strtotime( "last {$days[$wkst]} " . $initial_start->format( 'H:i:s' ), $start_timestamp );
-
- // Step through weeks.
- while ( $weekrecurring_timestamp <= $until ) {
- $dayrecurring_timestamp = $weekrecurring_timestamp;
-
- // Adjust time zone from initial event.
- $dayrecurring_offset = 0;
- if ( $this->use_timezone_with_r_rules ) {
- $day_recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $dayrecurring_timestamp );
- $day_recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $dayrecurring_offset = $day_recurring_timezone->getOffset();
- $dayrecurring_timestamp += $dayrecurring_offset;
- }
-
- foreach ( $weekdays as $day ) {
- // Check if day should be added.
- if ( in_array( $day, $by_days, true ) && $dayrecurring_timestamp > $start_timestamp
- && $dayrecurring_timestamp <= $until
- ) {
- // Add event.
- $an_event['DTSTART'] = gmdate( self::DATE_TIME_FORMAT, $dayrecurring_timestamp ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $dayrecurring_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
-
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $dayrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $dayrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break 2;
- }
- }
- }
- }
-
- // Move forwards a day.
- $dayrecurring_timestamp = strtotime( '+1 day', $dayrecurring_timestamp );
- }
-
- // Move forwards $interval weeks.
- $weekrecurring_timestamp = strtotime( $offset, $weekrecurring_timestamp );
- }
-
- $recurrence_events = $this->trim_to_recurrence_count( $rrules, $recurrence_events );
- $all_recurrence_events = array_merge( $all_recurrence_events, $recurrence_events );
- $recurrence_events = array(); // Reset.
- break;
-
- case 'MONTHLY':
- // Create offset.
- $recurring_timestamp = $start_timestamp;
- $offset = "+{$interval} month";
-
- if ( isset( $rrules['BYMONTHDAY'] ) && '' !== $rrules['BYMONTHDAY'] ) {
- // Deal with BYMONTHDAY.
- $monthdays = explode( ',', $rrules['BYMONTHDAY'] );
-
- while ( $recurring_timestamp <= $until ) {
- foreach ( $monthdays as $key => $monthday ) {
- $month_recurring_timestamp = null;
-
- if ( 0 === $key ) {
- // Ensure original event conforms to monthday rule.
- $an_event['DTSTART'] = gmdate(
- 'Ym' . sprintf( '%02d', $monthday ) . '\T' . self::TIME_FORMAT,
- strtotime( $an_event['DTSTART'] )
- ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
-
- $an_event['DTEND'] = gmdate(
- 'Ym' . sprintf( '%02d', $monthday ) . '\T' . self::TIME_FORMAT,
- isset( $an_event['DURATION'] )
- ? $this->parse_duration( $an_event['DTSTART'], end( $an_event['DURATION_array'] ) )
- : strtotime( $an_event['DTEND'] )
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
-
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $this->ical_date_to_unix_timestamp( $an_event['DTSTART'] );
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
- $an_event['DTEND_array'][2] = $this->ical_date_to_unix_timestamp( $an_event['DTEND'] );
-
- // Ensure recurring timestamp confirms to BYMONTHDAY rule.
- $month_recurring_date_time = new \DateTime( '@' . $recurring_timestamp );
- $month_recurring_date_time->setDate(
- $month_recurring_date_time->format( 'Y' ),
- $month_recurring_date_time->format( 'm' ),
- $monthday
- );
- $month_recurring_timestamp = $month_recurring_date_time->getTimestamp();
- }
-
- // Adjust time zone from initial event.
- $monthrecurring_offset = 0;
- if ( $this->use_timezone_with_r_rules ) {
- $recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $month_recurring_timestamp );
- $recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $monthrecurring_offset = $recurring_timezone->getOffset();
- $month_recurring_timestamp += $monthrecurring_offset;
- }
-
- if ( ( $month_recurring_timestamp > $start_timestamp ) && ( $month_recurring_timestamp <= $until ) ) {
- // Add event.
- $an_event['DTSTART'] = gmdate(
- 'Ym' . sprintf( '%02d', $monthday ) . '\T' . self::TIME_FORMAT,
- $month_recurring_timestamp
- ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $month_recurring_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $monthrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $monthrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break 2;
- }
- }
- }
- }
- }
-
- // Move forwards.
- $recurring_timestamp = strtotime( $offset, $recurring_timestamp );
- }
- } elseif ( isset( $rrules['BYDAY'] ) && '' !== $rrules['BYDAY'] ) {
- while ( $recurring_timestamp <= $until ) {
- $month_recurring_timestamp = $recurring_timestamp;
-
- // Adjust time zone from initial event.
- $monthrecurring_offset = 0;
-
- if ( $this->use_timezone_with_r_rules ) {
- $recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $month_recurring_timestamp );
- $recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $monthrecurring_offset = $recurring_timezone->getOffset();
- $month_recurring_timestamp += $monthrecurring_offset;
- }
-
- $event_start_desc = "{$this->convert_day_ordinal_to_positive($day_number, $weekday, $month_recurring_timestamp)} {$this->weekdays[$weekday]} "
- . gmdate( self::DATE_TIME_FORMAT_PRETTY, $month_recurring_timestamp );
- $event_start_timestamp = strtotime( $event_start_desc );
-
- if ( intval( $rrules['BYDAY'] ) === 0 ) {
- $last_day_desc = "last {$this->weekdays[$weekday]} "
- . gmdate( self::DATE_TIME_FORMAT_PRETTY, $month_recurring_timestamp );
- } else {
- $last_day_desc = "{$this->convert_day_ordinal_to_positive($day_number, $weekday, $month_recurring_timestamp)} {$this->weekdays[$weekday]} "
- . gmdate( self::DATE_TIME_FORMAT_PRETTY, $month_recurring_timestamp );
- }
-
- $last_day_time_stamp = strtotime( $last_day_desc );
-
- 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.
-
- $compare_current_month = gmdate( 'F', $month_recurring_timestamp );
- $compare_event_month = gmdate( 'F', $event_start_timestamp );
-
- if ( $compare_current_month !== $compare_event_month ) {
- $month_recurring_timestamp = strtotime( $offset, $month_recurring_timestamp );
- continue;
- }
-
- if ( $event_start_timestamp > $start_timestamp && $event_start_timestamp <= $until ) {
- $an_event['DTSTART'] = gmdate( self::DATE_TIME_FORMAT, $event_start_timestamp ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $event_start_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $monthrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $monthrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break 2;
- }
- }
- }
- }
-
- if ( isset( $rrules['BYSETPOS'] ) ) {
- // BYSETPOS is defined so skip.
- // looping through each week.
- $last_day_time_stamp = $event_start_timestamp;
- }
-
- $event_start_timestamp += self::SECONDS_IN_A_WEEK;
- } while ( $event_start_timestamp <= $last_day_time_stamp );
-
- }
- }
-
- $recurrence_events = $this->trim_to_recurrence_count( $rrules, $recurrence_events );
- $all_recurrence_events = array_merge( $all_recurrence_events, $recurrence_events );
- $recurrence_events = array(); // Reset.
- break;
-
- case 'YEARLY':
- // Create offset.
- $recurring_timestamp = $start_timestamp;
- $offset = "+{$interval} year";
-
- // Deal with BYMONTH.
- if ( isset( $rrules['BYMONTH'] ) && '' !== $rrules['BYMONTH'] ) {
- $bymonths = explode( ',', $rrules['BYMONTH'] );
- } else {
- $bymonths = array();
- }
-
- // Check if BYDAY rule exists.
- if ( isset( $rrules['BYDAY'] ) && '' !== $rrules['BYDAY'] ) {
- while ( $recurring_timestamp <= $until ) {
- $yearrecurring_timestamp = $recurring_timestamp;
-
- // Adjust time zone from initial event.
- $yearrecurring_offset = 0;
-
- if ( $this->use_timezone_with_r_rules ) {
- $recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $yearrecurring_timestamp );
- $recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $yearrecurring_offset = $recurring_timezone->getOffset();
- $yearrecurring_timestamp += $yearrecurring_offset;
- }
-
- foreach ( $bymonths as $bymonth ) {
- $event_start_desc = "{$this->convert_day_ordinal_to_positive($day_number, $weekday, $yearrecurring_timestamp)} {$this->weekdays[$weekday]}"
- . " {$this->month_names[$bymonth]} "
- . gmdate( 'Y H:i:s', $yearrecurring_timestamp );
- $event_start_timestamp = strtotime( $event_start_desc );
-
- if ( intval( $rrules['BYDAY'] ) === 0 ) {
- $last_day_desc = "last {$this->weekdays[$weekday]}"
- . " {$this->month_names[$bymonth]} "
- . gmdate( 'Y H:i:s', $yearrecurring_timestamp );
- } else {
- $last_day_desc = "{$this->convert_day_ordinal_to_positive($day_number, $weekday, $yearrecurring_timestamp)} {$this->weekdays[$weekday]}"
- . " {$this->month_names[$bymonth]} "
- . gmdate( 'Y H:i:s', $yearrecurring_timestamp );
- }
-
- $last_day_time_stamp = strtotime( $last_day_desc );
-
- do {
- if ( $event_start_timestamp > $start_timestamp && $event_start_timestamp <= $until ) {
- $an_event['DTSTART'] = gmdate( self::DATE_TIME_FORMAT, $event_start_timestamp ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $event_start_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
-
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $yearrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $yearrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break 3;
- }
- }
- }
- }
-
- $event_start_timestamp += self::SECONDS_IN_A_WEEK;
- } while ( $event_start_timestamp <= $last_day_time_stamp );
- }
-
- // Move forwards.
- $recurring_timestamp = strtotime( $offset, $recurring_timestamp );
- }
- } else {
- $day = $initial_start->format( 'd' );
-
- // Step through years.
- while ( $recurring_timestamp <= $until ) {
- $yearrecurring_timestamp = $recurring_timestamp;
-
- // Adjust time zone from initial event.
- $yearrecurring_offset = 0;
- if ( $this->use_timezone_with_r_rules ) {
- $recurring_timezone = \DateTime::createFromFormat( self::UNIX_FORMAT, $yearrecurring_timestamp );
- $recurring_timezone->setTimezone( $initial_start->getTimezone() );
- $yearrecurring_offset = $recurring_timezone->getOffset();
- $yearrecurring_timestamp += $yearrecurring_offset;
- }
-
- $event_start_descs = array();
- if ( isset( $rrules['BYMONTH'] ) && '' !== $rrules['BYMONTH'] ) {
- foreach ( $bymonths as $bymonth ) {
- array_push( $event_start_descs, "{$day} {$this->month_names[$bymonth]} " . gmdate( 'Y H:i:s', $yearrecurring_timestamp ) );
- }
- } else {
- array_push( $event_start_descs, $day . gmdate( self::DATE_TIME_FORMAT_PRETTY, $yearrecurring_timestamp ) );
- }
-
- foreach ( $event_start_descs as $event_start_desc ) {
- $event_start_timestamp = strtotime( $event_start_desc );
-
- if ( $event_start_timestamp > $start_timestamp && $until >= $event_start_timestamp ) {
- $an_event['DTSTART'] = gmdate( self::DATE_TIME_FORMAT, $event_start_timestamp ) . ( ( 'Z' === $initial_start_timezone_name ) ? 'Z' : '' );
- $an_event['DTSTART_array'][1] = $an_event['DTSTART'];
- $an_event['DTSTART_array'][2] = $event_start_timestamp;
- $an_event['DTEND_array'] = $an_event['DTSTART_array'];
- $an_event['DTEND_array'][2] += $event_timestamp_offset;
- $an_event['DTEND'] = gmdate(
- self::DATE_TIME_FORMAT,
- $an_event['DTEND_array'][2]
- ) . ( ( 'Z' === $initial_end_timezone_name ) ? 'Z' : '' );
- $an_event['DTEND_array'][1] = $an_event['DTEND'];
-
- // Exclusions.
- $is_excluded = array_filter(
- $exdates,
- function ( $exdate ) use ( $an_event, $yearrecurring_offset ) {
- return self::is_ex_date_match( $exdate, $an_event, $yearrecurring_offset );
- }
- );
-
- if ( isset( $an_event['UID'] ) ) {
- $search_date = $an_event['DTSTART'];
- if ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->escape_param_text( $an_event['DTSTART_array'][0]['TZID'] );
- $search_date = sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) . $search_date;
- }
-
- if ( isset( $this->altered_recurrence_instances[ $an_event['UID'] ] ) ) {
- $search_date_utc = $this->ical_date_to_unix_timestamp( $search_date );
- if ( in_array( $search_date_utc, $this->altered_recurrence_instances[ $an_event['UID'] ], true ) ) {
- $is_excluded = true;
- }
- }
- }
-
- if ( ! $is_excluded ) {
- $an_event = $this->process_event_ical_datetime( $an_event );
- $recurrence_events[] = $an_event;
- $this->event_count++;
-
- // If RRULE[COUNT] is reached then break.
- if ( isset( $rrules['COUNT'] ) ) {
- $count_nb++;
-
- if ( $count_nb >= $count_orig ) {
- break 2;
- }
- }
- }
- }
- }
-
- // Move forwards.
- $recurring_timestamp = strtotime( $offset, $recurring_timestamp );
- }
- }
-
- $recurrence_events = $this->trim_to_recurrence_count( $rrules, $recurrence_events );
- $all_recurrence_events = array_merge( $all_recurrence_events, $recurrence_events );
- $recurrence_events = array(); // Reset.
- break;
- }
- }
- }
-
- $events = array_merge( $events, $all_recurrence_events );
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- /**
- * Processes date conversions using the time zone
- *
- * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
- * These keys contain dates adapted to the calendar
- * time zone depending on the event `TZID`.
- *
- * @return void
- */
- protected function process_date_conversions() {
- $events = ( isset( $this->cal['VEVENT'] ) ) ? $this->cal['VEVENT'] : array();
-
- if ( ! empty( $events ) ) {
- foreach ( $events as $key => $an_event ) {
- if ( ! $this->is_valid_date( $an_event['DTSTART'] ) ) {
- unset( $events[ $key ] );
- $this->event_count--;
-
- continue;
- }
-
- if ( $this->use_timezone_with_r_rules && isset( $an_event['RRULE_array'][2] ) && self::RECURRENCE_EVENT === $an_event['RRULE_array'][2] ) {
- $events[ $key ]['DTSTART_tz'] = $an_event['DTSTART'];
- $events[ $key ]['DTEND_tz'] = isset( $an_event['DTEND'] ) ? $an_event['DTEND'] : $an_event['DTSTART'];
- } else {
- $events[ $key ]['DTSTART_tz'] = $this->ical_date_with_timezone( $an_event, 'DTSTART' );
-
- if ( $this->ical_date_with_timezone( $an_event, 'DTEND' ) ) {
- $events[ $key ]['DTEND_tz'] = $this->ical_date_with_timezone( $an_event, 'DTEND' );
- } elseif ( $this->ical_date_with_timezone( $an_event, 'DURATION' ) ) {
- $events[ $key ]['DTEND_tz'] = $this->ical_date_with_timezone( $an_event, 'DURATION' );
- } elseif ( $this->ical_date_with_timezone( $an_event, 'DTSTART' ) ) {
- $events[ $key ]['DTEND_tz'] = $this->ical_date_with_timezone( $an_event, 'DTSTART' );
- }
- }
- }
-
- $this->cal['VEVENT'] = $events;
- }
- }
-
- /**
- * Extends the `{DTSTART|DTEND|RECURRENCE-ID}_array`
- * array to include an iCal date time for each event
- * (`TZID=Timezone:YYYYMMDD[T]HHMMSS`)
- *
- * @param array $event Event array.
- * @param integer $index Index value.
- *
- * @return array
- */
- protected function process_event_ical_datetime( array $event, $index = 3 ) {
- $calendar_timezone = $this->calendar_timezone( true );
-
- foreach ( array( 'DTSTART', 'DTEND', 'RECURRENCE-ID' ) as $type ) {
- if ( isset( $event[ "{$type}_array" ] ) ) {
- $timezone = ( isset( $event[ "{$type}_array" ][0]['TZID'] ) ) ? $event[ "{$type}_array" ][0]['TZID'] : $calendar_timezone;
- $timezone = $this->escape_param_text( $timezone );
- $event[ "{$type}_array" ][ $index ] = ( ( is_null( $timezone ) ) ? '' : sprintf( self::ICAL_DATE_TIME_TEMPLATE, $timezone ) ) . $event[ "{$type}_array" ][1];
- $event[ "{$type}_array" ][2] = $this->ical_date_to_unix_timestamp( $event[ "{$type}_array" ][3] );
- }
- }
-
- return $event;
- }
-
- /**
- * Returns an array of Events.
- * Every event is a class with the event details being properties within it.
- *
- * @return array
- */
- public function events() {
- $array = $this->cal;
- $array = isset( $array['VEVENT'] ) ? $array['VEVENT'] : array();
- $events = array();
-
- if ( ! empty( $array ) ) {
- foreach ( $array as $event ) {
- $events[] = new CoBlocks_ICal_Event( $event );
- }
- }
-
- return $events;
- }
-
- /**
- * Returns the calendar name
- *
- * @return string
- */
- public function calendar_name() {
- return isset( $this->cal['VCALENDAR']['X-WR-CALNAME'] ) ? $this->cal['VCALENDAR']['X-WR-CALNAME'] : '';
- }
-
- /**
- * Returns the calendar description
- *
- * @return string
- */
- public function calendar_description() {
- return isset( $this->cal['VCALENDAR']['X-WR-CALDESC'] ) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : '';
- }
-
- /**
- * Returns the calendar time zone
- *
- * @param boolean $ignore_utc Whether or not to ignore UTC.
- *
- * @return string
- */
- public function calendar_timezone( $ignore_utc = false ) {
- if ( isset( $this->cal['VCALENDAR']['X-WR-TIMEZONE'] ) ) {
- $timezone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
- } elseif ( isset( $this->cal['VTIMEZONE']['TZID'] ) ) {
- $timezone = $this->cal['VTIMEZONE']['TZID'];
- } else {
- $timezone = $this->default_time_zone;
- }
-
- // Validate the time zone, falling back to the time zone set in the PHP environment.
- $timezone = $this->timezone_string_to_date_timezone( $timezone )->getName();
-
- if ( $ignore_utc && strtoupper( $timezone ) === self::TIME_ZONE_UTC ) {
- return null;
- }
-
- 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 free_busy_events() {
- $array = $this->cal;
-
- return isset( $array['VFREEBUSY'] ) ? $array['VFREEBUSY'] : array();
- }
-
- /**
- * Returns a boolean value whether the current calendar has events or not
- *
- * @return boolean
- */
- public function has_events() {
- return ( count( $this->events() ) > 0 ) ? true : false;
- }
-
- /**
- * Returns a sorted array of the events in a given range,
- * or an empty array 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, then 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 29 Jan 2038.
- * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
- *
- * @param string|null $range_start Date range start.
- * @param string|null $range_end Date range end.
- *
- * @return array
- *
- * @throws \Exception Exception thrown on error.
- */
- public function events_from_range( $range_start = null, $range_end = null ) {
- // Sort events before processing range.
- $events = $this->sort_events_with_order( $this->events(), SORT_ASC );
-
- if ( empty( $events ) ) {
- return array();
- }
-
- $extended_events = array();
-
- if ( ! is_null( $range_start ) ) {
- try {
- $range_start = new \DateTime( $range_start, new \DateTimeZone( $this->default_time_zone ) );
- } catch ( \Exception $e ) {
- $range_start = false;
- }
- } else {
- $range_start = new \DateTime( 'now', new \DateTimeZone( $this->default_time_zone ) );
- }
-
- if ( ! is_null( $range_end ) ) {
- try {
- $range_end = new \DateTime( $range_end, new \DateTimeZone( $this->default_time_zone ) );
- } catch ( \Exception $e ) {
- $range_end = false;
- }
- } else {
- $range_end = new \DateTime( 'now', new \DateTimeZone( $this->default_time_zone ) );
- $range_end->modify( '+20 years' );
- }
-
- // If start and end are identical and are dates with no times...
- if ( $range_end->format( 'His' ) === 0 && $range_start->getTimestamp() === $range_end->getTimestamp() ) {
- $range_end->modify( '+1 day' );
- }
-
- $range_start = $range_start->getTimestamp();
- $range_end = $range_end->getTimestamp();
-
- foreach ( $events as $an_event ) {
- $event_start = $an_event->dtstart_array[2];
- $event_end = ( isset( $an_event->dtend_array[2] ) ) ? $an_event->dtend_array[2] : null;
-
- if ( ( $event_start >= $range_start && $event_start < $range_end ) // Event start date contained in the range.
- || ( null !== $event_end
- && (
- ( $event_end > $range_start && $event_end <= $range_end ) // Event end date contained in the range.
- || ( $event_start < $range_start && $event_end > $range_end ) // Event starts before and finishes after range.
- )
- )
- ) {
- $extended_events[] = $an_event;
- }
- }
-
- if ( empty( $extended_events ) ) {
- return array();
- }
-
- return $extended_events;
- }
-
- /**
- * Returns a sorted array of the events following a given string,
- * or `false` if no events exist in the range.
- *
- * @param string $interval A date with relative parts.
- *
- * @return array
- */
- public function events_from_interval( $interval ) {
- $range_start = new \DateTime( 'now', new \DateTimeZone( $this->default_time_zone ) );
- $range_end = new \DateTime( 'now', new \DateTimeZone( $this->default_time_zone ) );
-
- $date_interval = \DateInterval::createFromDateString( $interval );
- $range_end->add( $date_interval );
-
- return $this->events_from_range( $range_start->format( 'Y-m-d' ), $range_end->format( 'Y-m-d' ) );
- }
-
- /**
- * Sorts events based on a given sort order
- *
- * @param array $events Events array.
- * @param integer $sort_order Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING.
- *
- * @return array
- */
- public function sort_events_with_order( array $events, $sort_order = SORT_ASC ) {
- $extended_events = array();
- $timestamp = array();
-
- foreach ( $events as $key => $an_event ) {
- $extended_events[] = $an_event;
- $timestamp[ $key ] = $an_event->dtstart_array[2];
- }
-
- array_multisort( $timestamp, $sort_order, $extended_events );
-
- return $extended_events;
- }
-
- /**
- * Checks if a time zone is valid (IANA, CLDR, or Windows)
- *
- * @param string $timezone Timezone value.
- *
- * @return boolean
- */
- protected function is_valid_timezone_id( $timezone ) {
- return ( $this->is_valid_iana_timezone_id( $timezone ) !== false
- || $this->is_valid_cldr_timezone_id( $timezone ) !== false
- || $this->is_valid_windows_timezone_id( $timezone ) !== false );
- }
-
- /**
- * Checks if a time zone is a valid IANA time zone
- *
- * @param string $timezone Timezone value.
- *
- * @return boolean
- */
- protected function is_valid_iana_timezone_id( $timezone ) {
- if ( in_array( $timezone, $this->valid_iana_timezones, true ) ) {
- return true;
- }
-
- $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 ), true ) ) {
- $this->valid_iana_timezones[] = $timezone;
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Checks if a time zone is a valid CLDR time zone
- *
- * @param string $timezone Timezone value.
- * @return boolean
- */
- public function is_valid_cldr_timezone_id( $timezone ) {
- return array_key_exists( html_entity_decode( $timezone ), self::$cldr_timezones_map );
- }
-
- /**
- * Checks if a time zone is a recognised Windows (non-CLDR) time zone
- *
- * @param string $timezone Timezone value.
- * @return boolean
- */
- public function is_valid_windows_timezone_id( $timezone ) {
- return array_key_exists( html_entity_decode( $timezone ), self::$windows_timezones_map );
- }
-
- /**
- * Parses a duration and applies it to a date
- *
- * @param string $date Date string.
- * @param object $duration Duration value.
- * @param string $format Format.
- *
- * @return integer|\DateTime
- */
- protected function parse_duration( $date, $duration, $format = self::UNIX_FORMAT ) {
- $date_time = date_create( $date );
- $date_time->modify( $duration->y . ' year' );
- $date_time->modify( $duration->m . ' month' );
- $date_time->modify( $duration->d . ' day' );
- $date_time->modify( $duration->h . ' hour' );
- $date_time->modify( $duration->i . ' minute' );
- $date_time->modify( $duration->s . ' second' );
-
- if ( is_null( $format ) ) {
- $output = $date_time;
- } else {
- if ( self::UNIX_FORMAT === $format ) {
- $output = $date_time->getTimestamp();
- } else {
- $output = $date_time->format( $format );
- }
- }
-
- return $output;
- }
-
- /**
- * Gets the number of days between a start and end date
- *
- * @param integer $days Number of days.
- * @param integer $start Start value.
- * @param integer $end End value.
- *
- * @return integer
- */
- protected function number_of_days( $days, $start, $end ) {
- $w = array( gmdate( 'w', $start ), gmdate( 'w', $end ) );
- $base = floor( ( $end - $start ) / self::SECONDS_IN_A_WEEK );
- $sum = 0;
-
- for ( $day = 0; $day < 7; ++$day ) {
- if ( $days & pow( 2, $day ) ) {
- $sum += $base + ( ( $w[0] > $w[1] ) ? $w[0] <= $day || $day <= $w[1] : $w[0] <= $day && $day <= $w[1] );
- }
- }
-
- return $sum;
- }
-
- /**
- * Converts a negative day ordinal to
- * its equivalent positive form
- *
- * @param integer $day_number Day number.
- * @param integer $weekday Weekday value.
- * @param integer|\DateTime $timestamp Timestamp.
- *
- * @return string
- */
- protected function convert_day_ordinal_to_positive( $day_number, $weekday, $timestamp ) {
- // 0 when no number is defined for BYDAY.
- $day_number = empty( $day_number ) ? 1 : intval( $day_number );
-
- $day_ordinals = $this->day_ordinals;
-
- if ( -1 <= $day_number ) {
- $day_ordinal = ( -1 === $day_number ) ? 'last' : $day_ordinals[ $day_number ];
-
- if ( 'weekday' === $weekday ) {
- $day_ordinal = "-1 day {$day_ordinal}";
- }
-
- return $day_ordinal;
- }
-
- $timestamp = ( is_object( $timestamp ) ) ? $timestamp : \DateTime::createFromFormat( self::UNIX_FORMAT, $timestamp );
- $start = strtotime( 'first day of ' . $timestamp->format( self::DATE_TIME_FORMAT_PRETTY ) );
- $end = strtotime( 'last day of ' . $timestamp->format( self::DATE_TIME_FORMAT_PRETTY ) );
-
- // Used with pow(2, X) so pow(2, 4) is THURSDAY.
- $weekdays = array_flip( array_keys( $this->weekdays ) );
-
- $number_of_days = $this->number_of_days( pow( 2, $weekdays[ $weekday ] ), $start, $end );
-
- // Create subset.
- $day_ordinals = array_slice( $day_ordinals, 0, $number_of_days, true );
-
- // Reverse only the values.
- $day_ordinals = array_combine( array_keys( $day_ordinals ), array_reverse( array_values( $day_ordinals ) ) );
-
- return $day_ordinals[ $day_number * -1 ];
- }
-
- /**
- * Removes unprintable ASCII and UTF-8 characters
- *
- * @param string $data Data to remove characters from.
- *
- * @return string
- */
- protected function remove_unprintable_chars( $data ) {
- return preg_replace( '/[\x00-\x1F\x7F\xA0]/u', '', $data );
- }
-
- /**
- * Provides a polyfill for PHP 7.2's `mb_chr()`, which is a multibyte safe version of `chr()`.
- * Multibyte safe.
- *
- * @param integer $code Value used to determine encoding.
- *
- * @return string
- */
- protected function mb_chr( $code ) {
- $code %= 0x200000;
- if ( function_exists( 'mb_chr' ) ) {
- return mb_chr( $code ); // phpcs:ignore
- } else {
- if ( 0x80 > $code ) {
- $s = chr( $code );
- } elseif ( 0x800 > $code ) {
- $s = chr( 0xc0 | $code >> 6 ) . chr( 0x80 | $code & 0x3f );
- } elseif ( 0x10000 > $code ) {
- $s = chr( 0xe0 | $code >> 12 ) . chr( 0x80 | $code >> 6 & 0x3f ) . chr( 0x80 | $code & 0x3f );
- } else {
- $s = chr( 0xf0 | $code >> 18 ) . chr( 0x80 | $code >> 12 & 0x3f ) . chr( 0x80 | $code >> 6 & 0x3f ) . chr( 0x80 | $code & 0x3f );
- }
-
- return $s;
- }
- }
-
- /**
- * Replace all occurrences of the search string with the replacement string.
- * Multibyte safe.
- *
- * @param string|array $search Needle to search for.
- * @param string|array $replace Replace value.
- * @param string|array $subject Subject text.
- * @param string $encoding Encoding value.
- * @param integer $count Index value in the iteration.
- *
- * @return array|string
- */
- protected static function mb_str_replace( $search, $replace, $subject, $encoding = null, &$count = 0 ) {
- if ( is_array( $subject ) ) {
- // Call `mb_str_replace()` for each subject in the array, recursively.
- foreach ( $subject as $key => $value ) {
- $subject[ $key ] = self::mb_str_replace( $search, $replace, $value, $encoding, $count );
- }
- } else {
- // 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 ) {
- if ( is_null( $encoding ) ) {
- $encoding = mb_detect_encoding( $search, 'UTF-8', true );
- }
-
- $replace = $replacements[ $key ];
- $search_len = mb_strlen( $search, $encoding );
- $offset = mb_strpos( $subject, $search, 0, $encoding );
-
- $sb = array();
- while ( false !== $offset ) {
- $sb[] = mb_substr( $subject, 0, $offset, $encoding );
- $subject = mb_substr( $subject, $offset + $search_len, null, $encoding );
- ++$count;
- }
-
- $sb[] = $subject;
- $subject = implode( $replace, $sb );
- }
- }
-
- return $subject;
- }
-
- /**
- * Places double-quotes around texts that have characters not permitted
- * in parameter-texts, but are permitted in quoted-texts.
- *
- * @param string $candidate_text Candidate text string.
- * @return string
- */
- protected function escape_param_text( $candidate_text ) {
- if ( strpbrk( $candidate_text, ':;,' ) !== false ) {
- return '"' . $candidate_text . '"';
- }
-
- return $candidate_text;
- }
-
- /**
- * Replaces curly quotes and other special characters
- * with their standard equivalents
- *
- * @param string $data Data string.
- *
- * @return string
- */
- protected function clean_data( $data ) {
- $replacement_chars = 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.
- $cleaned_data = strtr( $data, $replacement_chars );
-
- // Replace Windows-1252 equivalents.
- $chars_to_replace = array_map(
- function ( $code ) {
- return $this->mb_chr( $code );
- },
- array( 133, 145, 146, 147, 148, 150, 151, 194 )
- );
-
- $cleaned_data = $this->mb_str_replace( $chars_to_replace, $replacement_chars, $cleaned_data );
-
- return $cleaned_data;
- }
-
- /**
- * Parses a list of excluded dates to be applied to an Event
- *
- * @param array $event Event array.
- *
- * @return array
- */
- public function parse_ex_dates( array $event ) {
- if ( empty( $event['EXDATE_array'] ) ) {
- return array();
- } else {
- $exdates = $event['EXDATE_array'];
- }
-
- $output = array();
- $current_time_zone = $this->default_time_zone;
-
- foreach ( $exdates as $sub_array ) {
- end( $sub_array );
- $final_key = key( $sub_array );
-
- foreach ( $sub_array as $key => $value ) {
- if ( 'TZID' === $key ) {
- $current_time_zone = $this->timezone_string_to_date_timezone( $sub_array[ $key ] );
- } elseif ( is_numeric( $key ) ) {
- $ical_date = $sub_array[ $key ];
-
- if ( substr( $ical_date, -1 ) === 'Z' ) {
- $current_time_zone = self::TIME_ZONE_UTC;
- }
-
- $output[] = new \DateTime( $ical_date, new \DateTimeZone( $current_time_zone ) );
-
- if ( $key === $final_key ) {
- // Reset to default.
- $current_time_zone = $this->default_time_zone;
- }
- }
- }
- }
-
- return $output;
- }
-
- /**
- * Checks if a date string is a valid date
- *
- * @param string $value Date value.
- *
- * @return boolean
- *
- * @throws \Exception Exception thrown on error.
- */
- public function is_valid_date( $value ) {
- if ( ! $value ) {
- return false;
- }
-
- try {
- new \DateTime( $value );
-
- return true;
- } catch ( \Exception $e ) {
- return false;
- }
- }
-
- /**
- * Checks if a filename exists as a file or URL
- *
- * @param string $filename Filename or URL.
- *
- * @return boolean
- */
- protected function is_file_or_url( $filename ) {
-
- $file_or_url = ( file_exists( $filename ) || filter_var( $filename, FILTER_VALIDATE_URL ) );
-
- return $file_or_url ? $file_or_url : false;
-
- }
-
- /**
- * Reads an entire file or URL into an array
- *
- * @param string $filename The file name.
- *
- * @return array
- *
- * @throws \Exception Exception thrown on error.
- */
- protected function file_or_url( $filename ) {
- // If this is a URL, let's use wp_safe_remote_get.
- if ( filter_var( $filename, FILTER_VALIDATE_URL ) ) {
- $request = wp_safe_remote_get( $filename );
- if ( is_wp_error( $request ) ) {
- return false;
- }
- return explode( "\n", wp_remote_retrieve_body( $request ) );
- }
- }
-
- /**
- * Returns a `date_timezone` object based on a string containing a time zone name.
- *
- * Falls back to the default time zone if string passed not a recognised time zone.
- *
- * @param string $timezone_string Timezone string.
- *
- * @return \date_timezone
- */
- public function timezone_string_to_date_timezone( $timezone_string ) {
- // Some time zones contain characters that are not permitted in param-texts,
- // but are within quoted texts. We need to remove the quotes as they're not
- // actually part of the time zone.
- $timezone_string = trim( $timezone_string, '"' );
- $timezone_string = html_entity_decode( $timezone_string );
-
- if ( $this->is_valid_iana_timezone_id( $timezone_string ) ) {
- return new \DateTimeZone( $timezone_string );
- }
-
- if ( $this->is_valid_cldr_timezone_id( $timezone_string ) ) {
- return new \DateTimeZone( self::$cldr_timezones_map[ $timezone_string ] );
- }
-
- if ( $this->is_valid_windows_timezone_id( $timezone_string ) ) {
- return new \DateTimeZone( self::$windows_timezones_map[ $timezone_string ] );
- }
-
- return new \DateTimeZone( $this->default_time_zone );
- }
-
- /**
- * Ensures the recurrence count is enforced against generated recurrence events.
- *
- * @param array $rrules Recurring rules.
- * @param array $recurrence_events Recurring events array.
- *
- * @return array
- */
- protected function trim_to_recurrence_count( array $rrules, array $recurrence_events ) {
- if ( isset( $rrules['COUNT'] ) ) {
- $recurrence_count = ( intval( $rrules['COUNT'] ) - 1 );
- $surplus_count = ( count( $recurrence_events ) - $recurrence_count );
-
- if ( $surplus_count > 0 ) {
- $recurrence_events = array_slice( $recurrence_events, 0, $recurrence_count );
- $this->event_count -= $surplus_count;
- }
- }
-
- return $recurrence_events;
- }
-
- /**
- * Checks if an excluded date matches a given date by reconciling time zones.
- *
- * @param DateTime $exdate Excluded date class.
- * @param array $an_event Event array.
- * @param integer $recurring_offset A date with relative parts.
- *
- * @return boolean
- */
- protected function is_ex_date_match( $exdate, array $an_event, $recurring_offset ) {
- $search_date = $an_event['DTSTART'];
-
- if ( substr( $search_date, -1 ) === 'Z' ) {
- $timezone = new \DateTimeZone( self::TIME_ZONE_UTC );
- } elseif ( isset( $an_event['DTSTART_array'][0]['TZID'] ) ) {
- $timezone = $this->timezone_string_to_date_timezone( $an_event['DTSTART_array'][0]['TZID'] );
- } else {
- $timezone = new \DateTimeZone( $this->default_time_zone );
- }
-
- $a = new \DateTime( $search_date, $timezone );
- $b = $exdate->add( \DateInterval::createFromDateString( $recurring_offset . ' seconds' ) );
-
- return $a === $b;
- }
-}
+class CoBlocks_ICal
+{
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
+ const DATE_TIME_FORMAT = 'Ymd\THis';
+ const DATE_TIME_FORMAT_PRETTY = 'F Y H:i:s';
+ const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
+ const ISO_8601_WEEK_START = 'MO';
+ const RECURRENCE_EVENT = 'Generated recurrence event';
+ const SECONDS_IN_A_WEEK = 604800;
+ const TIME_FORMAT = 'His';
+ const TIME_ZONE_UTC = 'UTC';
+ const UNIX_FORMAT = 'U';
+ const UNIX_MIN_YEAR = 1970;
+
+ /**
+ * Tracks the number of alarms in the current iCal feed
+ *
+ * @var integer
+ */
+ public $alarmCount = 0;
+
+ /**
+ * Tracks the number of events in the current iCal feed
+ *
+ * @var integer
+ */
+ public $eventCount = 0;
+
+ /**
+ * Tracks the free/busy count in the current iCal feed
+ *
+ * @var integer
+ */
+ public $freeBusyCount = 0;
+
+ /**
+ * Tracks 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;
+
+ /**
+ * Enables customisation of the default time zone
+ *
+ * @var string|null
+ */
+ public $defaultTimeZone;
+
+ /**
+ * The two letter representation of the first day of the week
+ *
+ * @var string
+ */
+ public $defaultWeekStart = self::ISO_8601_WEEK_START;
+
+ /**
+ * Toggles whether to skip the parsing of recurrence rules
+ *
+ * @var boolean
+ */
+ public $skipRecurrence = false;
+
+ /**
+ * Toggles whether to disable all character replacement.
+ *
+ * @var boolean
+ */
+ public $disableCharacterReplacement = false;
+
+ /**
+ * If this value is an integer, the parser will ignore all events more than roughly this many days before now.
+ * If this value is a date, the parser will ignore all events occurring before this date.
+ *
+ * @var \DateTimeInterface|integer|null
+ */
+ public $filterDaysBefore;
+
+ /**
+ * If this value is an integer, the parser will ignore all events more than roughly this many days after now.
+ * If this value is a date, the parser will ignore all events occurring after this date.
+ *
+ * @var \DateTimeInterface|integer|null
+ */
+ public $filterDaysAfter;
+
+ /**
+ * The parsed calendar
+ *
+ * @var array
+ */
+ public $cal = array();
+
+ /**
+ * Tracks the VFREEBUSY component
+ *
+ * @var integer
+ */
+ protected $freeBusyIndex = 0;
+
+ /**
+ * Variable to track the previous keyword
+ *
+ * @var string
+ */
+ protected $lastKeyword;
+
+ /**
+ * Cache valid IANA time zone IDs to avoid unnecessary lookups
+ *
+ * @var array
+ */
+ protected $validIanaTimeZones = array();
+
+ /**
+ * Event recurrence instances that have been altered
+ *
+ * @var array
+ */
+ protected $alteredRecurrenceInstances = array();
+
+ /**
+ * An associative array containing weekday conversion data
+ *
+ * The order of the days in the array follow the ISO-8601 specification of a week.
+ *
+ * @var array
+ */
+ protected $weekdays = array(
+ 'MO' => 'monday',
+ 'TU' => 'tuesday',
+ 'WE' => 'wednesday',
+ 'TH' => 'thursday',
+ 'FR' => 'friday',
+ 'SA' => 'saturday',
+ 'SU' => 'sunday',
+ );
+
+ /**
+ * An associative array containing frequency conversion terms
+ *
+ * @var array
+ */
+ protected $frequencyConversion = array(
+ 'DAILY' => 'day',
+ 'WEEKLY' => 'week',
+ 'MONTHLY' => 'month',
+ 'YEARLY' => 'year',
+ );
+
+ /**
+ * Holds the username and password for HTTP basic authentication
+ *
+ * @var array
+ */
+ protected $httpBasicAuth = array();
+
+ /**
+ * Holds the custom User Agent string header
+ *
+ * @var string
+ */
+ protected $httpUserAgent;
+
+ /**
+ * Holds the custom Accept Language string header
+ *
+ * @var string
+ */
+ protected $httpAcceptLanguage;
+
+ /**
+ * Holds the custom HTTP Protocol version
+ *
+ * @var string
+ */
+ protected $httpProtocolVersion;
+
+ /**
+ * Define which variables can be configured
+ *
+ * @var array
+ */
+ private static $configurableOptions = array(
+ 'defaultSpan',
+ 'defaultTimeZone',
+ 'defaultWeekStart',
+ 'disableCharacterReplacement',
+ 'filterDaysAfter',
+ 'filterDaysBefore',
+ 'httpUserAgent',
+ 'skipRecurrence',
+ );
+
+ /**
+ * CLDR time zones mapped to IANA time zones.
+ *
+ * @var array
+ */
+ private static $cldrTimeZonesMap = array(
+ '(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
+ '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
+ '(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
+ '(UTC-09:00) Alaska' => 'America/Anchorage',
+ '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
+ '(UTC-07:00) Arizona' => 'America/Phoenix',
+ '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
+ '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
+ '(UTC-06:00) Central America' => 'America/Guatemala',
+ '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
+ '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
+ '(UTC-06:00) Saskatchewan' => 'America/Regina',
+ '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
+ '(UTC-05:00) Chetumal' => 'America/Cancun',
+ '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
+ '(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
+ '(UTC-04:00) Asuncion' => 'America/Asuncion',
+ '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
+ '(UTC-04:00) Caracas' => 'America/Caracas',
+ '(UTC-04:00) Cuiaba' => 'America/Cuiaba',
+ '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
+ '(UTC-04:00) Santiago' => 'America/Santiago',
+ '(UTC-03:30) Newfoundland' => 'America/St_Johns',
+ '(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
+ '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
+ '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
+ '(UTC-03:00) Greenland' => 'America/Godthab',
+ '(UTC-03:00) Montevideo' => 'America/Montevideo',
+ '(UTC-03:00) Salvador' => 'America/Bahia',
+ '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
+ '(UTC-01:00) Azores' => 'Atlantic/Azores',
+ '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
+ '(UTC) Coordinated Universal Time' => 'Etc/GMT',
+ '(UTC+00:00) Casablanca' => 'Africa/Casablanca',
+ '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
+ '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
+ '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
+ '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
+ '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
+ '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
+ '(UTC+01:00) West Central Africa' => 'Africa/Lagos',
+ '(UTC+02:00) Amman' => 'Asia/Amman',
+ '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
+ '(UTC+02:00) Beirut' => 'Asia/Beirut',
+ '(UTC+02:00) Cairo' => 'Africa/Cairo',
+ '(UTC+02:00) Chisinau' => 'Europe/Chisinau',
+ '(UTC+02:00) Damascus' => 'Asia/Damascus',
+ '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
+ '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
+ '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
+ '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
+ '(UTC+02:00) Tripoli' => 'Africa/Tripoli',
+ '(UTC+02:00) Windhoek' => 'Africa/Windhoek',
+ '(UTC+03:00) Baghdad' => 'Asia/Baghdad',
+ '(UTC+03:00) Istanbul' => 'Europe/Istanbul',
+ '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
+ '(UTC+03:00) Minsk' => 'Europe/Minsk',
+ '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
+ '(UTC+03:00) Nairobi' => 'Africa/Nairobi',
+ '(UTC+03:30) Tehran' => 'Asia/Tehran',
+ '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
+ '(UTC+04:00) Baku' => 'Asia/Baku',
+ '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
+ '(UTC+04:00) Port Louis' => 'Indian/Mauritius',
+ '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
+ '(UTC+04:00) Yerevan' => 'Asia/Yerevan',
+ '(UTC+04:30) Kabul' => 'Asia/Kabul',
+ '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
+ '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
+ '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
+ '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
+ '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
+ '(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
+ '(UTC+06:00) Astana' => 'Asia/Almaty',
+ '(UTC+06:00) Dhaka' => 'Asia/Dhaka',
+ '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
+ '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
+ '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
+ '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
+ '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
+ '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
+ '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
+ '(UTC+08:00) Perth' => 'Australia/Perth',
+ '(UTC+08:00) Taipei' => 'Asia/Taipei',
+ '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
+ '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
+ '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
+ '(UTC+09:00) Seoul' => 'Asia/Seoul',
+ '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
+ '(UTC+09:30) Adelaide' => 'Australia/Adelaide',
+ '(UTC+09:30) Darwin' => 'Australia/Darwin',
+ '(UTC+10:00) Brisbane' => 'Australia/Brisbane',
+ '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
+ '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
+ '(UTC+10:00) Hobart' => 'Australia/Hobart',
+ '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
+ '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
+ '(UTC+11:00) Magadan' => 'Asia/Magadan',
+ '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
+ '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
+ '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
+ '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
+ '(UTC+12:00) Fiji' => 'Pacific/Fiji',
+ "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
+ '(UTC+13:00) Samoa' => 'Pacific/Apia',
+ '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
+ );
+
+ /**
+ * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
+ * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
+ *
+ * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
+ *
+ * @var array
+ */
+ private static $windowsTimeZonesMap = array(
+ 'AUS Central Standard Time' => 'Australia/Darwin',
+ 'AUS Eastern Standard Time' => 'Australia/Sydney',
+ 'Afghanistan Standard Time' => 'Asia/Kabul',
+ 'Alaskan Standard Time' => 'America/Anchorage',
+ 'Aleutian Standard Time' => 'America/Adak',
+ 'Altai Standard Time' => 'Asia/Barnaul',
+ 'Arab Standard Time' => 'Asia/Riyadh',
+ 'Arabian Standard Time' => 'Asia/Dubai',
+ 'Arabic Standard Time' => 'Asia/Baghdad',
+ 'Argentina Standard Time' => 'America/Buenos_Aires',
+ 'Astrakhan Standard Time' => 'Europe/Astrakhan',
+ 'Atlantic Standard Time' => 'America/Halifax',
+ 'Aus Central W. Standard Time' => 'Australia/Eucla',
+ 'Azerbaijan Standard Time' => 'Asia/Baku',
+ 'Azores Standard Time' => 'Atlantic/Azores',
+ 'Bahia Standard Time' => 'America/Bahia',
+ 'Bangladesh Standard Time' => 'Asia/Dhaka',
+ 'Belarus Standard Time' => 'Europe/Minsk',
+ 'Bougainville Standard Time' => 'Pacific/Bougainville',
+ 'Canada Central Standard Time' => 'America/Regina',
+ 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
+ 'Caucasus Standard Time' => 'Asia/Yerevan',
+ 'Cen. Australia Standard Time' => 'Australia/Adelaide',
+ 'Central America Standard Time' => 'America/Guatemala',
+ 'Central Asia Standard Time' => 'Asia/Almaty',
+ 'Central Brazilian Standard Time' => 'America/Cuiaba',
+ 'Central Europe Standard Time' => 'Europe/Budapest',
+ 'Central European Standard Time' => 'Europe/Warsaw',
+ 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
+ 'Central Standard Time (Mexico)' => 'America/Mexico_City',
+ 'Central Standard Time' => 'America/Chicago',
+ 'Chatham Islands Standard Time' => 'Pacific/Chatham',
+ 'China Standard Time' => 'Asia/Shanghai',
+ 'Cuba Standard Time' => 'America/Havana',
+ 'Dateline Standard Time' => 'Etc/GMT+12',
+ 'E. Africa Standard Time' => 'Africa/Nairobi',
+ 'E. Australia Standard Time' => 'Australia/Brisbane',
+ 'E. Europe Standard Time' => 'Europe/Chisinau',
+ 'E. South America Standard Time' => 'America/Sao_Paulo',
+ 'Easter Island Standard Time' => 'Pacific/Easter',
+ 'Eastern Standard Time (Mexico)' => 'America/Cancun',
+ 'Eastern Standard Time' => 'America/New_York',
+ 'Egypt Standard Time' => 'Africa/Cairo',
+ 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
+ 'FLE Standard Time' => 'Europe/Kiev',
+ 'Fiji Standard Time' => 'Pacific/Fiji',
+ 'GMT Standard Time' => 'Europe/London',
+ 'GTB Standard Time' => 'Europe/Bucharest',
+ 'Georgian Standard Time' => 'Asia/Tbilisi',
+ 'Greenland Standard Time' => 'America/Godthab',
+ 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
+ 'Haiti Standard Time' => 'America/Port-au-Prince',
+ 'Hawaiian Standard Time' => 'Pacific/Honolulu',
+ 'India Standard Time' => 'Asia/Calcutta',
+ 'Iran Standard Time' => 'Asia/Tehran',
+ 'Israel Standard Time' => 'Asia/Jerusalem',
+ 'Jordan Standard Time' => 'Asia/Amman',
+ 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
+ 'Korea Standard Time' => 'Asia/Seoul',
+ 'Libya Standard Time' => 'Africa/Tripoli',
+ 'Line Islands Standard Time' => 'Pacific/Kiritimati',
+ 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
+ 'Magadan Standard Time' => 'Asia/Magadan',
+ 'Magallanes Standard Time' => 'America/Punta_Arenas',
+ 'Marquesas Standard Time' => 'Pacific/Marquesas',
+ 'Mauritius Standard Time' => 'Indian/Mauritius',
+ 'Middle East Standard Time' => 'Asia/Beirut',
+ 'Montevideo Standard Time' => 'America/Montevideo',
+ 'Morocco Standard Time' => 'Africa/Casablanca',
+ 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
+ 'Mountain Standard Time' => 'America/Denver',
+ 'Myanmar Standard Time' => 'Asia/Rangoon',
+ 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
+ 'Namibia Standard Time' => 'Africa/Windhoek',
+ 'Nepal Standard Time' => 'Asia/Katmandu',
+ 'New Zealand Standard Time' => 'Pacific/Auckland',
+ 'Newfoundland Standard Time' => 'America/St_Johns',
+ 'Norfolk Standard Time' => 'Pacific/Norfolk',
+ 'North Asia East Standard Time' => 'Asia/Irkutsk',
+ 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
+ 'North Korea Standard Time' => 'Asia/Pyongyang',
+ 'Omsk Standard Time' => 'Asia/Omsk',
+ 'Pacific SA Standard Time' => 'America/Santiago',
+ 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
+ 'Pacific Standard Time' => 'America/Los_Angeles',
+ 'Pakistan Standard Time' => 'Asia/Karachi',
+ 'Paraguay Standard Time' => 'America/Asuncion',
+ 'Romance Standard Time' => 'Europe/Paris',
+ 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
+ 'Russia Time Zone 11' => 'Asia/Kamchatka',
+ 'Russia Time Zone 3' => 'Europe/Samara',
+ 'Russian Standard Time' => 'Europe/Moscow',
+ 'SA Eastern Standard Time' => 'America/Cayenne',
+ 'SA Pacific Standard Time' => 'America/Bogota',
+ 'SA Western Standard Time' => 'America/La_Paz',
+ 'SE Asia Standard Time' => 'Asia/Bangkok',
+ 'Saint Pierre Standard Time' => 'America/Miquelon',
+ 'Sakhalin Standard Time' => 'Asia/Sakhalin',
+ 'Samoa Standard Time' => 'Pacific/Apia',
+ 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
+ 'Saratov Standard Time' => 'Europe/Saratov',
+ 'Singapore Standard Time' => 'Asia/Singapore',
+ 'South Africa Standard Time' => 'Africa/Johannesburg',
+ 'Sri Lanka Standard Time' => 'Asia/Colombo',
+ 'Sudan Standard Time' => 'Africa/Tripoli',
+ 'Syria Standard Time' => 'Asia/Damascus',
+ 'Taipei Standard Time' => 'Asia/Taipei',
+ 'Tasmania Standard Time' => 'Australia/Hobart',
+ 'Tocantins Standard Time' => 'America/Araguaina',
+ 'Tokyo Standard Time' => 'Asia/Tokyo',
+ 'Tomsk Standard Time' => 'Asia/Tomsk',
+ 'Tonga Standard Time' => 'Pacific/Tongatapu',
+ 'Transbaikal Standard Time' => 'Asia/Chita',
+ 'Turkey Standard Time' => 'Europe/Istanbul',
+ 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
+ 'US Eastern Standard Time' => 'America/Indianapolis',
+ 'US Mountain Standard Time' => 'America/Phoenix',
+ 'UTC' => 'Etc/GMT',
+ 'UTC+12' => 'Etc/GMT-12',
+ 'UTC+13' => 'Etc/GMT-13',
+ 'UTC-02' => 'Etc/GMT+2',
+ 'UTC-08' => 'Etc/GMT+8',
+ 'UTC-09' => 'Etc/GMT+9',
+ 'UTC-11' => 'Etc/GMT+11',
+ 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
+ 'Venezuela Standard Time' => 'America/Caracas',
+ 'Vladivostok Standard Time' => 'Asia/Vladivostok',
+ 'W. Australia Standard Time' => 'Australia/Perth',
+ 'W. Central Africa Standard Time' => 'Africa/Lagos',
+ 'W. Europe Standard Time' => 'Europe/Berlin',
+ 'W. Mongolia Standard Time' => 'Asia/Hovd',
+ 'West Asia Standard Time' => 'Asia/Tashkent',
+ 'West Bank Standard Time' => 'Asia/Hebron',
+ 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
+ 'Yakutsk Standard Time' => 'Asia/Yakutsk',
+ );
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMaxTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMinTimestamp;
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMinTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMaxTimestamp;
+
+ /**
+ * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set.
+ *
+ * @var boolean
+ */
+ private $shouldFilterByWindow = false;
+
+ /**
+ * Creates the ICal object
+ *
+ * @param mixed $files
+ * @param array $options
+ * @return void
+ */
+ public function __construct($files = false, array $options = array())
+ {
+ if (\PHP_VERSION_ID < 80100) {
+ ini_set('auto_detect_line_endings', '1');
+ }
+
+ foreach ($options as $option => $value) {
+ if (in_array($option, self::$configurableOptions)) {
+ $this->{$option} = $value;
+ }
+ }
+
+ // Fallback to use the system default time zone
+ if (!isset($this->defaultTimeZone) || !$this->isValidTimeZoneId($this->defaultTimeZone)) {
+ $this->defaultTimeZone = $this->getDefaultTimeZone(true);
+ }
+
+ // Ideally you would use `PHP_INT_MIN` from PHP 7
+ $php_int_min = -2147483648;
+
+ $this->windowMinTimestamp = $php_int_min;
+
+ if (!is_null($this->filterDaysBefore)) {
+ if (is_int($this->filterDaysBefore)) {
+ $this->windowMinTimestamp = (new \DateTime('now'))
+ ->sub(new \DateInterval('P' . $this->filterDaysBefore . 'D'))
+ ->getTimestamp();
+ }
+
+ if ($this->filterDaysBefore instanceof \DateTimeInterface) {
+ $this->windowMinTimestamp = $this->filterDaysBefore->getTimestamp();
+ }
+ }
+
+ $this->windowMaxTimestamp = PHP_INT_MAX;
+
+ if (!is_null($this->filterDaysAfter)) {
+ if (is_int($this->filterDaysAfter)) {
+ $this->windowMaxTimestamp = (new \DateTime('now'))
+ ->add(new \DateInterval('P' . $this->filterDaysAfter . 'D'))
+ ->getTimestamp();
+ }
+
+ if ($this->filterDaysAfter instanceof \DateTimeInterface) {
+ $this->windowMaxTimestamp = $this->filterDaysAfter->getTimestamp();
+ }
+ }
+
+ $this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter);
+
+ if ($files !== false) {
+ $files = is_array($files) ? $files : array($files);
+
+ foreach ($files as $file) {
+ if (!is_array($file) && $this->isFileOrUrl($file)) {
+ $lines = $this->fileOrUrl($file);
+ } else {
+ $lines = is_array($file) ? $file : array($file);
+ }
+
+ $this->initLines($lines);
+ }
+ }
+ }
+
+ /**
+ * Initialises lines from a string
+ *
+ * @param string $string
+ * @return ICal
+ */
+ public function initString($string)
+ {
+ $string = str_replace(array("\r\n", "\n\r", "\r"), "\n", $string);
+
+ if ($this->cal === array()) {
+ $lines = explode("\n", $string);
+
+ $this->initLines($lines);
+ } else {
+ trigger_error('ICal::initString: Calendar already initialised in constructor', E_USER_NOTICE);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Initialises lines from a file
+ *
+ * @param string $file
+ * @return ICal
+ */
+ public function initFile($file)
+ {
+ if ($this->cal === array()) {
+ $lines = $this->fileOrUrl($file);
+
+ $this->initLines($lines);
+ } else {
+ trigger_error('ICal::initFile: Calendar already initialised in constructor', E_USER_NOTICE);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Initialises lines from a URL
+ *
+ * @param string $url
+ * @param string $username
+ * @param string $password
+ * @param string $userAgent
+ * @param string $acceptLanguage
+ * @param string $httpProtocolVersion
+ * @return ICal
+ */
+ public function initUrl($url, $username = null, $password = null, $userAgent = null, $acceptLanguage = null, $httpProtocolVersion = null)
+ {
+ if (!is_null($username) && !is_null($password)) {
+ $this->httpBasicAuth['username'] = $username;
+ $this->httpBasicAuth['password'] = $password;
+ }
+
+ if (!is_null($userAgent)) {
+ $this->httpUserAgent = $userAgent;
+ }
+
+ if (!is_null($acceptLanguage)) {
+ $this->httpAcceptLanguage = $acceptLanguage;
+ }
+
+ if (!is_null($httpProtocolVersion)) {
+ $this->httpProtocolVersion = $httpProtocolVersion;
+ }
+
+ $this->initFile($url);
+
+ return $this;
+ }
+
+ /**
+ * Initialises the parser using an array
+ * containing each line of iCal content
+ *
+ * @param array $lines
+ * @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);
+
+ if (empty($line)) {
+ continue;
+ }
+
+ if (!$this->disableCharacterReplacement) {
+ $line = str_replace(array(
+ ' ',
+ "\t",
+ "\xc2\xa0", // Non-breaking space
+ ), ' ', $line);
+
+ $line = $this->cleanCharacters($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 one already
+ $blankArray = array(); // Empty placeholder array
+ $values[] = $blankArray;
+ } else {
+ $values = array(); // Use blank array to ignore this line
+ }
+ } elseif (empty($values[0])) {
+ $values = array(); // Use blank array to ignore this line
+ }
+
+ // Reverse so that our array of properties is processed first
+ $values = array_reverse($values);
+
+ foreach ($values as $value) {
+ switch ($line) {
+ // https://www.kanzaki.com/docs/ical/vtodo.html
+ case 'BEGIN:VTODO':
+ if (!is_array($value)) {
+ $this->todoCount++;
+ }
+
+ $component = 'VTODO';
+
+ break;
+
+ case 'BEGIN:VEVENT':
+ // https://www.kanzaki.com/docs/ical/vevent.html
+ if (!is_array($value)) {
+ $this->eventCount++;
+ }
+
+ $component = 'VEVENT';
+
+ break;
+
+ case 'BEGIN:VFREEBUSY':
+ // https://www.kanzaki.com/docs/ical/vfreebusy.html
+ if (!is_array($value)) {
+ $this->freeBusyIndex++;
+ }
+
+ $component = 'VFREEBUSY';
+
+ break;
+
+ case 'BEGIN:VALARM':
+ if (!is_array($value)) {
+ $this->alarmCount++;
+ }
+
+ $component = 'VALARM';
+
+ break;
+
+ case 'END:VALARM':
+ $component = 'VEVENT';
+
+ break;
+
+ case 'BEGIN:DAYLIGHT':
+ case 'BEGIN:STANDARD':
+ case 'BEGIN:VCALENDAR':
+ case 'BEGIN:VTIMEZONE':
+ $component = $value;
+
+ break;
+
+ case 'END:DAYLIGHT':
+ case 'END:STANDARD':
+ case 'END:VCALENDAR':
+ case 'END:VFREEBUSY':
+ case 'END:VTIMEZONE':
+ case 'END:VTODO':
+ $component = 'VCALENDAR';
+
+ break;
+
+ case 'END:VEVENT':
+ if ($this->shouldFilterByWindow) {
+ $this->removeLastEventIfOutsideWindowAndNonRecurring();
+ }
+
+ $component = 'VCALENDAR';
+
+ break;
+
+ default:
+ $this->addCalendarComponentWithKeyAndValue($component, $keyword, $value);
+
+ break;
+ }
+ }
+ }
+
+ $this->processEvents();
+
+ if (!$this->skipRecurrence) {
+ $this->processRecurrences();
+
+ // Apply changes to altered recurrence instances
+ if ($this->alteredRecurrenceInstances !== array()) {
+ $events = $this->cal['VEVENT'];
+
+ foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) {
+ if (isset($alteredRecurrenceInstance['altered-event'])) {
+ $alteredEvent = $alteredRecurrenceInstance['altered-event'];
+ $key = key($alteredEvent);
+ $events[$key] = $alteredEvent[$key];
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ if ($this->shouldFilterByWindow) {
+ $this->reduceEventsToMinMaxRange();
+ }
+
+ $this->processDateConversions();
+ }
+ }
+
+ /**
+ * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
+ * `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ *
+ * @return void
+ */
+ protected function removeLastEventIfOutsideWindowAndNonRecurring()
+ {
+ $events = $this->cal['VEVENT'];
+
+ if ($events !== array()) {
+ $lastIndex = count($events) - 1;
+ $lastEvent = $events[$lastIndex];
+
+ if ((!isset($lastEvent['RRULE']) || $lastEvent['RRULE'] === '') && $this->doesEventStartOutsideWindow($lastEvent)) {
+ $this->eventCount--;
+
+ unset($events[$lastIndex]);
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Reduces the number of events to the defined minimum and maximum range
+ *
+ * @return void
+ */
+ protected function reduceEventsToMinMaxRange()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if ($events !== array()) {
+ foreach ($events as $key => $anEvent) {
+ if ($anEvent === null) {
+ unset($events[$key]);
+
+ continue;
+ }
+
+ if ($this->doesEventStartOutsideWindow($anEvent)) {
+ $this->eventCount--;
+
+ unset($events[$key]);
+
+ continue;
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ * Returns `true` for invalid dates.
+ *
+ * @param array $event
+ * @return boolean
+ */
+ protected function doesEventStartOutsideWindow(array $event)
+ {
+ return !$this->isValidDate($event['DTSTART']) || $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
+ }
+
+ /**
+ * Determines whether a valid iCalendar date is within a given range
+ *
+ * @param string $calendarDate
+ * @param integer $minTimestamp
+ * @param integer $maxTimestamp
+ * @return boolean
+ */
+ protected function isOutOfRange($calendarDate, $minTimestamp, $maxTimestamp)
+ {
+ $timestamp = strtotime(explode('T', $calendarDate)[0]);
+
+ return $timestamp < $minTimestamp || $timestamp > $maxTimestamp;
+ }
+
+ /**
+ * Unfolds an iCal file in preparation for parsing
+ * (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html)
+ *
+ * @param array $lines
+ * @return array
+ */
+ protected function unfold(array $lines)
+ {
+ $string = implode(PHP_EOL, $lines);
+ $string = str_ireplace(' ', ' ', $string);
+
+ $cleanedString = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string);
+
+ $lines = explode(PHP_EOL, $cleanedString ?: $string);
+
+ return $lines;
+ }
+
+ /**
+ * Add one key and value pair to the `$this->cal` array
+ *
+ * @param string $component
+ * @param string|boolean $keyword
+ * @param string|array $value
+ * @return void
+ */
+ protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value)
+ {
+ if ($keyword === false) {
+ $keyword = $this->lastKeyword;
+ }
+
+ switch ($component) {
+ case 'VALARM':
+ $key1 = 'VEVENT';
+ $key2 = ($this->eventCount - 1);
+ $key3 = $component;
+
+ if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array();
+ }
+
+ if (is_array($value)) {
+ // Add array of properties to the end
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
+ } else {
+ if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
+ $this->cal[$key1][$key2][$key3][$keyword] = $value;
+ }
+
+ if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value;
+ }
+ }
+ break;
+
+ case 'VEVENT':
+ $key1 = $component;
+ $key2 = ($this->eventCount - 1);
+
+ if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2]["{$keyword}_array"] = array();
+ }
+
+ if (is_array($value)) {
+ // Add array of properties to the end
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
+ } else {
+ if (!isset($this->cal[$key1][$key2][$keyword])) {
+ $this->cal[$key1][$key2][$keyword] = $value;
+ }
+
+ if ($keyword === 'EXDATE') {
+ if (trim($value) === $value) {
+ $array = array_filter(explode(',', $value));
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $array;
+ } else {
+ $value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value));
+ $this->cal[$key1][$key2]["{$keyword}_array"][1] = $value;
+ }
+ } else {
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
+
+ if ($keyword === 'DURATION') {
+ $duration = new \DateInterval($value);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
+ }
+ }
+
+ if (!is_array($value) && $this->cal[$key1][$key2][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$keyword] .= ',' . $value;
+ }
+ }
+ break;
+
+ case 'VFREEBUSY':
+ $key1 = $component;
+ $key2 = ($this->freeBusyIndex - 1);
+ $key3 = $keyword;
+
+ if ($keyword === 'FREEBUSY') {
+ if (is_array($value)) {
+ $this->cal[$key1][$key2][$key3][][] = $value;
+ } else {
+ $this->freeBusyCount++;
+
+ end($this->cal[$key1][$key2][$key3]);
+ $key = key($this->cal[$key1][$key2][$key3]);
+
+ $value = explode('/', $value);
+ $this->cal[$key1][$key2][$key3][$key][] = $value;
+ }
+ } else {
+ $this->cal[$key1][$key2][$key3][] = $value;
+ }
+ break;
+
+ case 'VTODO':
+ $this->cal[$component][$this->todoCount - 1][$keyword] = $value;
+
+ break;
+
+ default:
+ $this->cal[$component][$keyword] = $value;
+
+ break;
+ }
+
+ if (is_string($keyword)) {
+ $this->lastKeyword = $keyword;
+ }
+ }
+
+ /**
+ * Gets the key value pair from an iCal string
+ *
+ * @param string $text
+ * @return array
+ */
+ public function keyValueFromString($text)
+ {
+ $splitLine = $this->parseLine($text);
+ $object = array();
+ $paramObj = array();
+ $valueObj = '';
+ $i = 0;
+
+ while ($i < count($splitLine)) {
+ // The first token corresponds to the property name
+ if ($i === 0) {
+ $object[0] = $splitLine[$i];
+ $i++;
+
+ continue;
+ }
+
+ // After each semicolon define the property parameters
+ if ($splitLine[$i] == ';') {
+ $i++;
+ $paramName = $splitLine[$i];
+ $i += 2;
+ $paramValue = array();
+ $multiValue = false;
+ // A parameter can have multiple values separated by a comma
+ while ($i + 1 < count($splitLine) && $splitLine[$i + 1] === ',') {
+ $paramValue[] = $splitLine[$i];
+ $i += 2;
+ $multiValue = true;
+ }
+
+ if ($multiValue) {
+ $paramValue[] = $splitLine[$i];
+ } else {
+ $paramValue = $splitLine[$i];
+ }
+
+ // Create object with paramName => paramValue
+ $paramObj[$paramName] = $paramValue;
+ }
+
+ // After a colon all tokens are concatenated (non-standard behaviour because the property can have multiple values
+ // according to RFC5545)
+ if ($splitLine[$i] === ':') {
+ $i++;
+ while ($i < count($splitLine)) {
+ $valueObj .= $splitLine[$i];
+ $i++;
+ }
+ }
+
+ $i++;
+ }
+
+ // Object construction
+ if ($paramObj !== array()) {
+ $object[1][0] = $valueObj;
+ $object[1][1] = $paramObj;
+ } else {
+ $object[1] = $valueObj;
+ }
+
+ return $object;
+ }
+
+ /**
+ * Parses a line from an iCal file into an array of tokens
+ *
+ * @param string $line
+ * @return array
+ */
+ protected function parseLine($line)
+ {
+ $words = array();
+ $word = '';
+ // The use of str_split is not a problem here even if the character set is in utf8
+ // Indeed we only compare the characters , ; : = " which are on a single byte
+ $arrayOfChar = str_split($line);
+ $inDoubleQuotes = false;
+
+ foreach ($arrayOfChar as $char) {
+ // Don't stop the word on ; , : = if it is enclosed in double quotes
+ if ($char === '"') {
+ if ($word !== '') {
+ $words[] = $word;
+ }
+
+ $word = '';
+ $inDoubleQuotes = !$inDoubleQuotes;
+ } elseif (!in_array($char, array(';', ':', ',', '=')) || $inDoubleQuotes) {
+ $word .= $char;
+ } else {
+ if ($word !== '') {
+ $words[] = $word;
+ }
+
+ $words[] = $char;
+ $word = '';
+ }
+ }
+
+ $words[] = $word;
+
+ return $words;
+ }
+
+ /**
+ * Returns the default time zone if set.
+ * Falls back to the system default if not set.
+ *
+ * @param boolean $forceReturnSystemDefault
+ * @return string
+ */
+ private function getDefaultTimeZone($forceReturnSystemDefault = false)
+ {
+ $systemDefault = date_default_timezone_get();
+
+ if ($forceReturnSystemDefault) {
+ return $systemDefault;
+ }
+
+ return $this->defaultTimeZone ?: $systemDefault;
+ }
+
+ /**
+ * Returns a `DateTime` object from an iCal date time format
+ *
+ * @param string $icalDate
+ * @return \DateTime|false
+ * @throws \Exception
+ */
+ public function iCalDateToDateTime($icalDate)
+ {
+ /**
+ * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
+ *
+ * UTC: Has a trailing 'Z'
+ * Floating: No time zone reference specified, no trailing 'Z', use local time
+ * TZID: Set time zone as specified
+ *
+ * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
+ * Must have a local time zone set to process floating times.
+ */
+ $pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone
+ $pattern .= ':?'; // Time zone delimiter
+ $pattern .= '([0-9]{8})'; // [2]: YYYYMMDD
+ $pattern .= 'T?'; // Time delimiter
+ $pattern .= '(?(?<=T)([0-9]{6}))'; // [3]: HHMMSS (filled if delimiter present)
+ $pattern .= '(Z?)/'; // [4]: UTC flag
+
+ preg_match($pattern, $icalDate, $date);
+
+ if ($date === array()) {
+ throw new \Exception('Invalid iCal date format.');
+ }
+
+ // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
+ // PHP, on the other hand, uses negative numbers for that. Thus we don't
+ // need to special case them.
+
+ if ($date[4] === 'Z') {
+ $dateTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
+ } elseif (isset($date[1]) && $date[1] !== '') {
+ $dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]);
+ } else {
+ $dateTimeZone = new \DateTimeZone($this->getDefaultTimeZone());
+ }
+
+ // The exclamation mark at the start of the format string indicates that if a
+ // time portion is not included, the time in the returned DateTime should be
+ // set to 00:00:00. Without it, the time would be set to the current system time.
+ $dateFormat = '!Ymd';
+ $dateBasic = $date[2];
+ if (isset($date[3]) && $date[3] !== '') {
+ $dateBasic .= "T{$date[3]}";
+ $dateFormat .= '\THis';
+ }
+
+ return \DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone);
+ }
+
+ /**
+ * Returns a Unix timestamp from an iCal date time format
+ *
+ * @param string $icalDate
+ * @return integer
+ */
+ public function iCalDateToUnixTimestamp($icalDate)
+ {
+ $iCalDateToDateTime = $this->iCalDateToDateTime($icalDate);
+
+ if ($iCalDateToDateTime === false) {
+ trigger_error("ICal::iCalDateToUnixTimestamp: Invalid date passed ({$icalDate})", E_USER_NOTICE);
+
+ return 0;
+ }
+
+ return $iCalDateToDateTime->getTimestamp();
+ }
+
+ /**
+ * Returns a date adapted to the calendar time zone depending on the event `TZID`
+ *
+ * @param array $event
+ * @param string $key
+ * @param string|null $format
+ * @return string|integer|boolean|\DateTime
+ */
+ public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT)
+ {
+ if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
+ return false;
+ }
+
+ $dateArray = $event["{$key}_array"];
+
+ if ($key === 'DURATION') {
+ $dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2]);
+
+ if ($dateTime instanceof \DateTime === false) {
+ trigger_error("ICal::iCalDateWithTimeZone: Invalid date passed ({$event['DTSTART']})", E_USER_NOTICE);
+
+ return false;
+ }
+ } else {
+ // When constructing from a Unix Timestamp, no time zone needs passing.
+ $dateTime = new \DateTime("@{$dateArray[2]}");
+ }
+
+ $calendarTimeZone = $this->calendarTimeZone();
+
+ if (!is_null($calendarTimeZone)) {
+ // Set the time zone we wish to use when running `$dateTime->format`.
+ $dateTime->setTimezone(new \DateTimeZone($calendarTimeZone));
+ }
+
+ if (is_null($format)) {
+ return $dateTime;
+ }
+
+ return $dateTime->format($format);
+ }
+
+ /**
+ * Performs admin tasks on all events as read from the iCal file.
+ * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
+ * Tracks modified recurrence instances
+ *
+ * @return void
+ */
+ protected function processEvents()
+ {
+ $checks = null;
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if ($events !== array()) {
+ 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'])) {
+ $timeZone = $this->escapeParamText($anEvent["{$type}_array"][0]['TZID']);
+ $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date;
+ }
+
+ $anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date);
+ $anEvent["{$type}_array"][3] = $date;
+ }
+ }
+
+ if (isset($anEvent['RECURRENCE-ID'])) {
+ $uid = $anEvent['UID'];
+
+ if (!isset($this->alteredRecurrenceInstances[$uid])) {
+ $this->alteredRecurrenceInstances[$uid] = array();
+ }
+
+ $recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3]);
+ $this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc;
+ }
+
+ $events[$key] = $anEvent;
+ }
+
+ $eventKeysToRemove = array();
+
+ foreach ($events as $key => $event) {
+ $checks[] = !isset($event['RECURRENCE-ID']);
+ $checks[] = isset($event['UID']);
+ $checks[] = isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
+
+ if ((bool) array_product($checks)) {
+ $eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]);
+
+ // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
+ if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']], true)) !== false) {
+ $eventKeysToRemove[] = $alteredEventKey;
+
+ $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]);
+ $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
+ }
+ }
+
+ unset($checks);
+ }
+
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Processes recurrence rules
+ *
+ * @return void
+ */
+ protected function processRecurrences()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ // If there are no events, then we have nothing to process.
+ if ($events === array()) {
+ return;
+ }
+
+ $allEventRecurrences = array();
+ $eventKeysToRemove = array();
+
+ foreach ($events as $key => $anEvent) {
+ if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') {
+ continue;
+ }
+
+ // Tag as generated by a recurrence rule
+ $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;
+
+ // Create new initial starting point.
+ $initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]);
+
+ if ($initialEventDate === false) {
+ trigger_error("ICal::processRecurrences: Invalid date passed ({$anEvent['DTSTART_array'][3]})", E_USER_NOTICE);
+
+ continue;
+ }
+
+ // Separate the RRULE stanzas, and explode the values that are lists.
+ $rrules = array();
+ foreach (array_filter(explode(';', $anEvent['RRULE'])) as $s) {
+ list($k, $v) = explode('=', $s);
+ if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYYEARDAY', 'BYWEEKNO'))) {
+ $rrules[$k] = explode(',', $v);
+ } else {
+ $rrules[$k] = $v;
+ }
+ }
+
+ $frequency = $rrules['FREQ'];
+
+ if (!is_string($frequency)) {
+ trigger_error('ICal::processRecurrences: Invalid frequency passed', E_USER_NOTICE);
+
+ continue;
+ }
+
+ // Reject RRULE if BYDAY stanza is invalid:
+ // > The BYDAY rule part MUST NOT be specified with a numeric value
+ // > when the FREQ rule part is not set to MONTHLY or YEARLY.
+ // > Furthermore, the BYDAY rule part MUST NOT be specified with a
+ // > numeric value with the FREQ rule part set to YEARLY when the
+ // > BYWEEKNO rule part is specified.
+ if (isset($rrules['BYDAY'])) {
+ $checkByDays = function ($carry, $weekday) {
+ return $carry && substr($weekday, -2) === $weekday;
+ };
+ if (!in_array($frequency, array('MONTHLY', 'YEARLY'))) {
+ if (is_array($rrules['BYDAY']) && !array_reduce($rrules['BYDAY'], $checkByDays, true)) {
+ trigger_error("ICal::processRecurrences: A {$frequency} RRULE may not contain BYDAY values with numeric prefixes", E_USER_NOTICE);
+
+ continue;
+ }
+ } elseif ($frequency === 'YEARLY' && (isset($rrules['BYWEEKNO']) && ($rrules['BYWEEKNO'] !== '' && $rrules['BYWEEKNO'] !== array()))) {
+ if (is_array($rrules['BYDAY']) && !array_reduce($rrules['BYDAY'], $checkByDays, true)) {
+ trigger_error('ICal::processRecurrences: A YEARLY RRULE with a BYWEEKNO part may not contain BYDAY values with numeric prefixes', E_USER_NOTICE);
+
+ continue;
+ }
+ }
+ }
+
+ $interval = (empty($rrules['INTERVAL'])) ? 1 : (int) $rrules['INTERVAL'];
+
+ // Throw an error if this isn't an integer.
+ if (!is_int($this->defaultSpan)) {
+ trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE);
+ }
+
+ // Compute EXDATEs
+ $exdates = $this->parseExdates($anEvent);
+
+ // Determine if the initial date is also an EXDATE
+ $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) {
+ return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp();
+ }, false);
+
+ if ($initialDateIsExdate) {
+ $eventKeysToRemove[] = $key;
+ }
+
+ /**
+ * Determine at what point we should stop calculating recurrences
+ * by looking at the UNTIL or COUNT rrule stanza, or, if neither
+ * if set, using a fallback.
+ *
+ * If the initial date is also an EXDATE, it shouldn't be included
+ * in the count.
+ *
+ * Syntax:
+ * UNTIL={enddate}
+ * COUNT=
+ *
+ * Where:
+ * enddate = ||
+ */
+ $count = 1;
+ $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : PHP_INT_MAX;
+ $now = date_create();
+
+ $until = $now === false
+ ? 0
+ : $now->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp();
+
+ $untilWhile = $until;
+
+ if (isset($rrules['UNTIL']) && is_string($rrules['UNTIL'])) {
+ $untilDT = $this->iCalDateToDateTime($rrules['UNTIL']);
+ $until = min($until, ($untilDT === false) ? $until : $untilDT->getTimestamp());
+
+ // There are certain edge cases where we need to go a little beyond the UNTIL to
+ // ensure we get all events. Consider:
+ //
+ // DTSTART:20200103
+ // RRULE:FREQ=MONTHLY;BYDAY=-5FR;UNTIL=20200502
+ //
+ // In this case the last occurrence should be 1st May, however when we transition
+ // from April to May:
+ //
+ // $until ~= 2nd May
+ // $frequencyRecurringDateTime ~= 3rd May
+ //
+ // And as the latter comes after the former, the while loop ends before any dates
+ // in May have the chance to be considered.
+ $untilWhile = min($untilWhile, ($untilDT === false) ? $untilWhile : $untilDT->modify("+1 {$this->frequencyConversion[$frequency]}")->getTimestamp());
+ }
+
+ $eventRecurrences = array();
+
+ $frequencyRecurringDateTime = clone $initialEventDate;
+ while ($frequencyRecurringDateTime->getTimestamp() <= $untilWhile && $count < $countLimit) {
+ $candidateDateTimes = array();
+
+ // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault
+ switch ($frequency) {
+ case 'DAILY':
+ if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
+ if (!isset($monthDays)) {
+ // This variable is unset when we change months (see below)
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
+ }
+
+ if (!in_array($frequencyRecurringDateTime->format('j'), $monthDays)) {
+ break;
+ }
+ }
+
+ $candidateDateTimes[] = clone $frequencyRecurringDateTime;
+
+ break;
+
+ case 'WEEKLY':
+ $initialDayOfWeek = $frequencyRecurringDateTime->format('N');
+ $matchingDays = array($initialDayOfWeek);
+
+ if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
+ // setISODate() below uses the ISO-8601 specification of weeks: start on
+ // a Monday, end on a Sunday. However, RRULEs (or the caller of the
+ // parser) may state an alternate WeeKSTart.
+ $wkstTransition = 7;
+
+ if (empty($rrules['WKST'])) {
+ if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays), true);
+ }
+ } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays), true);
+ }
+
+ $matchingDays = array_map(
+ function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) {
+ $day = array_search($weekday, array_keys($this->weekdays), true);
+
+ if ($day < $initialDayOfWeek) {
+ $day += 7;
+ }
+
+ if ($day >= $wkstTransition) {
+ $day += 7 * ($interval - 1);
+ }
+
+ // Ignoring alternate week starts, $day at this point will have a
+ // value between 0 and 6. But setISODate() expects a value of 1 to 7.
+ // Even with alternate week starts, we still need to +1 to set the
+ // correct weekday.
+ $day++;
+
+ return $day;
+ },
+ $rrules['BYDAY']
+ );
+ }
+
+ sort($matchingDays);
+
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setISODate(
+ (int) $frequencyRecurringDateTime->format('o'),
+ (int) $frequencyRecurringDateTime->format('W'),
+ (int) $day
+ );
+ }
+ break;
+
+ case 'MONTHLY':
+ $matchingDays = array();
+
+ if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
+ $matchingDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
+ if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
+ $matchingDays = array_filter(
+ $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
+ function ($monthDay) use ($matchingDays) {
+ return in_array($monthDay, $matchingDays);
+ }
+ );
+ }
+ } elseif (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
+ $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
+ } else {
+ $matchingDays[] = $frequencyRecurringDateTime->format('d');
+ }
+
+ if (isset($rrules['BYSETPOS']) && (is_array($rrules['BYSETPOS']) && $rrules['BYSETPOS'] !== array())) {
+ $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
+ }
+
+ foreach ($matchingDays as $day) {
+ // Skip invalid dates (e.g. 30th February)
+ if ($day > $frequencyRecurringDateTime->format('t')) {
+ continue;
+ }
+
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ (int) $frequencyRecurringDateTime->format('Y'),
+ (int) $frequencyRecurringDateTime->format('m'),
+ $day
+ );
+ }
+ break;
+
+ case 'YEARLY':
+ $matchingDays = array();
+
+ if (isset($rrules['BYMONTH']) && (is_array($rrules['BYMONTH']) && $rrules['BYMONTH'] !== array())) {
+ $bymonthRecurringDatetime = clone $frequencyRecurringDateTime;
+ foreach ($rrules['BYMONTH'] as $byMonth) {
+ $bymonthRecurringDatetime->setDate(
+ (int) $frequencyRecurringDateTime->format('Y'),
+ (int) $byMonth,
+ (int) $frequencyRecurringDateTime->format('d')
+ );
+
+ // Determine the days of the month affected
+ // (The interaction between BYMONTHDAY and BYDAY is resolved later.)
+ $monthDays = array();
+ if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $bymonthRecurringDatetime);
+ } elseif (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
+ $monthDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime);
+ } else {
+ $monthDays[] = $bymonthRecurringDatetime->format('d');
+ }
+
+ // And add each of them to the list of recurrences
+ foreach ($monthDays as $day) {
+ $matchingDays[] = $bymonthRecurringDatetime->setDate(
+ (int) $frequencyRecurringDateTime->format('Y'),
+ (int) $bymonthRecurringDatetime->format('m'),
+ $day
+ )->format('z') + 1;
+ }
+ }
+ } elseif (isset($rrules['BYWEEKNO']) && (is_array($rrules['BYWEEKNO']) && $rrules['BYWEEKNO'] !== array())) {
+ $matchingDays = $this->getDaysOfYearMatchingByWeekNoRRule($rrules['BYWEEKNO'], $frequencyRecurringDateTime);
+ } elseif (isset($rrules['BYYEARDAY']) && (is_array($rrules['BYYEARDAY']) && $rrules['BYYEARDAY'] !== array())) {
+ $matchingDays = $this->getDaysOfYearMatchingByYearDayRRule($rrules['BYYEARDAY'], $frequencyRecurringDateTime);
+ } elseif (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
+ $matchingDays = $this->getDaysOfYearMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
+ }
+
+ if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
+ if (isset($rrules['BYYEARDAY']) && ($rrules['BYYEARDAY'] !== '' && $rrules['BYYEARDAY'] !== array()) || isset($rrules['BYMONTHDAY']) && ($rrules['BYMONTHDAY'] !== '' && $rrules['BYMONTHDAY'] !== array()) || isset($rrules['BYWEEKNO']) && ($rrules['BYWEEKNO'] !== '' && $rrules['BYWEEKNO'] !== array())) {
+ $matchingDays = array_filter(
+ $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
+ function ($yearDay) use ($matchingDays) {
+ return in_array($yearDay, $matchingDays);
+ }
+ );
+ } elseif ($matchingDays === array()) {
+ $matchingDays = $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
+ }
+ }
+
+ if ($matchingDays === array()) {
+ $matchingDays = array($frequencyRecurringDateTime->format('z') + 1);
+ } else {
+ sort($matchingDays);
+ }
+
+ if (isset($rrules['BYSETPOS']) && (is_array($rrules['BYSETPOS']) && $rrules['BYSETPOS'] !== array())) {
+ $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
+ }
+
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ (int) $frequencyRecurringDateTime->format('Y'),
+ 1,
+ $day
+ );
+ }
+ break;
+ }
+
+ foreach ($candidateDateTimes as $candidate) {
+ $timestamp = $candidate->getTimestamp();
+ if ($timestamp <= $initialEventDate->getTimestamp()) {
+ continue;
+ }
+
+ if ($timestamp > $until) {
+ break;
+ }
+
+ // Exclusions
+ $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) {
+ return $exdate->getTimestamp() === $timestamp;
+ });
+
+ if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ $isExcluded = true;
+ }
+ }
+
+ if (!$isExcluded) {
+ $eventRecurrences[] = $candidate;
+ $this->eventCount++;
+ }
+
+ // Count all evaluated candidates including excluded ones,
+ // and if RRULE[COUNT] (if set) is reached then break.
+ $count++;
+ if ($count >= $countLimit) {
+ break 2;
+ }
+ }
+
+ // Move forwards $interval $frequency.
+ $monthPreMove = $frequencyRecurringDateTime->format('m');
+ $frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}");
+
+ // As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php,
+ // there are some occasions where adding months doesn't give the month you might
+ // expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap
+ // year.) The following code crudely rectifies this.
+ if ($frequency === 'MONTHLY') {
+ $monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove;
+
+ if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) {
+ $frequencyRecurringDateTime->modify('-1 month');
+ }
+ }
+
+ // $monthDays is set in the DAILY frequency if the BYMONTHDAY stanza is present in
+ // the RRULE. The variable only needs to be updated when we change months, so we
+ // unset it here, prompting a recreation next iteration.
+ if (isset($monthDays) && $frequencyRecurringDateTime->format('m') !== $monthPreMove) {
+ unset($monthDays);
+ }
+ }
+
+ unset($monthDays); // Unset it here as well, so it doesn't bleed into the calculation of the next recurring event.
+
+ // Determine event length
+ $eventLength = 0;
+ if (isset($anEvent['DURATION'])) {
+ $clonedDateTime = clone $initialEventDate;
+ $endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]);
+ $eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2];
+ } elseif (isset($anEvent['DTEND_array'])) {
+ $eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2];
+ }
+
+ // Whether or not the initial date was UTC
+ $initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z';
+
+ // Build the param array
+ $dateParamArray = array();
+ if (
+ !$initialDateWasUTC
+ && isset($anEvent['DTSTART_array'][0]['TZID'])
+ && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])
+ ) {
+ $dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID'];
+ }
+
+ // Populate the `DT{START|END}[_array]`s
+ $eventRecurrences = array_map(
+ function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) {
+ $tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : '';
+
+ foreach (array('DTSTART', 'DTEND') as $dtkey) {
+ $anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : '');
+
+ $anEvent["{$dtkey}_array"] = array(
+ $dateParamArray, // [0] Array of params (incl. TZID)
+ $anEvent[$dtkey], // [1] ICalDateTime string w/o TZID
+ $recurringDatetime->getTimestamp(), // [2] Unix Timestamp
+ "{$tzidPrefix}{$anEvent[$dtkey]}", // [3] Full ICalDateTime string
+ );
+
+ if ($dtkey !== 'DTEND') {
+ $recurringDatetime->modify("{$eventLength} seconds");
+ }
+ }
+
+ return $anEvent;
+ },
+ $eventRecurrences
+ );
+
+ $allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences);
+ }
+
+ // Nullify the initial events that are also EXDATEs
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
+
+ $events = array_merge($events, $allEventRecurrences);
+
+ $this->cal['VEVENT'] = $events;
+ }
+
+ /**
+ * Resolves values from indices of the range 1 -> $limit.
+ *
+ * For instance, if passed [1, 4, -16] and 28, this will return [1, 4, 13].
+ *
+ * @param array $indexes
+ * @param integer $limit
+ * @return array
+ */
+ protected function resolveIndicesOfRange(array $indexes, $limit)
+ {
+ $matching = array();
+ foreach ($indexes as $index) {
+ if ($index > 0 && $index <= $limit) {
+ $matching[] = $index;
+ } elseif ($index < 0 && -$index <= $limit) {
+ $matching[] = $index + $limit + 1;
+ }
+ }
+
+ sort($matching);
+
+ return $matching;
+ }
+
+ /**
+ * Find all days of a month that match the BYDAY stanza of an RRULE.
+ *
+ * With no {ordwk}, then return the day number of every {weekday}
+ * within the month.
+ *
+ * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
+ * month.
+ *
+ * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
+ * within the month.
+ *
+ * RRule Syntax:
+ * BYDAY={bywdaylist}
+ *
+ * Where:
+ * bywdaylist = {weekdaynum}[,{weekdaynum}...]
+ * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
+ * ordwk = 1 to 53
+ * weekday = SU || MO || TU || WE || TH || FR || SA
+ *
+ * @param array $byDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfMonthMatchingByDayRRule(array $byDays, $initialDateTime)
+ {
+ $matchingDays = array();
+ $currentMonth = $initialDateTime->format('n');
+
+ foreach ($byDays as $weekday) {
+ $bydayDateTime = clone $initialDateTime;
+
+ $ordwk = intval(substr($weekday, 0, -2));
+
+ // Quantise the date to the first instance of the requested day in a month
+ // (Or last if we have a -ve {ordwk})
+ $bydayDateTime->modify(
+ (($ordwk < 0) ? 'Last' : 'First') .
+ ' ' .
+ $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
+ ' of ' .
+ $initialDateTime->format('F') // e.g. "June"
+ );
+
+ if ($ordwk < 0) { // -ve {ordwk}
+ $bydayDateTime->modify((++$ordwk) . ' week');
+ if ($bydayDateTime->format('n') === $currentMonth) {
+ $matchingDays[] = $bydayDateTime->format('j');
+ }
+ } elseif ($ordwk > 0) { // +ve {ordwk}
+ $bydayDateTime->modify((--$ordwk) . ' week');
+ if ($bydayDateTime->format('n') === $currentMonth) {
+ $matchingDays[] = $bydayDateTime->format('j');
+ }
+ } else { // No {ordwk}
+ while ($bydayDateTime->format('n') === $initialDateTime->format('n')) {
+ $matchingDays[] = $bydayDateTime->format('j');
+ $bydayDateTime->modify('+1 week');
+ }
+ }
+ }
+
+ // Sort into ascending order
+ sort($matchingDays);
+
+ return $matchingDays;
+ }
+
+ /**
+ * Find all days of a month that match the BYMONTHDAY stanza of an RRULE.
+ *
+ * RRUle Syntax:
+ * BYMONTHDAY={bymodaylist}
+ *
+ * Where:
+ * bymodaylist = {monthdaynum}[,{monthdaynum}...]
+ * monthdaynum = ([+] || -) {ordmoday}
+ * ordmoday = 1 to 31
+ *
+ * @param array $byMonthDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfMonthMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
+ {
+ return $this->resolveIndicesOfRange($byMonthDays, (int) $initialDateTime->format('t'));
+ }
+
+ /**
+ * Find all days of a year that match the BYDAY stanza of an RRULE.
+ *
+ * With no {ordwk}, then return the day number of every {weekday}
+ * within the year.
+ *
+ * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
+ * year.
+ *
+ * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
+ * within the year.
+ *
+ * RRule Syntax:
+ * BYDAY={bywdaylist}
+ *
+ * Where:
+ * bywdaylist = {weekdaynum}[,{weekdaynum}...]
+ * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
+ * ordwk = 1 to 53
+ * weekday = SU || MO || TU || WE || TH || FR || SA
+ *
+ * @param array $byDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByDayRRule(array $byDays, $initialDateTime)
+ {
+ $matchingDays = array();
+
+ foreach ($byDays as $weekday) {
+ $bydayDateTime = clone $initialDateTime;
+
+ $ordwk = intval(substr($weekday, 0, -2));
+
+ // Quantise the date to the first instance of the requested day in a year
+ // (Or last if we have a -ve {ordwk})
+ $bydayDateTime->modify(
+ (($ordwk < 0) ? 'Last' : 'First') .
+ ' ' .
+ $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
+ ' of ' . (($ordwk < 0) ? 'December' : 'January') .
+ ' ' . $initialDateTime->format('Y') // e.g. "2018"
+ );
+
+ if ($ordwk < 0) { // -ve {ordwk}
+ $bydayDateTime->modify((++$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ } elseif ($ordwk > 0) { // +ve {ordwk}
+ $bydayDateTime->modify((--$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ } else { // No {ordwk}
+ while ($bydayDateTime->format('Y') === $initialDateTime->format('Y')) {
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ $bydayDateTime->modify('+1 week');
+ }
+ }
+ }
+
+ // Sort into ascending order
+ sort($matchingDays);
+
+ return $matchingDays;
+ }
+
+ /**
+ * Find all days of a year that match the BYYEARDAY stanza of an RRULE.
+ *
+ * RRUle Syntax:
+ * BYYEARDAY={byyrdaylist}
+ *
+ * Where:
+ * byyrdaylist = {yeardaynum}[,{yeardaynum}...]
+ * yeardaynum = ([+] || -) {ordyrday}
+ * ordyrday = 1 to 366
+ *
+ * @param array $byYearDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByYearDayRRule(array $byYearDays, $initialDateTime)
+ {
+ // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
+ $daysInThisYear = $initialDateTime->format('L') ? 366 : 365;
+
+ return $this->resolveIndicesOfRange($byYearDays, $daysInThisYear);
+ }
+
+ /**
+ * Find all days of a year that match the BYWEEKNO stanza of an RRULE.
+ *
+ * Unfortunately, the RFC5545 specification does not specify exactly
+ * how BYWEEKNO should expand on the initial DTSTART when provided
+ * without any other stanzas.
+ *
+ * A comparison of expansions used by other ics parsers may be found
+ * at https://github.com/s0600204/ics-parser-1/wiki/byweekno
+ *
+ * This method uses the same expansion as the python-dateutil module.
+ *
+ * RRUle Syntax:
+ * BYWEEKNO={bywknolist}
+ *
+ * Where:
+ * bywknolist = {weeknum}[,{weeknum}...]
+ * weeknum = ([+] || -) {ordwk}
+ * ordwk = 1 to 53
+ *
+ * @param array $byWeekNums
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByWeekNoRRule(array $byWeekNums, $initialDateTime)
+ {
+ // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
+ $isLeapYear = $initialDateTime->format('L');
+ $initialYear = date_create("first day of January {$initialDateTime->format('Y')}");
+ $firstDayOfTheYear = ($initialYear === false) ? null : $initialYear->format('D');
+ $weeksInThisYear = ($firstDayOfTheYear === 'Thu' || $isLeapYear && $firstDayOfTheYear === 'Wed') ? 53 : 52;
+
+ $matchingWeeks = $this->resolveIndicesOfRange($byWeekNums, $weeksInThisYear);
+ $matchingDays = array();
+ $byweekDateTime = clone $initialDateTime;
+ foreach ($matchingWeeks as $weekNum) {
+ $dayNum = $byweekDateTime->setISODate(
+ (int) $initialDateTime->format('Y'),
+ $weekNum,
+ 1
+ )->format('z') + 1;
+ for ($x = 0; $x < 7; ++$x) {
+ $matchingDays[] = $x + $dayNum;
+ }
+ }
+
+ sort($matchingDays);
+
+ return $matchingDays;
+ }
+
+ /**
+ * Find all days of a year that match the BYMONTHDAY stanza of an RRULE.
+ *
+ * RRule Syntax:
+ * BYMONTHDAY={bymodaylist}
+ *
+ * Where:
+ * bymodaylist = {monthdaynum}[,{monthdaynum}...]
+ * monthdaynum = ([+] || -) {ordmoday}
+ * ordmoday = 1 to 31
+ *
+ * @param array $byMonthDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
+ {
+ $matchingDays = array();
+ $monthDateTime = clone $initialDateTime;
+ for ($month = 1; $month < 13; $month++) {
+ $monthDateTime->setDate(
+ (int) $initialDateTime->format('Y'),
+ $month,
+ 1
+ );
+
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($byMonthDays, $monthDateTime);
+ foreach ($monthDays as $day) {
+ $matchingDays[] = $monthDateTime->setDate(
+ (int) $initialDateTime->format('Y'),
+ (int) $monthDateTime->format('m'),
+ $day
+ )->format('z') + 1;
+ }
+ }
+
+ return $matchingDays;
+ }
+
+ /**
+ * Filters a provided values-list by applying a BYSETPOS RRule.
+ *
+ * Where a +ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the start of the list of values should be retained.
+ *
+ * Where a -ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the end of the list of values should be retained.
+ *
+ * RRule Syntax:
+ * BYSETPOS={bysplist}
+ *
+ * Where:
+ * bysplist = {setposday}[,{setposday}...]
+ * setposday = {daynum}
+ * daynum = [+ || -] {ordday}
+ * ordday = 1 to 366
+ *
+ * @param array $bySetPos
+ * @param array $valuesList
+ * @return array
+ */
+ protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList)
+ {
+ $filteredMatches = array();
+
+ foreach ($bySetPos as $setPosition) {
+ if ($setPosition < 0) {
+ $setPosition = count($valuesList) + ++$setPosition;
+ }
+
+ // Positioning starts at 1, array indexes start at 0
+ if (isset($valuesList[$setPosition - 1])) {
+ $filteredMatches[] = $valuesList[$setPosition - 1];
+ }
+ }
+
+ return $filteredMatches;
+ }
+
+ /**
+ * Processes date conversions using the time zone
+ *
+ * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
+ * These keys contain dates adapted to the calendar
+ * time zone depending on the event `TZID`.
+ *
+ * @return void
+ * @throws \Exception
+ */
+ protected function processDateConversions()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if ($events !== array()) {
+ foreach ($events as $key => $anEvent) {
+ if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) {
+ unset($events[$key]);
+ $this->eventCount--;
+
+ continue;
+ }
+
+ $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');
+
+ if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
+ } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
+ } else {
+ $events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz'];
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Returns an array of Events.
+ * Every event is a class with the event
+ * details being properties within it.
+ *
+ * @return array
+ */
+ public function events()
+ {
+ $array = $this->cal;
+ $array = isset($array['VEVENT']) ? $array['VEVENT'] : array();
+
+ $events = array();
+
+ foreach ($array as $event) {
+ $events[] = new CoBlocks_ICal_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 string
+ */
+ public function calendarDescription()
+ {
+ return isset($this->cal['VCALENDAR']['X-WR-CALDESC']) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : '';
+ }
+
+ /**
+ * Returns the calendar time zone
+ *
+ * @param boolean $ignoreUtc
+ * @return string|null
+ */
+ public function calendarTimeZone($ignoreUtc = false)
+ {
+ if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
+ $timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
+ } elseif (isset($this->cal['VTIMEZONE']['TZID'])) {
+ $timeZone = $this->cal['VTIMEZONE']['TZID'];
+ } else {
+ $timeZone = $this->defaultTimeZone;
+ }
+
+ // Validate the time zone, falling back to the time zone set in the PHP environment.
+ $timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName();
+
+ if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) {
+ return null;
+ }
+
+ 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'] : array();
+ }
+
+ /**
+ * Returns a boolean value whether the
+ * current calendar has events or not
+ *
+ * @return boolean
+ */
+ public function hasEvents()
+ {
+ return ($this->events() !== array()) ?: false;
+ }
+
+ /**
+ * Returns a sorted array of the events in a given range,
+ * or an empty array 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, then 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 29 Jan 2038.
+ * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
+ *
+ * @param string|null $rangeStart
+ * @param string|null $rangeEnd
+ * @return array
+ * @throws \Exception
+ */
+ public function eventsFromRange($rangeStart = null, $rangeEnd = null)
+ {
+ // Sort events before processing range
+ $events = $this->sortEventsWithOrder($this->events());
+
+ if ($events === array()) {
+ return array();
+ }
+
+ $extendedEvents = array();
+
+ if (!is_null($rangeStart)) {
+ try {
+ $rangeStart = new \DateTime($rangeStart, new \DateTimeZone($this->getDefaultTimeZone()));
+ } catch (\Exception $exception) {
+ error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})");
+ $rangeStart = false;
+ }
+ } else {
+ $rangeStart = new \DateTime('now', new \DateTimeZone($this->getDefaultTimeZone()));
+ }
+
+ if (!is_null($rangeEnd)) {
+ try {
+ $rangeEnd = new \DateTime($rangeEnd, new \DateTimeZone($this->getDefaultTimeZone()));
+ } catch (\Exception $exception) {
+ error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})");
+ $rangeEnd = false;
+ }
+ } else {
+ $rangeEnd = new \DateTime('now', new \DateTimeZone($this->getDefaultTimeZone()));
+ $rangeEnd->modify('+20 years');
+ }
+
+ if ($rangeEnd !== false && $rangeStart !== false) {
+ // 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;
+ }
+ }
+
+ return $extendedEvents;
+ }
+
+ /**
+ * Returns a sorted array of the events following a given string
+ *
+ * @param string $interval
+ * @return array
+ */
+ public function eventsFromInterval($interval)
+ {
+ $timeZone = $this->getDefaultTimeZone();
+ $rangeStart = new \DateTime('now', new \DateTimeZone($timeZone));
+ $rangeEnd = new \DateTime('now', new \DateTimeZone($timeZone));
+
+ $dateInterval = \DateInterval::createFromDateString($interval);
+
+ if ($dateInterval instanceof \DateInterval) {
+ $rangeEnd->add($dateInterval);
+ }
+
+ return $this->eventsFromRange($rangeStart->format('Y-m-d'), $rangeEnd->format('Y-m-d'));
+ }
+
+ /**
+ * Sorts events based on a given sort order
+ *
+ * @param array $events
+ * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
+ * @return array
+ */
+ 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;
+ }
+
+ /**
+ * Checks if a time zone is valid (IANA, CLDR, or Windows)
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ protected function isValidTimeZoneId($timeZone)
+ {
+ return $this->isValidIanaTimeZoneId($timeZone) !== false
+ || $this->isValidCldrTimeZoneId($timeZone) !== false
+ || $this->isValidWindowsTimeZoneId($timeZone) !== false;
+ }
+
+ /**
+ * Checks if a time zone is a valid IANA time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ protected function isValidIanaTimeZoneId($timeZone)
+ {
+ if (in_array($timeZone, $this->validIanaTimeZones)) {
+ return true;
+ }
+
+ $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))) {
+ $this->validIanaTimeZones[] = $timeZone;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if a time zone is a valid CLDR time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ public function isValidCldrTimeZoneId($timeZone)
+ {
+ return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
+ }
+
+ /**
+ * Checks if a time zone is a recognised Windows (non-CLDR) time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ public function isValidWindowsTimeZoneId($timeZone)
+ {
+ return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
+ }
+
+ /**
+ * Parses a duration and applies it to a date
+ *
+ * @param string $date
+ * @param \DateInterval $duration
+ * @return \DateTime|false
+ */
+ protected function parseDuration($date, $duration)
+ {
+ $dateTime = date_create($date);
+
+ if ($dateTime === false) {
+ return false;
+ }
+
+ $dateTime->modify("{$duration->y} year");
+ $dateTime->modify("{$duration->m} month");
+ $dateTime->modify("{$duration->d} day");
+ $dateTime->modify("{$duration->h} hour");
+ $dateTime->modify("{$duration->i} minute");
+ $dateTime->modify("{$duration->s} second");
+
+ return $dateTime;
+ }
+
+ /**
+ * Removes unprintable ASCII and UTF-8 characters
+ *
+ * @param string $data
+ * @return string|null
+ */
+ protected function removeUnprintableChars($data)
+ {
+ return preg_replace('/[\x00-\x1F\x7F]/u', '', $data);
+ }
+
+ /**
+ * Provides a polyfill for PHP 7.2's `mb_chr()`, which is a multibyte safe version of `chr()`.
+ * Multibyte safe.
+ *
+ * @param integer $code
+ * @return string
+ */
+ protected function mb_chr($code) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ if (function_exists('mb_chr')) {
+ return mb_chr($code);
+ } else {
+ if (($code %= 0x200000) < 0x80) {
+ $s = chr($code);
+ } elseif ($code < 0x800) {
+ $s = chr(0xc0 | $code >> 6) . chr(0x80 | $code & 0x3f);
+ } elseif ($code < 0x10000) {
+ $s = chr(0xe0 | $code >> 12) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
+ } else {
+ $s = chr(0xf0 | $code >> 18) . chr(0x80 | $code >> 12 & 0x3f) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
+ }
+
+ return $s;
+ }
+ }
+
+ /**
+ * Places double-quotes around texts that have characters not permitted
+ * in parameter-texts, but are permitted in quoted-texts.
+ *
+ * @param string $candidateText
+ * @return string
+ */
+ protected function escapeParamText($candidateText)
+ {
+ if (strpbrk($candidateText, ':;,') !== false) {
+ return '"' . $candidateText . '"';
+ }
+
+ return $candidateText;
+ }
+
+ /**
+ * Replace curly quotes and other special characters with their standard equivalents
+ * @see https://utf8-chartable.de/unicode-utf8-table.pl?start=8211&utf8=string-literal
+ *
+ * @param string $input
+ * @return string
+ */
+ protected function cleanCharacters($input)
+ {
+ return strtr(
+ $input,
+ 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" => '...', // …
+ $this->mb_chr(145) => "'", // ‘
+ $this->mb_chr(146) => "'", // ’
+ $this->mb_chr(147) => '"', // “
+ $this->mb_chr(148) => '"', // ”
+ $this->mb_chr(150) => '-', // –
+ $this->mb_chr(151) => '--', // —
+ $this->mb_chr(133) => '...', // …
+ )
+ );
+ }
+
+ /**
+ * Parses 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 = new \DateTimeZone($this->getDefaultTimeZone());
+
+ foreach ($exdates as $subArray) {
+ end($subArray);
+ $finalKey = key($subArray);
+
+ foreach (array_keys($subArray) as $key) {
+ if ($key === 'TZID') {
+ $currentTimeZone = $this->timeZoneStringToDateTimeZone($subArray[$key]);
+ } elseif (is_numeric($key)) {
+ $icalDate = $subArray[$key];
+
+ if (substr($icalDate, -1) === 'Z') {
+ $currentTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
+ }
+
+ $output[] = new \DateTime($icalDate, $currentTimeZone);
+
+ if ($key === $finalKey) {
+ // Reset to default
+ $currentTimeZone = new \DateTimeZone($this->getDefaultTimeZone());
+ }
+ }
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Checks if a date string is a valid date
+ *
+ * @param string $value
+ * @return boolean
+ * @throws \Exception
+ */
+ public function isValidDate($value)
+ {
+ if (!$value) {
+ return false;
+ }
+
+ try {
+ new \DateTime($value);
+
+ return true;
+ } catch (\Exception $exception) {
+ return false;
+ }
+ }
+
+ /**
+ * Checks if a filename exists as a file or URL
+ *
+ * @param string $filename
+ * @return boolean
+ */
+ protected function isFileOrUrl($filename)
+ {
+ return (file_exists($filename) || filter_var($filename, FILTER_VALIDATE_URL)) ?: false;
+ }
+
+ /**
+ * Reads an entire file or URL into an array
+ *
+ * @param string $filename
+ * @return array
+ * @throws \Exception
+ */
+ protected function fileOrUrl($filename)
+ {
+ // If this is a URL, let's use wp_safe_remote_get.
+ if ( filter_var( $filename, FILTER_VALIDATE_URL ) ) {
+ $request = wp_safe_remote_get( $filename );
+ if ( is_wp_error( $request ) ) {
+ return false;
+ }
+ return explode( "\n", wp_remote_retrieve_body( $request ) );
+ }
+ }
+
+ /**
+ * Returns a `DateTimeZone` object based on a string containing a time zone name.
+ * Falls back to the default time zone if string passed not a recognised time zone.
+ *
+ * @param string $timeZoneString
+ * @return \DateTimeZone
+ */
+ public function timeZoneStringToDateTimeZone($timeZoneString)
+ {
+ // Some time zones contain characters that are not permitted in param-texts,
+ // but are within quoted texts. We need to remove the quotes as they're not
+ // actually part of the time zone.
+ $timeZoneString = trim($timeZoneString, '"');
+ $timeZoneString = html_entity_decode($timeZoneString);
+
+ if ($this->isValidIanaTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone($timeZoneString);
+ }
+
+ if ($this->isValidCldrTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]);
+ }
+
+ if ($this->isValidWindowsTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]);
+ }
+
+ return new \DateTimeZone($this->getDefaultTimeZone());
+ }
+}
\ No newline at end of file
diff --git a/src/blocks/events/index.php b/src/blocks/events/index.php
index bf7f3a861bd..9f82323c0d5 100644
--- a/src/blocks/events/index.php
+++ b/src/blocks/events/index.php
@@ -39,12 +39,12 @@ function coblocks_render_coblocks_events_block( $attributes, $content ) {
'use_timezone_with_r_rules' => false,
)
);
- $ical->init_url( esc_url_raw( $attributes['externalCalendarUrl'] ) );
+ $ical->initUrl( esc_url_raw( $attributes['externalCalendarUrl'] ) ); // @codingStandardsIgnoreLine.
if ( 'all' === $attributes['eventsRange'] ) {
- $events = $ical->events_from_range();
+ $events = $ical->eventsFromRange(); // @codingStandardsIgnoreLine.
} else {
- $events = $ical->events_from_interval( $attributes['eventsRange'] );
+ $events = $ical->eventsFromInterval( $attributes['eventsRange'] ); // @codingStandardsIgnoreLine.
}
// Limit to 100 events.
$events = array_slice( $events, 0, 100 );
@@ -77,8 +77,8 @@ function coblocks_render_coblocks_events_block( $attributes, $content ) {
foreach ( $events as $event ) {
$events_layout .= '';
- $dtstart = $ical->ical_date_to_date_time( $event->dtstart_array[3] );
- $dtend = $ical->ical_date_to_date_time( $event->dtend_array[3] );
+ $dtstart = $ical->icalDateToDateTime( $event->dtstart_array[3] ); // @codingStandardsIgnoreLine.
+ $dtend = $ical->icalDateToDateTime( $event->dtend_array[3] ); // @codingStandardsIgnoreLine.
$start_date_string = strtotime( $dtstart->format( 'YmdHis' ) );
$end_date_string = strtotime( $dtend->format( 'YmdHis' ) );
$year = gmdate( 'Y', $start_date_string );