2731 lines
104 KiB
PHP
2731 lines
104 KiB
PHP
|
<?php
|
|||
|
|
|||
|
/**
|
|||
|
* This PHP class will read an ICS (`.ics`, `.ical`, `.ifb`) file, parse it and return an
|
|||
|
* array of its contents.
|
|||
|
*
|
|||
|
* PHP 5 (≥ 5.6.40)
|
|||
|
*
|
|||
|
* @author Jonathan Goode <https://github.com/u01jmg3>
|
|||
|
* @license https://opensource.org/licenses/mit-license.php MIT License
|
|||
|
* @version 3.2.0
|
|||
|
*/
|
|||
|
|
|||
|
|
|||
|
class 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;
|
|||
|
|
|||
|
|
|||
|
public $events = array();
|
|||
|
|
|||
|
/**
|
|||
|
* 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;
|
|||
|
|
|||
|
/**
|
|||
|
* With this being non-null the parser will ignore all events more than roughly this many days after now.
|
|||
|
*
|
|||
|
* @var integer|null
|
|||
|
*/
|
|||
|
public $filterDaysBefore;
|
|||
|
|
|||
|
/**
|
|||
|
* With this being non-null the parser will ignore all events more than roughly this many days before now.
|
|||
|
*
|
|||
|
* @var 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())
|
|||
|
{
|
|||
|
$this->events = [];
|
|||
|
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 = is_null($this->filterDaysBefore) ? $php_int_min : (new \DateTime('now'))->sub(new \DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
|
|||
|
$this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new \DateTime('now'))->add(new \DateInterval('P' . $this->filterDaysAfter . 'D'))->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=<positive integer>
|
|||
|
*
|
|||
|
* Where:
|
|||
|
* enddate = <icalDate> || <icalDateTime>
|
|||
|
*/
|
|||
|
$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 \Event($event);
|
|||
|
}
|
|||
|
|
|||
|
$this->events = $events;
|
|||
|
}
|
|||
|
|
|||
|
public function filterByDate(string $dateString) {
|
|||
|
$dateArray = explode('.', $dateString);
|
|||
|
$dateStart = mktime(0,0,0,$dateArray[1],$dateArray[0],$dateArray[2]);
|
|||
|
$tomorrow = $dateStart + 86400;
|
|||
|
|
|||
|
$filteredEvents = [];
|
|||
|
foreach ($this->events as $event) {
|
|||
|
if ($event->startInt < $dateStart && $event->endInt > $dateStart) {
|
|||
|
$filteredEvents[] = $event; //Event hat in der Vergangenheit begonnen und endet nach heute Mitternacht
|
|||
|
} elseif ($event->startInt >= $dateStart && $event->endInt <= $tomorrow) {
|
|||
|
$filteredEvents[] = $event; //Event findet genau heute statt
|
|||
|
} elseif ($event->startInt >= $dateStart && $event->startInt <= $tomorrow) {
|
|||
|
$filteredEvents[] = $event; //Event beginnt heute und endet in der Zukunft
|
|||
|
}
|
|||
|
}
|
|||
|
return $filteredEvents;
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
/**
|
|||
|
* 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\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
|
|||
|
* @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)
|
|||
|
{
|
|||
|
$options = array();
|
|||
|
$options['http'] = array();
|
|||
|
$options['http']['header'] = array();
|
|||
|
|
|||
|
if ($this->httpBasicAuth === array() || !empty($this->httpUserAgent) || !empty($this->httpAcceptLanguage)) {
|
|||
|
if ($this->httpBasicAuth !== array()) {
|
|||
|
$username = $this->httpBasicAuth['username'];
|
|||
|
$password = $this->httpBasicAuth['password'];
|
|||
|
$basicAuth = base64_encode("{$username}:{$password}");
|
|||
|
|
|||
|
$options['http']['header'][] = "Authorization: Basic {$basicAuth}";
|
|||
|
}
|
|||
|
|
|||
|
if (!empty($this->httpUserAgent)) {
|
|||
|
$options['http']['header'][] = "User-Agent: {$this->httpUserAgent}";
|
|||
|
}
|
|||
|
|
|||
|
if (!empty($this->httpAcceptLanguage)) {
|
|||
|
$options['http']['header'][] = "Accept-language: {$this->httpAcceptLanguage}";
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (empty($this->httpUserAgent)) {
|
|||
|
if (mb_stripos($filename, 'outlook.office365.com') !== false) {
|
|||
|
$options['http']['header'][] = 'User-Agent: A User Agent';
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (!empty($this->httpProtocolVersion)) {
|
|||
|
$options['http']['protocol_version'] = $this->httpProtocolVersion;
|
|||
|
} else {
|
|||
|
$options['http']['protocol_version'] = '1.1';
|
|||
|
}
|
|||
|
|
|||
|
$options['http']['header'][] = 'Connection: close';
|
|||
|
|
|||
|
$context = stream_context_create($options);
|
|||
|
|
|||
|
if (($lines = @file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES, $context)) === false) {
|
|||
|
# throw new \Exception("The file path or URL '{$filename}' does not exist.");
|
|||
|
}
|
|||
|
if (false === $lines) {
|
|||
|
return [];
|
|||
|
}
|
|||
|
return $lines;
|
|||
|
|
|||
|
// phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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());
|
|||
|
}
|
|||
|
}
|