PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 1.7.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v1.7.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / vendor / wpfluent / caldav / src / ICal / ICal.php

ICal.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 1.7.0, at vendor/wpfluent/caldav/src/ICal/ICal.php

2,753 lines 104.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * This PHP class will read an ICS (`.ics`, `.ical`, `.ifb`) file, parse it and return an
5 * array of its contents.
6 *
7 * PHP 5 (≥ 5.6.40)
8 *
9 * @author Jonathan Goode <https://github.com/u01jmg3>
10 * @license https://opensource.org/licenses/mit-license.php MIT License
11 * @version 3.2.0
12 */
13
14 namespace FluentBooking\Package\CalDav\ICal;
15
16 class ICal
17 {
18 // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
19
20 const DATE_TIME_FORMAT = 'Ymd\THis';
21 const DATE_TIME_FORMAT_PRETTY = 'F Y H:i:s';
22 const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
23 const ISO_8601_WEEK_START = 'MO';
24 const RECURRENCE_EVENT = 'Generated recurrence event';
25 const SECONDS_IN_A_WEEK = 604800;
26 const TIME_FORMAT = 'His';
27 const TIME_ZONE_UTC = 'UTC';
28 const UNIX_FORMAT = 'U';
29 const UNIX_MIN_YEAR = 1970;
30
31 /**
32 * Tracks the number of alarms in the current iCal feed
33 *
34 * @var integer
35 */
36 public $alarmCount = 0;
37
38 /**
39 * Tracks the number of events in the current iCal feed
40 *
41 * @var integer
42 */
43 public $eventCount = 0;
44
45 /**
46 * Tracks the free/busy count in the current iCal feed
47 *
48 * @var integer
49 */
50 public $freeBusyCount = 0;
51
52 /**
53 * Tracks the number of todos in the current iCal feed
54 *
55 * @var integer
56 */
57 public $todoCount = 0;
58
59 /**
60 * Tracks the number of journals in the current iCal feed
61 *
62 * @var integer
63 */
64 public $journalCount = 0;
65
66 /**
67 * The value in years to use for indefinite, recurring events
68 *
69 * @var integer
70 */
71 public $defaultSpan = 2;
72
73 /**
74 * Enables customisation of the default time zone
75 *
76 * @var string|null
77 */
78 public $defaultTimeZone;
79
80 /**
81 * The two letter representation of the first day of the week
82 *
83 * @var string
84 */
85 public $defaultWeekStart = self::ISO_8601_WEEK_START;
86
87 /**
88 * Toggles whether to skip the parsing of recurrence rules
89 *
90 * @var boolean
91 */
92 public $skipRecurrence = true;
93
94 /**
95 * Toggles whether to disable all character replacement.
96 *
97 * @var boolean
98 */
99 public $disableCharacterReplacement = false;
100
101 /**
102 * With this being non-null the parser will ignore all events more than roughly this many days after now.
103 *
104 * @var integer|null
105 */
106 public $filterDaysBefore;
107
108 /**
109 * With this being non-null the parser will ignore all events more than roughly this many days before now.
110 *
111 * @var integer|null
112 */
113 public $filterDaysAfter;
114
115 /**
116 * The parsed calendar
117 *
118 * @var array
119 */
120 public $cal = array();
121
122 /**
123 * Tracks the VFREEBUSY component
124 *
125 * @var integer
126 */
127 protected $freeBusyIndex = 0;
128
129 /**
130 * Variable to track the previous keyword
131 *
132 * @var string
133 */
134 protected $lastKeyword;
135
136 /**
137 * Cache valid IANA time zone IDs to avoid unnecessary lookups
138 *
139 * @var array
140 */
141 protected $validIanaTimeZones = array();
142
143 /**
144 * Event recurrence instances that have been altered
145 *
146 * @var array
147 */
148 protected $alteredRecurrenceInstances = array();
149
150 /**
151 * An associative array containing weekday conversion data
152 *
153 * The order of the days in the array follow the ISO-8601 specification of a week.
154 *
155 * @var array
156 */
157 protected $weekdays = array(
158 'MO' => 'monday',
159 'TU' => 'tuesday',
160 'WE' => 'wednesday',
161 'TH' => 'thursday',
162 'FR' => 'friday',
163 'SA' => 'saturday',
164 'SU' => 'sunday',
165 );
166
167 /**
168 * An associative array containing frequency conversion terms
169 *
170 * @var array
171 */
172 protected $frequencyConversion = array(
173 'DAILY' => 'day',
174 'WEEKLY' => 'week',
175 'MONTHLY' => 'month',
176 'YEARLY' => 'year',
177 );
178
179 /**
180 * Holds the username and password for HTTP basic authentication
181 *
182 * @var array
183 */
184 protected $httpBasicAuth = array();
185
186 /**
187 * Holds the custom User Agent string header
188 *
189 * @var string
190 */
191 protected $httpUserAgent;
192
193 /**
194 * Holds the custom Accept Language string header
195 *
196 * @var string
197 */
198 protected $httpAcceptLanguage;
199
200 /**
201 * Holds the custom HTTP Protocol version
202 *
203 * @var string
204 */
205 protected $httpProtocolVersion;
206
207 /**
208 * Define which variables can be configured
209 *
210 * @var array
211 */
212 private static $configurableOptions = array(
213 'defaultSpan',
214 'defaultTimeZone',
215 'defaultWeekStart',
216 'disableCharacterReplacement',
217 'filterDaysAfter',
218 'filterDaysBefore',
219 'httpUserAgent',
220 'skipRecurrence',
221 );
222
223 /**
224 * CLDR time zones mapped to IANA time zones.
225 *
226 * @var array
227 */
228 private static $cldrTimeZonesMap = array(
229 '(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
230 '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
231 '(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
232 '(UTC-09:00) Alaska' => 'America/Anchorage',
233 '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
234 '(UTC-07:00) Arizona' => 'America/Phoenix',
235 '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
236 '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
237 '(UTC-06:00) Central America' => 'America/Guatemala',
238 '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
239 '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
240 '(UTC-06:00) Saskatchewan' => 'America/Regina',
241 '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
242 '(UTC-05:00) Chetumal' => 'America/Cancun',
243 '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
244 '(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
245 '(UTC-04:00) Asuncion' => 'America/Asuncion',
246 '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
247 '(UTC-04:00) Caracas' => 'America/Caracas',
248 '(UTC-04:00) Cuiaba' => 'America/Cuiaba',
249 '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
250 '(UTC-04:00) Santiago' => 'America/Santiago',
251 '(UTC-03:30) Newfoundland' => 'America/St_Johns',
252 '(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
253 '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
254 '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
255 '(UTC-03:00) Greenland' => 'America/Godthab',
256 '(UTC-03:00) Montevideo' => 'America/Montevideo',
257 '(UTC-03:00) Salvador' => 'America/Bahia',
258 '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
259 '(UTC-01:00) Azores' => 'Atlantic/Azores',
260 '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
261 '(UTC) Coordinated Universal Time' => 'Etc/GMT',
262 '(UTC+00:00) Casablanca' => 'Africa/Casablanca',
263 '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
264 '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
265 '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
266 '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
267 '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
268 '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
269 '(UTC+01:00) West Central Africa' => 'Africa/Lagos',
270 '(UTC+02:00) Amman' => 'Asia/Amman',
271 '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
272 '(UTC+02:00) Beirut' => 'Asia/Beirut',
273 '(UTC+02:00) Cairo' => 'Africa/Cairo',
274 '(UTC+02:00) Chisinau' => 'Europe/Chisinau',
275 '(UTC+02:00) Damascus' => 'Asia/Damascus',
276 '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
277 '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
278 '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
279 '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
280 '(UTC+02:00) Tripoli' => 'Africa/Tripoli',
281 '(UTC+02:00) Windhoek' => 'Africa/Windhoek',
282 '(UTC+03:00) Baghdad' => 'Asia/Baghdad',
283 '(UTC+03:00) Istanbul' => 'Europe/Istanbul',
284 '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
285 '(UTC+03:00) Minsk' => 'Europe/Minsk',
286 '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
287 '(UTC+03:00) Nairobi' => 'Africa/Nairobi',
288 '(UTC+03:30) Tehran' => 'Asia/Tehran',
289 '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
290 '(UTC+04:00) Baku' => 'Asia/Baku',
291 '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
292 '(UTC+04:00) Port Louis' => 'Indian/Mauritius',
293 '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
294 '(UTC+04:00) Yerevan' => 'Asia/Yerevan',
295 '(UTC+04:30) Kabul' => 'Asia/Kabul',
296 '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
297 '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
298 '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
299 '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
300 '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
301 '(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
302 '(UTC+06:00) Astana' => 'Asia/Almaty',
303 '(UTC+06:00) Dhaka' => 'Asia/Dhaka',
304 '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
305 '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
306 '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
307 '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
308 '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
309 '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
310 '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
311 '(UTC+08:00) Perth' => 'Australia/Perth',
312 '(UTC+08:00) Taipei' => 'Asia/Taipei',
313 '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
314 '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
315 '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
316 '(UTC+09:00) Seoul' => 'Asia/Seoul',
317 '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
318 '(UTC+09:30) Adelaide' => 'Australia/Adelaide',
319 '(UTC+09:30) Darwin' => 'Australia/Darwin',
320 '(UTC+10:00) Brisbane' => 'Australia/Brisbane',
321 '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
322 '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
323 '(UTC+10:00) Hobart' => 'Australia/Hobart',
324 '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
325 '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
326 '(UTC+11:00) Magadan' => 'Asia/Magadan',
327 '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
328 '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
329 '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
330 '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
331 '(UTC+12:00) Fiji' => 'Pacific/Fiji',
332 "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
333 '(UTC+13:00) Samoa' => 'Pacific/Apia',
334 '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
335 );
336
337 /**
338 * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
339 * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
340 *
341 * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
342 *
343 * @var array
344 */
345 private static $windowsTimeZonesMap = array(
346 'AUS Central Standard Time' => 'Australia/Darwin',
347 'AUS Eastern Standard Time' => 'Australia/Sydney',
348 'Afghanistan Standard Time' => 'Asia/Kabul',
349 'Alaskan Standard Time' => 'America/Anchorage',
350 'Aleutian Standard Time' => 'America/Adak',
351 'Altai Standard Time' => 'Asia/Barnaul',
352 'Arab Standard Time' => 'Asia/Riyadh',
353 'Arabian Standard Time' => 'Asia/Dubai',
354 'Arabic Standard Time' => 'Asia/Baghdad',
355 'Argentina Standard Time' => 'America/Buenos_Aires',
356 'Astrakhan Standard Time' => 'Europe/Astrakhan',
357 'Atlantic Standard Time' => 'America/Halifax',
358 'Aus Central W. Standard Time' => 'Australia/Eucla',
359 'Azerbaijan Standard Time' => 'Asia/Baku',
360 'Azores Standard Time' => 'Atlantic/Azores',
361 'Bahia Standard Time' => 'America/Bahia',
362 'Bangladesh Standard Time' => 'Asia/Dhaka',
363 'Belarus Standard Time' => 'Europe/Minsk',
364 'Bougainville Standard Time' => 'Pacific/Bougainville',
365 'Canada Central Standard Time' => 'America/Regina',
366 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
367 'Caucasus Standard Time' => 'Asia/Yerevan',
368 'Cen. Australia Standard Time' => 'Australia/Adelaide',
369 'Central America Standard Time' => 'America/Guatemala',
370 'Central Asia Standard Time' => 'Asia/Almaty',
371 'Central Brazilian Standard Time' => 'America/Cuiaba',
372 'Central Europe Standard Time' => 'Europe/Budapest',
373 'Central European Standard Time' => 'Europe/Warsaw',
374 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
375 'Central Standard Time (Mexico)' => 'America/Mexico_City',
376 'Central Standard Time' => 'America/Chicago',
377 'Chatham Islands Standard Time' => 'Pacific/Chatham',
378 'China Standard Time' => 'Asia/Shanghai',
379 'Cuba Standard Time' => 'America/Havana',
380 'Dateline Standard Time' => 'Etc/GMT+12',
381 'E. Africa Standard Time' => 'Africa/Nairobi',
382 'E. Australia Standard Time' => 'Australia/Brisbane',
383 'E. Europe Standard Time' => 'Europe/Chisinau',
384 'E. South America Standard Time' => 'America/Sao_Paulo',
385 'Easter Island Standard Time' => 'Pacific/Easter',
386 'Eastern Standard Time (Mexico)' => 'America/Cancun',
387 'Eastern Standard Time' => 'America/New_York',
388 'Egypt Standard Time' => 'Africa/Cairo',
389 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
390 'FLE Standard Time' => 'Europe/Kiev',
391 'Fiji Standard Time' => 'Pacific/Fiji',
392 'GMT Standard Time' => 'Europe/London',
393 'GTB Standard Time' => 'Europe/Bucharest',
394 'Georgian Standard Time' => 'Asia/Tbilisi',
395 'Greenland Standard Time' => 'America/Godthab',
396 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
397 'Haiti Standard Time' => 'America/Port-au-Prince',
398 'Hawaiian Standard Time' => 'Pacific/Honolulu',
399 'India Standard Time' => 'Asia/Calcutta',
400 'Iran Standard Time' => 'Asia/Tehran',
401 'Israel Standard Time' => 'Asia/Jerusalem',
402 'Jordan Standard Time' => 'Asia/Amman',
403 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
404 'Korea Standard Time' => 'Asia/Seoul',
405 'Libya Standard Time' => 'Africa/Tripoli',
406 'Line Islands Standard Time' => 'Pacific/Kiritimati',
407 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
408 'Magadan Standard Time' => 'Asia/Magadan',
409 'Magallanes Standard Time' => 'America/Punta_Arenas',
410 'Marquesas Standard Time' => 'Pacific/Marquesas',
411 'Mauritius Standard Time' => 'Indian/Mauritius',
412 'Middle East Standard Time' => 'Asia/Beirut',
413 'Montevideo Standard Time' => 'America/Montevideo',
414 'Morocco Standard Time' => 'Africa/Casablanca',
415 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
416 'Mountain Standard Time' => 'America/Denver',
417 'Myanmar Standard Time' => 'Asia/Rangoon',
418 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
419 'Namibia Standard Time' => 'Africa/Windhoek',
420 'Nepal Standard Time' => 'Asia/Katmandu',
421 'New Zealand Standard Time' => 'Pacific/Auckland',
422 'Newfoundland Standard Time' => 'America/St_Johns',
423 'Norfolk Standard Time' => 'Pacific/Norfolk',
424 'North Asia East Standard Time' => 'Asia/Irkutsk',
425 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
426 'North Korea Standard Time' => 'Asia/Pyongyang',
427 'Omsk Standard Time' => 'Asia/Omsk',
428 'Pacific SA Standard Time' => 'America/Santiago',
429 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
430 'Pacific Standard Time' => 'America/Los_Angeles',
431 'Pakistan Standard Time' => 'Asia/Karachi',
432 'Paraguay Standard Time' => 'America/Asuncion',
433 'Romance Standard Time' => 'Europe/Paris',
434 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
435 'Russia Time Zone 11' => 'Asia/Kamchatka',
436 'Russia Time Zone 3' => 'Europe/Samara',
437 'Russian Standard Time' => 'Europe/Moscow',
438 'SA Eastern Standard Time' => 'America/Cayenne',
439 'SA Pacific Standard Time' => 'America/Bogota',
440 'SA Western Standard Time' => 'America/La_Paz',
441 'SE Asia Standard Time' => 'Asia/Bangkok',
442 'Saint Pierre Standard Time' => 'America/Miquelon',
443 'Sakhalin Standard Time' => 'Asia/Sakhalin',
444 'Samoa Standard Time' => 'Pacific/Apia',
445 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
446 'Saratov Standard Time' => 'Europe/Saratov',
447 'Singapore Standard Time' => 'Asia/Singapore',
448 'South Africa Standard Time' => 'Africa/Johannesburg',
449 'Sri Lanka Standard Time' => 'Asia/Colombo',
450 'Sudan Standard Time' => 'Africa/Tripoli',
451 'Syria Standard Time' => 'Asia/Damascus',
452 'Taipei Standard Time' => 'Asia/Taipei',
453 'Tasmania Standard Time' => 'Australia/Hobart',
454 'Tocantins Standard Time' => 'America/Araguaina',
455 'Tokyo Standard Time' => 'Asia/Tokyo',
456 'Tomsk Standard Time' => 'Asia/Tomsk',
457 'Tonga Standard Time' => 'Pacific/Tongatapu',
458 'Transbaikal Standard Time' => 'Asia/Chita',
459 'Turkey Standard Time' => 'Europe/Istanbul',
460 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
461 'US Eastern Standard Time' => 'America/Indianapolis',
462 'US Mountain Standard Time' => 'America/Phoenix',
463 'UTC' => 'Etc/GMT',
464 'UTC+12' => 'Etc/GMT-12',
465 'UTC+13' => 'Etc/GMT-13',
466 'UTC-02' => 'Etc/GMT+2',
467 'UTC-08' => 'Etc/GMT+8',
468 'UTC-09' => 'Etc/GMT+9',
469 'UTC-11' => 'Etc/GMT+11',
470 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
471 'Venezuela Standard Time' => 'America/Caracas',
472 'Vladivostok Standard Time' => 'Asia/Vladivostok',
473 'W. Australia Standard Time' => 'Australia/Perth',
474 'W. Central Africa Standard Time' => 'Africa/Lagos',
475 'W. Europe Standard Time' => 'Europe/Berlin',
476 'W. Mongolia Standard Time' => 'Asia/Hovd',
477 'West Asia Standard Time' => 'Asia/Tashkent',
478 'West Bank Standard Time' => 'Asia/Hebron',
479 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
480 'Yakutsk Standard Time' => 'Asia/Yakutsk',
481 );
482
483 /**
484 * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
485 * by this field and `$windowMaxTimestamp`.
486 *
487 * @var integer
488 */
489 private $windowMinTimestamp;
490
491 /**
492 * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
493 * by this field and `$windowMinTimestamp`.
494 *
495 * @var integer
496 */
497 private $windowMaxTimestamp;
498
499 /**
500 * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set.
501 *
502 * @var boolean
503 */
504 private $shouldFilterByWindow = false;
505
506 /**
507 * Creates the ICal object
508 *
509 * @param mixed $files
510 * @param array $options
511 * @return void
512 */
513 public function __construct($files = false, array $options = array())
514 {
515 if (\PHP_VERSION_ID < 80100) {
516 ini_set('auto_detect_line_endings', '1');
517 }
518
519 foreach ($options as $option => $value) {
520 if (in_array($option, self::$configurableOptions)) {
521 $this->{$option} = $value;
522 }
523 }
524
525 // Fallback to use the system default time zone
526 if (!isset($this->defaultTimeZone) || !$this->isValidTimeZoneId($this->defaultTimeZone)) {
527 $this->defaultTimeZone = $this->getDefaultTimeZone(true);
528 }
529
530 // Ideally you would use `PHP_INT_MIN` from PHP 7
531 $php_int_min = -2147483648;
532
533 $this->windowMinTimestamp = is_null($this->filterDaysBefore) ? $php_int_min : (new \DateTime('now'))->sub(new \DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
534 $this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new \DateTime('now'))->add(new \DateInterval('P' . $this->filterDaysAfter . 'D'))->getTimestamp();
535
536 $this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter);
537
538 if ($files !== false) {
539 $files = is_array($files) ? $files : array($files);
540
541 foreach ($files as $file) {
542 if (!is_array($file) && $this->isFileOrUrl($file)) {
543 $lines = $this->fileOrUrl($file);
544 } else {
545 $lines = is_array($file) ? $file : array($file);
546 }
547
548 $this->initLines($lines);
549 }
550 }
551 }
552
553 /**
554 * Initialises lines from a string
555 *
556 * @param string $string
557 * @return ICal
558 */
559 public function initString($string)
560 {
561 $string = str_replace(array("\r\n", "\n\r", "\r"), "\n", $string);
562
563 if ($this->cal === array()) {
564 $lines = explode("\n", $string);
565
566 $this->initLines($lines);
567 } else {
568 trigger_error('ICal::initString: Calendar already initialised in constructor', E_USER_NOTICE);
569 }
570
571 return $this;
572 }
573
574 /**
575 * Initialises lines from a file
576 *
577 * @param string $file
578 * @return ICal
579 */
580 public function initFile($file)
581 {
582 if ($this->cal === array()) {
583 $lines = $this->fileOrUrl($file);
584
585 $this->initLines($lines);
586 } else {
587 trigger_error('ICal::initFile: Calendar already initialised in constructor', E_USER_NOTICE);
588 }
589
590 return $this;
591 }
592
593 /**
594 * Initialises lines from a URL
595 *
596 * @param string $url
597 * @param string $username
598 * @param string $password
599 * @param string $userAgent
600 * @param string $acceptLanguage
601 * @param string $httpProtocolVersion
602 * @return ICal
603 */
604 public function initUrl($url, $username = null, $password = null, $userAgent = null, $acceptLanguage = null, $httpProtocolVersion = null)
605 {
606 if (!is_null($username) && !is_null($password)) {
607 $this->httpBasicAuth['username'] = $username;
608 $this->httpBasicAuth['password'] = $password;
609 }
610
611 if (!is_null($userAgent)) {
612 $this->httpUserAgent = $userAgent;
613 }
614
615 if (!is_null($acceptLanguage)) {
616 $this->httpAcceptLanguage = $acceptLanguage;
617 }
618
619 if (!is_null($httpProtocolVersion)) {
620 $this->httpProtocolVersion = $httpProtocolVersion;
621 }
622
623 $this->initFile($url);
624
625 return $this;
626 }
627
628 /**
629 * Initialises the parser using an array
630 * containing each line of iCal content
631 *
632 * @param array $lines
633 * @return void
634 */
635 protected function initLines(array $lines)
636 {
637 $lines = $this->unfold($lines);
638
639 if (stristr($lines[0], 'BEGIN:VCALENDAR') !== false) {
640 $component = '';
641 foreach ($lines as $line) {
642 $line = rtrim($line); // Trim trailing whitespace
643 $line = $this->removeUnprintableChars($line);
644
645 if (empty($line)) {
646 continue;
647 }
648
649 if (!$this->disableCharacterReplacement) {
650 $line = str_replace(array(
651 '&nbsp;',
652 "\t",
653 "\xc2\xa0", // Non-breaking space
654 ), ' ', $line);
655
656 $line = $this->cleanCharacters($line);
657 }
658
659 $add = $this->keyValueFromString($line);
660 $keyword = $add[0];
661 $values = $add[1]; // May be an array containing multiple values
662
663 if (!is_array($values)) {
664 if (!empty($values)) {
665 $values = array($values); // Make an array as not one already
666 $blankArray = array(); // Empty placeholder array
667 $values[] = $blankArray;
668 } else {
669 $values = array(); // Use blank array to ignore this line
670 }
671 } elseif (empty($values[0])) {
672 $values = array(); // Use blank array to ignore this line
673 }
674
675 // Reverse so that our array of properties is processed first
676 $values = array_reverse($values);
677
678 foreach ($values as $value) {
679 switch ($line) {
680 case 'BEGIN:VJOURNAL':
681 if (!is_array($value)) {
682 $this->journalCount++;
683 }
684
685 $component = 'VJOURNAL';
686
687 break;
688
689 // https://www.kanzaki.com/docs/ical/vtodo.html
690 case 'BEGIN:VTODO':
691 if (!is_array($value)) {
692 $this->todoCount++;
693 }
694
695 $component = 'VTODO';
696
697 break;
698
699 case 'BEGIN:VEVENT':
700 // https://www.kanzaki.com/docs/ical/vevent.html
701 if (!is_array($value)) {
702 $this->eventCount++;
703 }
704
705 $component = 'VEVENT';
706
707 break;
708
709 case 'BEGIN:VFREEBUSY':
710 // https://www.kanzaki.com/docs/ical/vfreebusy.html
711 if (!is_array($value)) {
712 $this->freeBusyIndex++;
713 }
714
715 $component = 'VFREEBUSY';
716
717 break;
718
719 case 'BEGIN:VALARM':
720 if (!is_array($value)) {
721 $this->alarmCount++;
722 }
723
724 $component = 'VALARM';
725
726 break;
727
728 case 'END:VALARM':
729 $component = 'VEVENT';
730
731 break;
732
733 case 'BEGIN:DAYLIGHT':
734 case 'BEGIN:STANDARD':
735 case 'BEGIN:VCALENDAR':
736 case 'BEGIN:VTIMEZONE':
737 $component = $value;
738
739 break;
740
741 case 'END:DAYLIGHT':
742 case 'END:STANDARD':
743 case 'END:VCALENDAR':
744 case 'END:VFREEBUSY':
745 case 'END:VTIMEZONE':
746 case 'END:VJOURNAL':
747 case 'END:VTODO':
748 $component = 'VCALENDAR';
749
750 break;
751
752 case 'END:VEVENT':
753 if ($this->shouldFilterByWindow) {
754 $this->removeLastEventIfOutsideWindowAndNonRecurring();
755 }
756
757 $component = 'VCALENDAR';
758
759 break;
760
761 default:
762 $this->addCalendarComponentWithKeyAndValue($component, $keyword, $value);
763
764 break;
765 }
766 }
767 }
768
769 $this->processEvents();
770
771 if (!$this->skipRecurrence) {
772 $this->processRecurrences();
773
774 // Apply changes to altered recurrence instances
775 if ($this->alteredRecurrenceInstances !== array()) {
776 $events = $this->cal['VEVENT'];
777
778 foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) {
779 if (isset($alteredRecurrenceInstance['altered-event'])) {
780 $alteredEvent = $alteredRecurrenceInstance['altered-event'];
781 $key = key($alteredEvent);
782 $events[$key] = $alteredEvent[$key];
783 }
784 }
785
786 $this->cal['VEVENT'] = $events;
787 }
788 }
789
790 if ($this->shouldFilterByWindow) {
791 $this->reduceEventsToMinMaxRange();
792 }
793
794 $this->processDateConversions();
795 }
796 }
797
798 /**
799 * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
800 * `$windowMinTimestamp` / `$windowMaxTimestamp`.
801 *
802 * @return void
803 */
804 protected function removeLastEventIfOutsideWindowAndNonRecurring()
805 {
806 $events = $this->cal['VEVENT'];
807
808 if ($events !== array()) {
809 $lastIndex = count($events) - 1;
810 $lastEvent = $events[$lastIndex];
811
812 if ((!isset($lastEvent['RRULE']) || $lastEvent['RRULE'] === '') && $this->doesEventStartOutsideWindow($lastEvent)) {
813 $this->eventCount--;
814
815 unset($events[$lastIndex]);
816 }
817
818 $this->cal['VEVENT'] = $events;
819 }
820 }
821
822 /**
823 * Reduces the number of events to the defined minimum and maximum range
824 *
825 * @return void
826 */
827 protected function reduceEventsToMinMaxRange()
828 {
829 $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
830
831 if ($events !== array()) {
832 foreach ($events as $key => $anEvent) {
833 if ($anEvent === null) {
834 unset($events[$key]);
835
836 continue;
837 }
838
839 if ($this->doesEventStartOutsideWindow($anEvent)) {
840 $this->eventCount--;
841
842 unset($events[$key]);
843
844 continue;
845 }
846 }
847
848 $this->cal['VEVENT'] = $events;
849 }
850 }
851
852 /**
853 * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`.
854 * Returns `true` for invalid dates.
855 *
856 * @param array $event
857 * @return boolean
858 */
859 protected function doesEventStartOutsideWindow(array $event)
860 {
861 return !$this->isValidDate($event['DTSTART']) || $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
862 }
863
864 /**
865 * Determines whether a valid iCalendar date is within a given range
866 *
867 * @param string $calendarDate
868 * @param integer $minTimestamp
869 * @param integer $maxTimestamp
870 * @return boolean
871 */
872 protected function isOutOfRange($calendarDate, $minTimestamp, $maxTimestamp)
873 {
874 $timestamp = strtotime(explode('T', $calendarDate)[0]);
875
876 return $timestamp < $minTimestamp || $timestamp > $maxTimestamp;
877 }
878
879 /**
880 * Unfolds an iCal file in preparation for parsing
881 * (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html)
882 *
883 * @param array $lines
884 * @return array
885 */
886 protected function unfold(array $lines)
887 {
888 $string = implode(PHP_EOL, $lines);
889 $string = str_ireplace('&nbsp;', ' ', $string);
890
891 $cleanedString = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string);
892
893 $lines = explode(PHP_EOL, $cleanedString ?: $string);
894
895 return $lines;
896 }
897
898 /**
899 * Add one key and value pair to the `$this->cal` array
900 *
901 * @param string $component
902 * @param string|boolean $keyword
903 * @param string|array $value
904 * @return void
905 */
906 protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value)
907 {
908 if ($keyword === false) {
909 $keyword = $this->lastKeyword;
910 }
911
912 switch ($component) {
913 case 'VALARM':
914 $key1 = 'VEVENT';
915 $key2 = ($this->eventCount - 1);
916 $key3 = $component;
917
918 if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) {
919 $this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array();
920 }
921
922 if (is_array($value)) {
923 // Add array of properties to the end
924 $this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
925 } else {
926 if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
927 $this->cal[$key1][$key2][$key3][$keyword] = $value;
928 }
929
930 if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) {
931 $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value;
932 }
933 }
934 break;
935
936 case 'VEVENT':
937 $key1 = $component;
938 $key2 = ($this->eventCount - 1);
939
940 if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) {
941 $this->cal[$key1][$key2]["{$keyword}_array"] = array();
942 }
943
944 if (is_array($value)) {
945 // Add array of properties to the end
946 $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
947 } else {
948 if (!isset($this->cal[$key1][$key2][$keyword])) {
949 $this->cal[$key1][$key2][$keyword] = $value;
950 }
951
952 if ($keyword === 'EXDATE') {
953 if (trim($value) === $value) {
954 $array = array_filter(explode(',', $value));
955 $this->cal[$key1][$key2]["{$keyword}_array"][] = $array;
956 } else {
957 $value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value));
958 $this->cal[$key1][$key2]["{$keyword}_array"][1] = $value;
959 }
960 } else {
961 $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
962
963 if ($keyword === 'DURATION') {
964 $duration = new \DateInterval($value);
965 $this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
966 }
967 }
968
969 if (!is_array($value) && $this->cal[$key1][$key2][$keyword] !== $value) {
970 $this->cal[$key1][$key2][$keyword] .= ',' . $value;
971 }
972 }
973 break;
974
975 case 'VFREEBUSY':
976 $key1 = $component;
977 $key2 = ($this->freeBusyIndex - 1);
978 $key3 = $keyword;
979
980 if ($keyword === 'FREEBUSY') {
981 if (is_array($value)) {
982 $this->cal[$key1][$key2][$key3][][] = $value;
983 } else {
984 $this->freeBusyCount++;
985
986 end($this->cal[$key1][$key2][$key3]);
987 $key = key($this->cal[$key1][$key2][$key3]);
988
989 $value = explode('/', $value);
990 $this->cal[$key1][$key2][$key3][$key][] = $value;
991 }
992 } else {
993 $this->cal[$key1][$key2][$key3][] = $value;
994 }
995 break;
996
997 case 'VJOURNAL':
998 $this->cal[$component][$this->journalCount - 1][$keyword] = $value;
999
1000 break;
1001
1002 case 'VTODO':
1003 $this->cal[$component][$this->todoCount - 1][$keyword] = $value;
1004
1005 break;
1006
1007 // Note: The following code is not required because
1008 // the default case does the same thing, check default.
1009
1010 // case 'VTIMEZONE':
1011 // if ($value) {
1012 // $this->cal[$component][$keyword] = $value;
1013 // }
1014
1015 // break;
1016
1017 default:
1018 $this->cal[$component][$keyword] = $value;
1019
1020 break;
1021 }
1022
1023 if (is_string($keyword)) {
1024 $this->lastKeyword = $keyword;
1025 }
1026 }
1027
1028 /**
1029 * Gets the key value pair from an iCal string
1030 *
1031 * @param string $text
1032 * @return array
1033 */
1034 public function keyValueFromString($text)
1035 {
1036 $splitLine = $this->parseLine($text);
1037 $object = array();
1038 $paramObj = array();
1039 $valueObj = '';
1040 $i = 0;
1041
1042 while ($i < count($splitLine)) {
1043 // The first token corresponds to the property name
1044 if ($i === 0) {
1045 $object[0] = $splitLine[$i];
1046 $i++;
1047
1048 continue;
1049 }
1050
1051 // After each semicolon define the property parameters
1052 if ($splitLine[$i] == ';') {
1053 $i++;
1054 $paramName = $splitLine[$i];
1055 $i += 2;
1056 $paramValue = array();
1057 $multiValue = false;
1058 // A parameter can have multiple values separated by a comma
1059 while ($i + 1 < count($splitLine) && $splitLine[$i + 1] === ',') {
1060 $paramValue[] = $splitLine[$i];
1061 $i += 2;
1062 $multiValue = true;
1063 }
1064
1065 if ($multiValue) {
1066 $paramValue[] = $splitLine[$i];
1067 } else {
1068 $paramValue = $splitLine[$i];
1069 }
1070
1071 // Create object with paramName => paramValue
1072 $paramObj[$paramName] = $paramValue;
1073 }
1074
1075 // After a colon all tokens are concatenated (non-standard behaviour because the property can have multiple values
1076 // according to RFC5545)
1077 if ($splitLine[$i] === ':') {
1078 $i++;
1079 while ($i < count($splitLine)) {
1080 $valueObj .= $splitLine[$i];
1081 $i++;
1082 }
1083 }
1084
1085 $i++;
1086 }
1087
1088 // Object construction
1089 if ($paramObj !== array()) {
1090 $object[1][0] = $valueObj;
1091 $object[1][1] = $paramObj;
1092 } else {
1093 $object[1] = $valueObj;
1094 }
1095
1096 return $object;
1097 }
1098
1099 /**
1100 * Parses a line from an iCal file into an array of tokens
1101 *
1102 * @param string $line
1103 * @return array
1104 */
1105 protected function parseLine($line)
1106 {
1107 $words = array();
1108 $word = '';
1109 // The use of str_split is not a problem here even if the character set is in utf8
1110 // Indeed we only compare the characters , ; : = " which are on a single byte
1111 $arrayOfChar = str_split($line);
1112 $inDoubleQuotes = false;
1113
1114 foreach ($arrayOfChar as $char) {
1115 // Don't stop the word on ; , : = if it is enclosed in double quotes
1116 if ($char === '"') {
1117 if ($word !== '') {
1118 $words[] = $word;
1119 }
1120
1121 $word = '';
1122 $inDoubleQuotes = !$inDoubleQuotes;
1123 } elseif (!in_array($char, array(';', ':', ',', '=')) || $inDoubleQuotes) {
1124 $word .= $char;
1125 } else {
1126 if ($word !== '') {
1127 $words[] = $word;
1128 }
1129
1130 $words[] = $char;
1131 $word = '';
1132 }
1133 }
1134
1135 $words[] = $word;
1136
1137 return $words;
1138 }
1139
1140 /**
1141 * Returns the default time zone if set.
1142 * Falls back to the system default if not set.
1143 *
1144 * @param boolean $forceReturnSystemDefault
1145 * @return string
1146 */
1147 private function getDefaultTimeZone($forceReturnSystemDefault = false)
1148 {
1149 $systemDefault = date_default_timezone_get();
1150
1151 if ($forceReturnSystemDefault) {
1152 return $systemDefault;
1153 }
1154
1155 return $this->defaultTimeZone ?: $systemDefault;
1156 }
1157
1158 /**
1159 * Returns a `DateTime` object from an iCal date time format
1160 *
1161 * @param string $icalDate
1162 * @return \DateTime|false
1163 * @throws \Exception
1164 */
1165 public function iCalDateToDateTime($icalDate)
1166 {
1167 /**
1168 * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
1169 *
1170 * UTC: Has a trailing 'Z'
1171 * Floating: No time zone reference specified, no trailing 'Z', use local time
1172 * TZID: Set time zone as specified
1173 *
1174 * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
1175 * Must have a local time zone set to process floating times.
1176 */
1177 $pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone
1178 $pattern .= ':?'; // Time zone delimiter
1179 $pattern .= '([0-9]{8})'; // [2]: YYYYMMDD
1180 $pattern .= 'T?'; // Time delimiter
1181 $pattern .= '(?(?<=T)([0-9]{6}))'; // [3]: HHMMSS (filled if delimiter present)
1182 $pattern .= '(Z?)/'; // [4]: UTC flag
1183
1184 preg_match($pattern, $icalDate, $date);
1185
1186 if ($date === array()) {
1187 throw new \Exception('Invalid iCal date format.');
1188 }
1189
1190 // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
1191 // PHP, on the other hand, uses negative numbers for that. Thus we don't
1192 // need to special case them.
1193
1194 if ($date[4] === 'Z') {
1195 $dateTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
1196 } elseif (isset($date[1]) && $date[1] !== '') {
1197 $dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]);
1198 } else {
1199 $dateTimeZone = new \DateTimeZone($this->getDefaultTimeZone());
1200 }
1201
1202 // The exclamation mark at the start of the format string indicates that if a
1203 // time portion is not included, the time in the returned DateTime should be
1204 // set to 00:00:00. Without it, the time would be set to the current system time.
1205 $dateFormat = '!Ymd';
1206 $dateBasic = $date[2];
1207 if (isset($date[3]) && $date[3] !== '') {
1208 $dateBasic .= "T{$date[3]}";
1209 $dateFormat .= '\THis';
1210 }
1211
1212 return \DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone);
1213 }
1214
1215 /**
1216 * Returns a Unix timestamp from an iCal date time format
1217 *
1218 * @param string $icalDate
1219 * @return integer
1220 */
1221 public function iCalDateToUnixTimestamp($icalDate)
1222 {
1223 $iCalDateToDateTime = $this->iCalDateToDateTime($icalDate);
1224
1225 if ($iCalDateToDateTime === false) {
1226 trigger_error("ICal::iCalDateToUnixTimestamp: Invalid date passed ({$icalDate})", E_USER_NOTICE);
1227
1228 return 0;
1229 }
1230
1231 return $iCalDateToDateTime->getTimestamp();
1232 }
1233
1234 /**
1235 * Returns a date adapted to the calendar time zone depending on the event `TZID`
1236 *
1237 * @param array $event
1238 * @param string $key
1239 * @param string|null $format
1240 * @return string|integer|boolean|\DateTime
1241 */
1242 public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT)
1243 {
1244 if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
1245 return false;
1246 }
1247
1248 $dateArray = $event["{$key}_array"];
1249
1250 if ($key === 'DURATION') {
1251 $dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2]);
1252
1253 if ($dateTime instanceof \DateTime === false) {
1254 trigger_error("ICal::iCalDateWithTimeZone: Invalid date passed ({$event['DTSTART']})", E_USER_NOTICE);
1255
1256 return false;
1257 }
1258 } else {
1259 // When constructing from a Unix Timestamp, no time zone needs passing.
1260 $dateTime = new \DateTime("@{$dateArray[2]}");
1261 }
1262
1263 $calendarTimeZone = $this->calendarTimeZone();
1264
1265 if (!is_null($calendarTimeZone)) {
1266 // Set the time zone we wish to use when running `$dateTime->format`.
1267 $dateTime->setTimezone(new \DateTimeZone($calendarTimeZone));
1268 }
1269
1270 if (is_null($format)) {
1271 return $dateTime;
1272 }
1273
1274 return $dateTime->format($format);
1275 }
1276
1277 /**
1278 * Performs admin tasks on all events as read from the iCal file.
1279 * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
1280 * Tracks modified recurrence instances
1281 *
1282 * @return void
1283 */
1284 protected function processEvents()
1285 {
1286 $checks = null;
1287 $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
1288
1289 if ($events !== array()) {
1290 foreach ($events as $key => $anEvent) {
1291 foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
1292 if (isset($anEvent[$type])) {
1293 $date = $anEvent["{$type}_array"][1];
1294
1295 if (isset($anEvent["{$type}_array"][0]['TZID'])) {
1296 $timeZone = $this->escapeParamText(
1297 $anEvent["{$type}_array"][0]['TZID']
1298 );
1299 $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date;
1300 }
1301
1302 $anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date);
1303 $anEvent["{$type}_array"][3] = $date;
1304 }
1305 }
1306
1307 if (isset($anEvent['RECURRENCE-ID'])) {
1308 $uid = $anEvent['UID'];
1309
1310 if (!isset($this->alteredRecurrenceInstances[$uid])) {
1311 $this->alteredRecurrenceInstances[$uid] = array();
1312 }
1313
1314 $recurrenceDateUtc = $this->iCalDateToUnixTimestamp(
1315 $anEvent['RECURRENCE-ID_array'][3]
1316 );
1317
1318 $this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc;
1319 }
1320
1321 $anEvent['timezone'] = $this->calendarTimeZoneFromRemote();
1322
1323 $events[$key] = $anEvent;
1324 }
1325
1326 $eventKeysToRemove = array();
1327
1328 foreach ($events as $key => $event) {
1329 $checks[] = !isset($event['RECURRENCE-ID']);
1330 $checks[] = isset($event['UID']);
1331 $checks[] = isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
1332
1333 if ((bool) array_product($checks)) {
1334 $eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]);
1335
1336 // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
1337 if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']], true)) !== false) {
1338 $eventKeysToRemove[] = $alteredEventKey;
1339
1340 $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]);
1341 $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
1342 }
1343 }
1344
1345 unset($checks);
1346 }
1347
1348 foreach ($eventKeysToRemove as $eventKeyToRemove) {
1349 $events[$eventKeyToRemove] = null;
1350 }
1351
1352 $this->cal['VEVENT'] = $events;
1353 }
1354 }
1355
1356 /**
1357 * Processes recurrence rules
1358 *
1359 * @return void
1360 */
1361 protected function processRecurrences()
1362 {
1363 $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
1364
1365 // If there are no events, then we have nothing to process.
1366 if ($events === array()) {
1367 return;
1368 }
1369
1370 $allEventRecurrences = array();
1371 $eventKeysToRemove = array();
1372
1373 foreach ($events as $key => $anEvent) {
1374 if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') {
1375 continue;
1376 }
1377
1378 // Tag as generated by a recurrence rule
1379 $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;
1380
1381 // Create new initial starting point.
1382 $initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]);
1383
1384 if ($initialEventDate === false) {
1385 trigger_error("ICal::processRecurrences: Invalid date passed ({$anEvent['DTSTART_array'][3]})", E_USER_NOTICE);
1386
1387 continue;
1388 }
1389
1390 // Separate the RRULE stanzas, and explode the values that are lists.
1391 $rrules = array();
1392 foreach (array_filter(explode(';', $anEvent['RRULE'])) as $s) {
1393 list($k, $v) = explode('=', $s);
1394 if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYYEARDAY', 'BYWEEKNO'))) {
1395 $rrules[$k] = explode(',', $v);
1396 } else {
1397 $rrules[$k] = $v;
1398 }
1399 }
1400
1401 $frequency = $rrules['FREQ'];
1402
1403 if (!is_string($frequency)) {
1404 trigger_error('ICal::processRecurrences: Invalid frequency passed', E_USER_NOTICE);
1405
1406 continue;
1407 }
1408
1409 // Reject RRULE if BYDAY stanza is invalid:
1410 // > The BYDAY rule part MUST NOT be specified with a numeric value
1411 // > when the FREQ rule part is not set to MONTHLY or YEARLY.
1412 // > Furthermore, the BYDAY rule part MUST NOT be specified with a
1413 // > numeric value with the FREQ rule part set to YEARLY when the
1414 // > BYWEEKNO rule part is specified.
1415 if (isset($rrules['BYDAY'])) {
1416 $checkByDays = function ($carry, $weekday) {
1417 return $carry && substr($weekday, -2) === $weekday;
1418 };
1419 if (!in_array($frequency, array('MONTHLY', 'YEARLY'))) {
1420 if (is_array($rrules['BYDAY']) && !array_reduce($rrules['BYDAY'], $checkByDays, true)) {
1421 trigger_error("ICal::processRecurrences: A {$frequency} RRULE may not contain BYDAY values with numeric prefixes", E_USER_NOTICE);
1422
1423 continue;
1424 }
1425 } elseif ($frequency === 'YEARLY' && (isset($rrules['BYWEEKNO']) && ($rrules['BYWEEKNO'] !== '' && $rrules['BYWEEKNO'] !== array()))) {
1426 if (is_array($rrules['BYDAY']) && !array_reduce($rrules['BYDAY'], $checkByDays, true)) {
1427 trigger_error('ICal::processRecurrences: A YEARLY RRULE with a BYWEEKNO part may not contain BYDAY values with numeric prefixes', E_USER_NOTICE);
1428
1429 continue;
1430 }
1431 }
1432 }
1433
1434 $interval = (empty($rrules['INTERVAL'])) ? 1 : (int) $rrules['INTERVAL'];
1435
1436 // Throw an error if this isn't an integer.
1437 if (!is_int($this->defaultSpan)) {
1438 trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE);
1439 }
1440
1441 // Compute EXDATEs
1442 $exdates = $this->parseExdates($anEvent);
1443
1444 // Determine if the initial date is also an EXDATE
1445 $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) {
1446 return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp();
1447 }, false);
1448
1449 if ($initialDateIsExdate) {
1450 $eventKeysToRemove[] = $key;
1451 }
1452
1453 /**
1454 * Determine at what point we should stop calculating recurrences
1455 * by looking at the UNTIL or COUNT rrule stanza, or, if neither
1456 * if set, using a fallback.
1457 *
1458 * If the initial date is also an EXDATE, it shouldn't be included
1459 * in the count.
1460 *
1461 * Syntax:
1462 * UNTIL={enddate}
1463 * COUNT=<positive integer>
1464 *
1465 * Where:
1466 * enddate = <icalDate> || <icalDateTime>
1467 */
1468 $count = 1;
1469 $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : PHP_INT_MAX;
1470 $now = date_create();
1471
1472 $until = $now === false
1473 ? 0
1474 : $now->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp();
1475
1476 $untilWhile = $until;
1477
1478 if (isset($rrules['UNTIL']) && is_string($rrules['UNTIL'])) {
1479 $untilDT = $this->iCalDateToDateTime($rrules['UNTIL']);
1480 $until = min($until, ($untilDT === false) ? $until : $untilDT->getTimestamp());
1481
1482 // There are certain edge cases where we need to go a little beyond the UNTIL to
1483 // ensure we get all events. Consider:
1484 //
1485 // DTSTART:20200103
1486 // RRULE:FREQ=MONTHLY;BYDAY=-5FR;UNTIL=20200502
1487 //
1488 // In this case the last occurrence should be 1st May, however when we transition
1489 // from April to May:
1490 //
1491 // $until ~= 2nd May
1492 // $frequencyRecurringDateTime ~= 3rd May
1493 //
1494 // And as the latter comes after the former, the while loop ends before any dates
1495 // in May have the chance to be considered.
1496 $untilWhile = min($untilWhile, ($untilDT === false) ? $untilWhile : $untilDT->modify("+1 {$this->frequencyConversion[$frequency]}")->getTimestamp());
1497 }
1498
1499 $eventRecurrences = array();
1500
1501 $frequencyRecurringDateTime = clone $initialEventDate;
1502 while ($frequencyRecurringDateTime->getTimestamp() <= $untilWhile && $count < $countLimit) {
1503 $candidateDateTimes = array();
1504
1505 // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault
1506 switch ($frequency) {
1507 case 'DAILY':
1508 if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
1509 if (!isset($monthDays)) {
1510 // This variable is unset when we change months (see below)
1511 $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
1512 }
1513
1514 if (!in_array($frequencyRecurringDateTime->format('j'), $monthDays)) {
1515 break;
1516 }
1517 }
1518
1519 $candidateDateTimes[] = clone $frequencyRecurringDateTime;
1520
1521 break;
1522
1523 case 'WEEKLY':
1524 $initialDayOfWeek = $frequencyRecurringDateTime->format('N');
1525 $matchingDays = array($initialDayOfWeek);
1526
1527 if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
1528 // setISODate() below uses the ISO-8601 specification of weeks: start on
1529 // a Monday, end on a Sunday. However, RRULEs (or the caller of the
1530 // parser) may state an alternate WeeKSTart.
1531 $wkstTransition = 7;
1532
1533 if (empty($rrules['WKST'])) {
1534 if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) {
1535 $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays), true);
1536 }
1537 } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) {
1538 $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays), true);
1539 }
1540
1541 $matchingDays = array_map(
1542 function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) {
1543 $day = array_search($weekday, array_keys($this->weekdays), true);
1544
1545 if ($day < $initialDayOfWeek) {
1546 $day += 7;
1547 }
1548
1549 if ($day >= $wkstTransition) {
1550 $day += 7 * ($interval - 1);
1551 }
1552
1553 // Ignoring alternate week starts, $day at this point will have a
1554 // value between 0 and 6. But setISODate() expects a value of 1 to 7.
1555 // Even with alternate week starts, we still need to +1 to set the
1556 // correct weekday.
1557 $day++;
1558
1559 return $day;
1560 },
1561 $rrules['BYDAY']
1562 );
1563 }
1564
1565 sort($matchingDays);
1566
1567 foreach ($matchingDays as $day) {
1568 $clonedDateTime = clone $frequencyRecurringDateTime;
1569 $candidateDateTimes[] = $clonedDateTime->setISODate(
1570 (int) $frequencyRecurringDateTime->format('o'),
1571 (int) $frequencyRecurringDateTime->format('W'),
1572 (int) $day
1573 );
1574 }
1575 break;
1576
1577 case 'MONTHLY':
1578 $matchingDays = array();
1579
1580 if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
1581 $matchingDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
1582 if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
1583 $matchingDays = array_filter(
1584 $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
1585 function ($monthDay) use ($matchingDays) {
1586 return in_array($monthDay, $matchingDays);
1587 }
1588 );
1589 }
1590 } elseif (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
1591 $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
1592 } else {
1593 $matchingDays[] = $frequencyRecurringDateTime->format('d');
1594 }
1595
1596 if (isset($rrules['BYSETPOS']) && (is_array($rrules['BYSETPOS']) && $rrules['BYSETPOS'] !== array())) {
1597 $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
1598 }
1599
1600 foreach ($matchingDays as $day) {
1601 // Skip invalid dates (e.g. 30th February)
1602 if ($day > $frequencyRecurringDateTime->format('t')) {
1603 continue;
1604 }
1605
1606 $clonedDateTime = clone $frequencyRecurringDateTime;
1607 $candidateDateTimes[] = $clonedDateTime->setDate(
1608 (int) $frequencyRecurringDateTime->format('Y'),
1609 (int) $frequencyRecurringDateTime->format('m'),
1610 $day
1611 );
1612 }
1613 break;
1614
1615 case 'YEARLY':
1616 $matchingDays = array();
1617
1618 if (isset($rrules['BYMONTH']) && (is_array($rrules['BYMONTH']) && $rrules['BYMONTH'] !== array())) {
1619 $bymonthRecurringDatetime = clone $frequencyRecurringDateTime;
1620 foreach ($rrules['BYMONTH'] as $byMonth) {
1621 $bymonthRecurringDatetime->setDate(
1622 (int) $frequencyRecurringDateTime->format('Y'),
1623 (int) $byMonth,
1624 (int) $frequencyRecurringDateTime->format('d')
1625 );
1626
1627 // Determine the days of the month affected
1628 // (The interaction between BYMONTHDAY and BYDAY is resolved later.)
1629 $monthDays = array();
1630 if (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
1631 $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $bymonthRecurringDatetime);
1632 } elseif (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
1633 $monthDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime);
1634 } else {
1635 $monthDays[] = $bymonthRecurringDatetime->format('d');
1636 }
1637
1638 // And add each of them to the list of recurrences
1639 foreach ($monthDays as $day) {
1640 $matchingDays[] = $bymonthRecurringDatetime->setDate(
1641 (int) $frequencyRecurringDateTime->format('Y'),
1642 (int) $bymonthRecurringDatetime->format('m'),
1643 $day
1644 )->format('z') + 1;
1645 }
1646 }
1647 } elseif (isset($rrules['BYWEEKNO']) && (is_array($rrules['BYWEEKNO']) && $rrules['BYWEEKNO'] !== array())) {
1648 $matchingDays = $this->getDaysOfYearMatchingByWeekNoRRule($rrules['BYWEEKNO'], $frequencyRecurringDateTime);
1649 } elseif (isset($rrules['BYYEARDAY']) && (is_array($rrules['BYYEARDAY']) && $rrules['BYYEARDAY'] !== array())) {
1650 $matchingDays = $this->getDaysOfYearMatchingByYearDayRRule($rrules['BYYEARDAY'], $frequencyRecurringDateTime);
1651 } elseif (isset($rrules['BYMONTHDAY']) && (is_array($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== array())) {
1652 $matchingDays = $this->getDaysOfYearMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
1653 }
1654
1655 if (isset($rrules['BYDAY']) && (is_array($rrules['BYDAY']) && $rrules['BYDAY'] !== array())) {
1656 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())) {
1657 $matchingDays = array_filter(
1658 $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
1659 function ($yearDay) use ($matchingDays) {
1660 return in_array($yearDay, $matchingDays);
1661 }
1662 );
1663 } elseif ($matchingDays === array()) {
1664 $matchingDays = $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
1665 }
1666 }
1667
1668 if ($matchingDays === array()) {
1669 $matchingDays = array($frequencyRecurringDateTime->format('z') + 1);
1670 } else {
1671 sort($matchingDays);
1672 }
1673
1674 if (isset($rrules['BYSETPOS']) && (is_array($rrules['BYSETPOS']) && $rrules['BYSETPOS'] !== array())) {
1675 $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
1676 }
1677
1678 foreach ($matchingDays as $day) {
1679 $clonedDateTime = clone $frequencyRecurringDateTime;
1680 $candidateDateTimes[] = $clonedDateTime->setDate(
1681 (int) $frequencyRecurringDateTime->format('Y'),
1682 1,
1683 $day
1684 );
1685 }
1686 break;
1687 }
1688
1689 foreach ($candidateDateTimes as $candidate) {
1690 $timestamp = $candidate->getTimestamp();
1691 if ($timestamp <= $initialEventDate->getTimestamp()) {
1692 continue;
1693 }
1694
1695 if ($timestamp > $until) {
1696 break;
1697 }
1698
1699 // Exclusions
1700 $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) {
1701 return $exdate->getTimestamp() === $timestamp;
1702 });
1703
1704 if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
1705 if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
1706 $isExcluded = true;
1707 }
1708 }
1709
1710 if (!$isExcluded) {
1711 $eventRecurrences[] = $candidate;
1712 $this->eventCount++;
1713 }
1714
1715 // Count all evaluated candidates including excluded ones,
1716 // and if RRULE[COUNT] (if set) is reached then break.
1717 $count++;
1718 if ($count >= $countLimit) {
1719 break 2;
1720 }
1721 }
1722
1723 // Move forwards $interval $frequency.
1724 $monthPreMove = $frequencyRecurringDateTime->format('m');
1725 $frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}");
1726
1727 // As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php,
1728 // there are some occasions where adding months doesn't give the month you might
1729 // expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap
1730 // year.) The following code crudely rectifies this.
1731 if ($frequency === 'MONTHLY') {
1732 $monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove;
1733
1734 if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) {
1735 $frequencyRecurringDateTime->modify('-1 month');
1736 }
1737 }
1738
1739 // $monthDays is set in the DAILY frequency if the BYMONTHDAY stanza is present in
1740 // the RRULE. The variable only needs to be updated when we change months, so we
1741 // unset it here, prompting a recreation next iteration.
1742 if (isset($monthDays) && $frequencyRecurringDateTime->format('m') !== $monthPreMove) {
1743 unset($monthDays);
1744 }
1745 }
1746
1747 unset($monthDays); // Unset it here as well, so it doesn't bleed into the calculation of the next recurring event.
1748
1749 // Determine event length
1750 $eventLength = 0;
1751 if (isset($anEvent['DURATION'])) {
1752 $clonedDateTime = clone $initialEventDate;
1753 $endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]);
1754 $eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2];
1755 } elseif (isset($anEvent['DTEND_array'])) {
1756 $eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2];
1757 }
1758
1759 // Whether or not the initial date was UTC
1760 $initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z';
1761
1762 // Build the param array
1763 $dateParamArray = array();
1764 if (
1765 !$initialDateWasUTC
1766 && isset($anEvent['DTSTART_array'][0]['TZID'])
1767 && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])
1768 ) {
1769 $dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID'];
1770 }
1771
1772 // Populate the `DT{START|END}[_array]`s
1773 $eventRecurrences = array_map(
1774 function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) {
1775 $tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : '';
1776
1777 foreach (array('DTSTART', 'DTEND') as $dtkey) {
1778 $anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : '');
1779
1780 $anEvent["{$dtkey}_array"] = array(
1781 $dateParamArray, // [0] Array of params (incl. TZID)
1782 $anEvent[$dtkey], // [1] ICalDateTime string w/o TZID
1783 $recurringDatetime->getTimestamp(), // [2] Unix Timestamp
1784 "{$tzidPrefix}{$anEvent[$dtkey]}", // [3] Full ICalDateTime string
1785 );
1786
1787 if ($dtkey !== 'DTEND') {
1788 $recurringDatetime->modify("{$eventLength} seconds");
1789 }
1790 }
1791
1792 return $anEvent;
1793 },
1794 $eventRecurrences
1795 );
1796
1797 $allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences);
1798 }
1799
1800 // Nullify the initial events that are also EXDATEs
1801 foreach ($eventKeysToRemove as $eventKeyToRemove) {
1802 $events[$eventKeyToRemove] = null;
1803 }
1804
1805 $events = array_merge($events, $allEventRecurrences);
1806
1807 $this->cal['VEVENT'] = $events;
1808 }
1809
1810 /**
1811 * Resolves values from indices of the range 1 -> $limit.
1812 *
1813 * For instance, if passed [1, 4, -16] and 28, this will return [1, 4, 13].
1814 *
1815 * @param array $indexes
1816 * @param integer $limit
1817 * @return array
1818 */
1819 protected function resolveIndicesOfRange(array $indexes, $limit)
1820 {
1821 $matching = array();
1822 foreach ($indexes as $index) {
1823 if ($index > 0 && $index <= $limit) {
1824 $matching[] = $index;
1825 } elseif ($index < 0 && -$index <= $limit) {
1826 $matching[] = $index + $limit + 1;
1827 }
1828 }
1829
1830 sort($matching);
1831
1832 return $matching;
1833 }
1834
1835 /**
1836 * Find all days of a month that match the BYDAY stanza of an RRULE.
1837 *
1838 * With no {ordwk}, then return the day number of every {weekday}
1839 * within the month.
1840 *
1841 * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
1842 * month.
1843 *
1844 * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
1845 * within the month.
1846 *
1847 * RRule Syntax:
1848 * BYDAY={bywdaylist}
1849 *
1850 * Where:
1851 * bywdaylist = {weekdaynum}[,{weekdaynum}...]
1852 * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
1853 * ordwk = 1 to 53
1854 * weekday = SU || MO || TU || WE || TH || FR || SA
1855 *
1856 * @param array $byDays
1857 * @param \DateTime $initialDateTime
1858 * @return array
1859 */
1860 protected function getDaysOfMonthMatchingByDayRRule(array $byDays, $initialDateTime)
1861 {
1862 $matchingDays = array();
1863 $currentMonth = $initialDateTime->format('n');
1864
1865 foreach ($byDays as $weekday) {
1866 $bydayDateTime = clone $initialDateTime;
1867
1868 $ordwk = intval(substr($weekday, 0, -2));
1869
1870 // Quantise the date to the first instance of the requested day in a month
1871 // (Or last if we have a -ve {ordwk})
1872 $bydayDateTime->modify(
1873 (($ordwk < 0) ? 'Last' : 'First') .
1874 ' ' .
1875 $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
1876 ' of ' .
1877 $initialDateTime->format('F') // e.g. "June"
1878 );
1879
1880 if ($ordwk < 0) { // -ve {ordwk}
1881 $bydayDateTime->modify((++$ordwk) . ' week');
1882 if ($bydayDateTime->format('n') === $currentMonth) {
1883 $matchingDays[] = $bydayDateTime->format('j');
1884 }
1885 } elseif ($ordwk > 0) { // +ve {ordwk}
1886 $bydayDateTime->modify((--$ordwk) . ' week');
1887 if ($bydayDateTime->format('n') === $currentMonth) {
1888 $matchingDays[] = $bydayDateTime->format('j');
1889 }
1890 } else { // No {ordwk}
1891 while ($bydayDateTime->format('n') === $initialDateTime->format('n')) {
1892 $matchingDays[] = $bydayDateTime->format('j');
1893 $bydayDateTime->modify('+1 week');
1894 }
1895 }
1896 }
1897
1898 // Sort into ascending order
1899 sort($matchingDays);
1900
1901 return $matchingDays;
1902 }
1903
1904 /**
1905 * Find all days of a month that match the BYMONTHDAY stanza of an RRULE.
1906 *
1907 * RRUle Syntax:
1908 * BYMONTHDAY={bymodaylist}
1909 *
1910 * Where:
1911 * bymodaylist = {monthdaynum}[,{monthdaynum}...]
1912 * monthdaynum = ([+] || -) {ordmoday}
1913 * ordmoday = 1 to 31
1914 *
1915 * @param array $byMonthDays
1916 * @param \DateTime $initialDateTime
1917 * @return array
1918 */
1919 protected function getDaysOfMonthMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
1920 {
1921 return $this->resolveIndicesOfRange($byMonthDays, (int) $initialDateTime->format('t'));
1922 }
1923
1924 /**
1925 * Find all days of a year that match the BYDAY stanza of an RRULE.
1926 *
1927 * With no {ordwk}, then return the day number of every {weekday}
1928 * within the year.
1929 *
1930 * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
1931 * year.
1932 *
1933 * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
1934 * within the year.
1935 *
1936 * RRule Syntax:
1937 * BYDAY={bywdaylist}
1938 *
1939 * Where:
1940 * bywdaylist = {weekdaynum}[,{weekdaynum}...]
1941 * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
1942 * ordwk = 1 to 53
1943 * weekday = SU || MO || TU || WE || TH || FR || SA
1944 *
1945 * @param array $byDays
1946 * @param \DateTime $initialDateTime
1947 * @return array
1948 */
1949 protected function getDaysOfYearMatchingByDayRRule(array $byDays, $initialDateTime)
1950 {
1951 $matchingDays = array();
1952
1953 foreach ($byDays as $weekday) {
1954 $bydayDateTime = clone $initialDateTime;
1955
1956 $ordwk = intval(substr($weekday, 0, -2));
1957
1958 // Quantise the date to the first instance of the requested day in a year
1959 // (Or last if we have a -ve {ordwk})
1960 $bydayDateTime->modify(
1961 (($ordwk < 0) ? 'Last' : 'First') .
1962 ' ' .
1963 $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
1964 ' of ' . (($ordwk < 0) ? 'December' : 'January') .
1965 ' ' . $initialDateTime->format('Y') // e.g. "2018"
1966 );
1967
1968 if ($ordwk < 0) { // -ve {ordwk}
1969 $bydayDateTime->modify((++$ordwk) . ' week');
1970 $matchingDays[] = $bydayDateTime->format('z') + 1;
1971 } elseif ($ordwk > 0) { // +ve {ordwk}
1972 $bydayDateTime->modify((--$ordwk) . ' week');
1973 $matchingDays[] = $bydayDateTime->format('z') + 1;
1974 } else { // No {ordwk}
1975 while ($bydayDateTime->format('Y') === $initialDateTime->format('Y')) {
1976 $matchingDays[] = $bydayDateTime->format('z') + 1;
1977 $bydayDateTime->modify('+1 week');
1978 }
1979 }
1980 }
1981
1982 // Sort into ascending order
1983 sort($matchingDays);
1984
1985 return $matchingDays;
1986 }
1987
1988 /**
1989 * Find all days of a year that match the BYYEARDAY stanza of an RRULE.
1990 *
1991 * RRUle Syntax:
1992 * BYYEARDAY={byyrdaylist}
1993 *
1994 * Where:
1995 * byyrdaylist = {yeardaynum}[,{yeardaynum}...]
1996 * yeardaynum = ([+] || -) {ordyrday}
1997 * ordyrday = 1 to 366
1998 *
1999 * @param array $byYearDays
2000 * @param \DateTime $initialDateTime
2001 * @return array
2002 */
2003 protected function getDaysOfYearMatchingByYearDayRRule(array $byYearDays, $initialDateTime)
2004 {
2005 // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
2006 $daysInThisYear = $initialDateTime->format('L') ? 366 : 365;
2007
2008 return $this->resolveIndicesOfRange($byYearDays, $daysInThisYear);
2009 }
2010
2011 /**
2012 * Find all days of a year that match the BYWEEKNO stanza of an RRULE.
2013 *
2014 * Unfortunately, the RFC5545 specification does not specify exactly
2015 * how BYWEEKNO should expand on the initial DTSTART when provided
2016 * without any other stanzas.
2017 *
2018 * A comparison of expansions used by other ics parsers may be found
2019 * at https://github.com/s0600204/ics-parser-1/wiki/byweekno
2020 *
2021 * This method uses the same expansion as the python-dateutil module.
2022 *
2023 * RRUle Syntax:
2024 * BYWEEKNO={bywknolist}
2025 *
2026 * Where:
2027 * bywknolist = {weeknum}[,{weeknum}...]
2028 * weeknum = ([+] || -) {ordwk}
2029 * ordwk = 1 to 53
2030 *
2031 * @param array $byWeekNums
2032 * @param \DateTime $initialDateTime
2033 * @return array
2034 */
2035 protected function getDaysOfYearMatchingByWeekNoRRule(array $byWeekNums, $initialDateTime)
2036 {
2037 // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
2038 $isLeapYear = $initialDateTime->format('L');
2039 $initialYear = date_create("first day of January {$initialDateTime->format('Y')}");
2040 $firstDayOfTheYear = ($initialYear === false) ? null : $initialYear->format('D');
2041 $weeksInThisYear = ($firstDayOfTheYear === 'Thu' || $isLeapYear && $firstDayOfTheYear === 'Wed') ? 53 : 52;
2042
2043 $matchingWeeks = $this->resolveIndicesOfRange($byWeekNums, $weeksInThisYear);
2044 $matchingDays = array();
2045 $byweekDateTime = clone $initialDateTime;
2046 foreach ($matchingWeeks as $weekNum) {
2047 $dayNum = $byweekDateTime->setISODate(
2048 (int) $initialDateTime->format('Y'),
2049 $weekNum,
2050 1
2051 )->format('z') + 1;
2052 for ($x = 0; $x < 7; ++$x) {
2053 $matchingDays[] = $x + $dayNum;
2054 }
2055 }
2056
2057 sort($matchingDays);
2058
2059 return $matchingDays;
2060 }
2061
2062 /**
2063 * Find all days of a year that match the BYMONTHDAY stanza of an RRULE.
2064 *
2065 * RRule Syntax:
2066 * BYMONTHDAY={bymodaylist}
2067 *
2068 * Where:
2069 * bymodaylist = {monthdaynum}[,{monthdaynum}...]
2070 * monthdaynum = ([+] || -) {ordmoday}
2071 * ordmoday = 1 to 31
2072 *
2073 * @param array $byMonthDays
2074 * @param \DateTime $initialDateTime
2075 * @return array
2076 */
2077 protected function getDaysOfYearMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
2078 {
2079 $matchingDays = array();
2080 $monthDateTime = clone $initialDateTime;
2081 for ($month = 1; $month < 13; $month++) {
2082 $monthDateTime->setDate(
2083 (int) $initialDateTime->format('Y'),
2084 $month,
2085 1
2086 );
2087
2088 $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($byMonthDays, $monthDateTime);
2089 foreach ($monthDays as $day) {
2090 $matchingDays[] = $monthDateTime->setDate(
2091 (int) $initialDateTime->format('Y'),
2092 (int) $monthDateTime->format('m'),
2093 $day
2094 )->format('z') + 1;
2095 }
2096 }
2097
2098 return $matchingDays;
2099 }
2100
2101 /**
2102 * Filters a provided values-list by applying a BYSETPOS RRule.
2103 *
2104 * Where a +ve {daynum} is provided, the {ordday} position'd value as
2105 * measured from the start of the list of values should be retained.
2106 *
2107 * Where a -ve {daynum} is provided, the {ordday} position'd value as
2108 * measured from the end of the list of values should be retained.
2109 *
2110 * RRule Syntax:
2111 * BYSETPOS={bysplist}
2112 *
2113 * Where:
2114 * bysplist = {setposday}[,{setposday}...]
2115 * setposday = {daynum}
2116 * daynum = [+ || -] {ordday}
2117 * ordday = 1 to 366
2118 *
2119 * @param array $bySetPos
2120 * @param array $valuesList
2121 * @return array
2122 */
2123 protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList)
2124 {
2125 $filteredMatches = array();
2126
2127 foreach ($bySetPos as $setPosition) {
2128 if ($setPosition < 0) {
2129 $setPosition = count($valuesList) + ++$setPosition;
2130 }
2131
2132 // Positioning starts at 1, array indexes start at 0
2133 if (isset($valuesList[$setPosition - 1])) {
2134 $filteredMatches[] = $valuesList[$setPosition - 1];
2135 }
2136 }
2137
2138 return $filteredMatches;
2139 }
2140
2141 /**
2142 * Processes date conversions using the time zone
2143 *
2144 * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
2145 * These keys contain dates adapted to the calendar
2146 * time zone depending on the event `TZID`.
2147 *
2148 * @return void
2149 * @throws \Exception
2150 */
2151 protected function processDateConversions()
2152 {
2153 $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
2154
2155 if ($events !== array()) {
2156 foreach ($events as $key => $anEvent) {
2157 if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) {
2158 unset($events[$key]);
2159 $this->eventCount--;
2160
2161 continue;
2162 }
2163
2164 $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');
2165
2166 if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
2167 $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
2168 } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
2169 $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
2170 } else {
2171 $events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz'];
2172 }
2173 }
2174
2175 $this->cal['VEVENT'] = $events;
2176 }
2177 }
2178
2179 /**
2180 * Returns an array of Events.
2181 * Every event is a class with the event
2182 * details being properties within it.
2183 *
2184 * @return array
2185 */
2186 public function events()
2187 {
2188 $array = $this->cal;
2189 $array = isset($array['VEVENT']) ? $array['VEVENT'] : array();
2190
2191 $events = array();
2192
2193 foreach ($array as $event) {
2194 $events[] = new VEvent($event);
2195 }
2196
2197 return $events;
2198 }
2199
2200 /**
2201 * Returns the calendar name
2202 *
2203 * @return string
2204 */
2205 public function calendarName()
2206 {
2207 return isset($this->cal['VCALENDAR']['X-WR-CALNAME']) ? $this->cal['VCALENDAR']['X-WR-CALNAME'] : '';
2208 }
2209
2210 /**
2211 * Returns the calendar description
2212 *
2213 * @return string
2214 */
2215 public function calendarDescription()
2216 {
2217 return isset($this->cal['VCALENDAR']['X-WR-CALDESC']) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : '';
2218 }
2219
2220 /**
2221 * Returns the calendar time zone
2222 *
2223 * @param boolean $ignoreUtc
2224 * @return string|null
2225 */
2226 public function calendarTimeZone($ignoreUtc = false)
2227 {
2228 if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
2229 $timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
2230 } elseif (isset($this->cal['VTIMEZONE']['TZID'])) {
2231 $timeZone = $this->cal['VTIMEZONE']['TZID'];
2232 } else {
2233 $timeZone = $this->defaultTimeZone;
2234 }
2235
2236 // Validate the time zone, falling back to the time zone set in the PHP environment.
2237 $timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName();
2238
2239 if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) {
2240 return null;
2241 }
2242
2243 return $timeZone;
2244 }
2245
2246 /**
2247 * Returns an array of arrays with all free/busy events.
2248 * Every event is an associative array and each property
2249 * is an element it.
2250 *
2251 * @return array
2252 */
2253 public function freeBusyEvents()
2254 {
2255 $array = $this->cal;
2256
2257 return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : array();
2258 }
2259
2260 /**
2261 * Returns a boolean value whether the
2262 * current calendar has events or not
2263 *
2264 * @return boolean
2265 */
2266 public function hasEvents()
2267 {
2268 return ($this->events() !== array()) ?: false;
2269 }
2270
2271 /**
2272 * Returns a sorted array of the events in a given range,
2273 * or an empty array if no events exist in the range.
2274 *
2275 * Events will be returned if the start or end date is contained within the
2276 * range (inclusive), or if the event starts before and end after the range.
2277 *
2278 * If a start date is not specified or of a valid format, then the start
2279 * of the range will default to the current time and date of the server.
2280 *
2281 * If an end date is not specified or of a valid format, then the end of
2282 * the range will default to the current time and date of the server,
2283 * plus 20 years.
2284 *
2285 * Note that this function makes use of Unix timestamps. This might be a
2286 * problem for events on, during, or after 29 Jan 2038.
2287 * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
2288 *
2289 * @param string|null $rangeStart
2290 * @param string|null $rangeEnd
2291 * @return array
2292 * @throws \Exception
2293 */
2294 public function eventsFromRange($rangeStart = null, $rangeEnd = null)
2295 {
2296 // Sort events before processing range
2297 $events = $this->sortEventsWithOrder($this->events());
2298
2299 if ($events === array()) {
2300 return array();
2301 }
2302
2303 $extendedEvents = array();
2304
2305 if (!is_null($rangeStart)) {
2306 try {
2307 $rangeStart = new \DateTime($rangeStart, new \DateTimeZone($this->getDefaultTimeZone()));
2308 } catch (\Exception $exception) {
2309 error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})");
2310 $rangeStart = false;
2311 }
2312 } else {
2313 $rangeStart = new \DateTime('now', new \DateTimeZone($this->getDefaultTimeZone()));
2314 }
2315
2316 if (!is_null($rangeEnd)) {
2317 try {
2318 $rangeEnd = new \DateTime($rangeEnd, new \DateTimeZone($this->getDefaultTimeZone()));
2319 } catch (\Exception $exception) {
2320 error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})");
2321 $rangeEnd = false;
2322 }
2323 } else {
2324 $rangeEnd = new \DateTime('now', new \DateTimeZone($this->getDefaultTimeZone()));
2325 $rangeEnd->modify('+20 years');
2326 }
2327
2328 if ($rangeEnd !== false && $rangeStart !== false) {
2329 // If start and end are identical and are dates with no times...
2330 if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() === $rangeEnd->getTimestamp()) {
2331 $rangeEnd->modify('+1 day');
2332 }
2333
2334 $rangeStart = $rangeStart->getTimestamp();
2335 $rangeEnd = $rangeEnd->getTimestamp();
2336 }
2337
2338 foreach ($events as $anEvent) {
2339 $eventStart = $anEvent->dtstart_array[2];
2340 $eventEnd = (isset($anEvent->dtend_array[2])) ? $anEvent->dtend_array[2] : null;
2341
2342 if (
2343 ($eventStart >= $rangeStart && $eventStart < $rangeEnd) // Event start date contained in the range
2344 || (
2345 $eventEnd !== null
2346 && (
2347 ($eventEnd > $rangeStart && $eventEnd <= $rangeEnd) // Event end date contained in the range
2348 || ($eventStart < $rangeStart && $eventEnd > $rangeEnd) // Event starts before and finishes after range
2349 )
2350 )
2351 ) {
2352 $extendedEvents[] = $anEvent;
2353 }
2354 }
2355
2356 return $extendedEvents;
2357 }
2358
2359 /**
2360 * Returns a sorted array of the events following a given string
2361 *
2362 * @param string $interval
2363 * @return array
2364 */
2365 public function eventsFromInterval($interval)
2366 {
2367 $timeZone = $this->getDefaultTimeZone();
2368 $rangeStart = new \DateTime('now', new \DateTimeZone($timeZone));
2369 $rangeEnd = new \DateTime('now', new \DateTimeZone($timeZone));
2370
2371 $dateInterval = \DateInterval::createFromDateString($interval);
2372
2373 if ($dateInterval instanceof \DateInterval) {
2374 $rangeEnd->add($dateInterval);
2375 }
2376
2377 return $this->eventsFromRange($rangeStart->format('Y-m-d'), $rangeEnd->format('Y-m-d'));
2378 }
2379
2380 /**
2381 * Sorts events based on a given sort order
2382 *
2383 * @param array $events
2384 * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
2385 * @return array
2386 */
2387 public function sortEventsWithOrder(array $events, $sortOrder = SORT_ASC)
2388 {
2389 $extendedEvents = array();
2390 $timestamp = array();
2391
2392 foreach ($events as $key => $anEvent) {
2393 $extendedEvents[] = $anEvent;
2394 $timestamp[$key] = $anEvent->dtstart_array[2];
2395 }
2396
2397 array_multisort($timestamp, $sortOrder, $extendedEvents);
2398
2399 return $extendedEvents;
2400 }
2401
2402 /**
2403 * Checks if a time zone is valid (IANA, CLDR, or Windows)
2404 *
2405 * @param string $timeZone
2406 * @return boolean
2407 */
2408 protected function isValidTimeZoneId($timeZone)
2409 {
2410 return $this->isValidIanaTimeZoneId($timeZone) !== false
2411 || $this->isValidCldrTimeZoneId($timeZone) !== false
2412 || $this->isValidWindowsTimeZoneId($timeZone) !== false;
2413 }
2414
2415 /**
2416 * Checks if a time zone is a valid IANA time zone
2417 *
2418 * @param string $timeZone
2419 * @return boolean
2420 */
2421 protected function isValidIanaTimeZoneId($timeZone)
2422 {
2423 if (in_array($timeZone, $this->validIanaTimeZones)) {
2424 return true;
2425 }
2426
2427 $valid = array();
2428 $tza = timezone_abbreviations_list();
2429
2430 foreach ($tza as $zone) {
2431 foreach ($zone as $item) {
2432 $valid[$item['timezone_id']] = true;
2433 }
2434 }
2435
2436 unset($valid['']);
2437
2438 if (isset($valid[$timeZone]) || in_array($timeZone, timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC))) {
2439 $this->validIanaTimeZones[] = $timeZone;
2440
2441 return true;
2442 }
2443
2444 return false;
2445 }
2446
2447 /**
2448 * Checks if a time zone is a valid CLDR time zone
2449 *
2450 * @param string $timeZone
2451 * @return boolean
2452 */
2453 public function isValidCldrTimeZoneId($timeZone)
2454 {
2455 return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
2456 }
2457
2458 /**
2459 * Checks if a time zone is a recognised Windows (non-CLDR) time zone
2460 *
2461 * @param string $timeZone
2462 * @return boolean
2463 */
2464 public function isValidWindowsTimeZoneId($timeZone)
2465 {
2466 return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
2467 }
2468
2469 /**
2470 * Parses a duration and applies it to a date
2471 *
2472 * @param string $date
2473 * @param \DateInterval $duration
2474 * @return \DateTime|false
2475 */
2476 protected function parseDuration($date, $duration)
2477 {
2478 $dateTime = date_create($date);
2479
2480 if ($dateTime === false) {
2481 return false;
2482 }
2483
2484 $dateTime->modify("{$duration->y} year");
2485 $dateTime->modify("{$duration->m} month");
2486 $dateTime->modify("{$duration->d} day");
2487 $dateTime->modify("{$duration->h} hour");
2488 $dateTime->modify("{$duration->i} minute");
2489 $dateTime->modify("{$duration->s} second");
2490
2491 return $dateTime;
2492 }
2493
2494 /**
2495 * Removes unprintable ASCII and UTF-8 characters
2496 *
2497 * @param string $data
2498 * @return string|null
2499 */
2500 protected function removeUnprintableChars($data)
2501 {
2502 return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data);
2503 }
2504
2505 /**
2506 * Provides a polyfill for PHP 7.2's `mb_chr()`, which is a multibyte safe version of `chr()`.
2507 * Multibyte safe.
2508 *
2509 * @param integer $code
2510 * @return string
2511 */
2512 protected function mb_chr($code) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
2513 {
2514 if (function_exists('mb_chr')) {
2515 return mb_chr($code);
2516 } else {
2517 if (($code %= 0x200000) < 0x80) {
2518 $s = chr($code);
2519 } elseif ($code < 0x800) {
2520 $s = chr(0xc0 | $code >> 6) . chr(0x80 | $code & 0x3f);
2521 } elseif ($code < 0x10000) {
2522 $s = chr(0xe0 | $code >> 12) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
2523 } else {
2524 $s = chr(0xf0 | $code >> 18) . chr(0x80 | $code >> 12 & 0x3f) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
2525 }
2526
2527 return $s;
2528 }
2529 }
2530
2531 /**
2532 * Places double-quotes around texts that have characters not permitted
2533 * in parameter-texts, but are permitted in quoted-texts.
2534 *
2535 * @param string $candidateText
2536 * @return string
2537 */
2538 protected function escapeParamText($candidateText)
2539 {
2540 if (strpbrk($candidateText, ':;,') !== false) {
2541 return '"' . $candidateText . '"';
2542 }
2543
2544 return $candidateText;
2545 }
2546
2547 /**
2548 * Replace curly quotes and other special characters with their standard equivalents
2549 * @see https://utf8-chartable.de/unicode-utf8-table.pl?start=8211&utf8=string-literal
2550 *
2551 * @param string $input
2552 * @return string
2553 */
2554 protected function cleanCharacters($input)
2555 {
2556 return strtr(
2557 $input,
2558 array(
2559 "\xe2\x80\x98" => "'", // ‘
2560 "\xe2\x80\x99" => "'", // ’
2561 "\xe2\x80\x9a" => "'", // ‚
2562 "\xe2\x80\x9b" => "'", // ‛
2563 "\xe2\x80\x9c" => '"', // “
2564 "\xe2\x80\x9d" => '"', // ”
2565 "\xe2\x80\x9e" => '"', // „
2566 "\xe2\x80\x9f" => '"', // ‟
2567 "\xe2\x80\x93" => '-', // –
2568 "\xe2\x80\x94" => '--', // —
2569 "\xe2\x80\xa6" => '...', // …
2570 $this->mb_chr(145) => "'", // ‘
2571 $this->mb_chr(146) => "'", // ’
2572 $this->mb_chr(147) => '"', // “
2573 $this->mb_chr(148) => '"', // ”
2574 $this->mb_chr(150) => '-', // –
2575 $this->mb_chr(151) => '--', // —
2576 $this->mb_chr(133) => '...', // …
2577 )
2578 );
2579 }
2580
2581 /**
2582 * Parses a list of excluded dates
2583 * to be applied to an Event
2584 *
2585 * @param array $event
2586 * @return array
2587 */
2588 public function parseExdates(array $event)
2589 {
2590 if (empty($event['EXDATE_array'])) {
2591 return array();
2592 } else {
2593 $exdates = $event['EXDATE_array'];
2594 }
2595
2596 $output = array();
2597 $currentTimeZone = new \DateTimeZone($this->getDefaultTimeZone());
2598
2599 foreach ($exdates as $subArray) {
2600 end($subArray);
2601 $finalKey = key($subArray);
2602
2603 foreach (array_keys($subArray) as $key) {
2604 if ($key === 'TZID') {
2605 $currentTimeZone = $this->timeZoneStringToDateTimeZone($subArray[$key]);
2606 } elseif (is_numeric($key)) {
2607 $icalDate = $subArray[$key];
2608
2609 if (substr($icalDate, -1) === 'Z') {
2610 $currentTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
2611 }
2612
2613 $output[] = new \DateTime($icalDate, $currentTimeZone);
2614
2615 if ($key === $finalKey) {
2616 // Reset to default
2617 $currentTimeZone = new \DateTimeZone($this->getDefaultTimeZone());
2618 }
2619 }
2620 }
2621 }
2622
2623 return $output;
2624 }
2625
2626 /**
2627 * Checks if a date string is a valid date
2628 *
2629 * @param string $value
2630 * @return boolean
2631 * @throws \Exception
2632 */
2633 public function isValidDate($value)
2634 {
2635 if (!$value) {
2636 return false;
2637 }
2638
2639 try {
2640 new \DateTime($value);
2641
2642 return true;
2643 } catch (\Exception $exception) {
2644 return false;
2645 }
2646 }
2647
2648 /**
2649 * Checks if a filename exists as a file or URL
2650 *
2651 * @param string $filename
2652 * @return boolean
2653 */
2654 protected function isFileOrUrl($filename)
2655 {
2656 return (file_exists($filename) || filter_var($filename, FILTER_VALIDATE_URL)) ?: false;
2657 }
2658
2659 /**
2660 * Reads an entire file or URL into an array
2661 *
2662 * @param string $filename
2663 * @return array
2664 * @throws \Exception
2665 */
2666 protected function fileOrUrl($filename)
2667 {
2668 $options = array();
2669 $options['http'] = array();
2670 $options['http']['header'] = array();
2671
2672 if ($this->httpBasicAuth === array() || !empty($this->httpUserAgent) || !empty($this->httpAcceptLanguage)) {
2673 if ($this->httpBasicAuth !== array()) {
2674 $username = $this->httpBasicAuth['username'];
2675 $password = $this->httpBasicAuth['password'];
2676 $basicAuth = base64_encode("{$username}:{$password}");
2677
2678 $options['http']['header'][] = "Authorization: Basic {$basicAuth}";
2679 }
2680
2681 if (!empty($this->httpUserAgent)) {
2682 $options['http']['header'][] = "User-Agent: {$this->httpUserAgent}";
2683 }
2684
2685 if (!empty($this->httpAcceptLanguage)) {
2686 $options['http']['header'][] = "Accept-language: {$this->httpAcceptLanguage}";
2687 }
2688 }
2689
2690 if (empty($this->httpUserAgent)) {
2691 if (mb_stripos($filename, 'outlook.office365.com') !== false) {
2692 $options['http']['header'][] = 'User-Agent: A User Agent';
2693 }
2694 }
2695
2696 if (!empty($this->httpProtocolVersion)) {
2697 $options['http']['protocol_version'] = $this->httpProtocolVersion;
2698 } else {
2699 $options['http']['protocol_version'] = '1.1';
2700 }
2701
2702 $options['http']['header'][] = 'Connection: close';
2703
2704 $context = stream_context_create($options);
2705
2706 // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
2707 if (($lines = file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES, $context)) === false) {
2708 throw new \Exception("The file path or URL '{$filename}' does not exist.");
2709 }
2710
2711 return $lines;
2712 }
2713
2714 /**
2715 * Returns a `DateTimeZone` object based on a string containing a time zone name.
2716 * Falls back to the default time zone if string passed not a recognised time zone.
2717 *
2718 * @param string $timeZoneString
2719 * @return \DateTimeZone
2720 */
2721 public function timeZoneStringToDateTimeZone($timeZoneString)
2722 {
2723 // Some time zones contain characters that are not permitted in param-texts,
2724 // but are within quoted texts. We need to remove the quotes as they're not
2725 // actually part of the time zone.
2726 $timeZoneString = trim($timeZoneString, '"');
2727 $timeZoneString = html_entity_decode($timeZoneString);
2728
2729 if ($this->isValidIanaTimeZoneId($timeZoneString)) {
2730 return new \DateTimeZone($timeZoneString);
2731 }
2732
2733 if ($this->isValidCldrTimeZoneId($timeZoneString)) {
2734 return new \DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]);
2735 }
2736
2737 if ($this->isValidWindowsTimeZoneId($timeZoneString)) {
2738 return new \DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]);
2739 }
2740
2741 return new \DateTimeZone($this->getDefaultTimeZone());
2742 }
2743
2744 public function calendarTimeZoneFromRemote()
2745 {
2746 if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
2747 return $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
2748 } elseif (isset($this->cal['VTIMEZONE']['TZID'])) {
2749 return $this->cal['VTIMEZONE']['TZID'];
2750 }
2751 }
2752 }
2753