PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / _inc / lib / icalendar-reader.php

icalendar-reader.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at _inc/lib/icalendar-reader.php

1,094 lines 32.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Get and render iCal feeds.
4 * Used by the Upcoming Events widget and the [upcomingevents] shortcode.
5 *
6 * @package automattic/jetpack
7 */
8
9 // phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files.
10
11 /**
12 * Calendar utilities class.
13 *
14 * phpcs:disable PEAR.NamingConventions.ValidClassName.StartWithCapital
15 */
16 class iCalendarReader {
17 // phpcs:enable PEAR.NamingConventions.ValidClassName.StartWithCapital
18 // phpcs:disable WordPress.DateTime.RestrictedFunctions.date_date -- we manually handle timezones all over the file.
19 // @todo Verify that we're manually handling timezones *correctly*. We probably need more `DateTime` with `$this->timezone` and maybe `wp_date()` and less `strtotime()` and `date()` and `date_i18n()`.
20 /**
21 * Count To Do events in calendar.
22 *
23 * @var int
24 */
25 public $todo_count = 0;
26
27 /**
28 * How many events can be found in calendar.
29 *
30 * @var int
31 */
32 public $event_count = 0;
33
34 /**
35 * Details about our calendar.
36 *
37 * @var array
38 */
39 public $cal = array();
40
41 /**
42 * Timezone parsed from the iCalendar feed, if any.
43 *
44 * @var null|DateTimeZone
45 */
46 public $timezone = null;
47
48 /**
49 * Last iCalendar keyword parsed.
50 *
51 * @var string
52 */
53 public $last_keyword;
54
55 /**
56 * Class constructor
57 *
58 * @return void
59 */
60 public function __construct() {}
61
62 /**
63 * Return an array of events
64 *
65 * @param string $url (default: '') URL of the iCal feed.
66 * @param int $count Count the number of events.
67 *
68 * @return array | false on failure
69 */
70 public function get_events( $url = '', $count = 5 ) {
71 $count = (int) $count;
72 $transient_id = 'icalendar_vcal_' . md5( $url ) . '_' . $count;
73
74 $vcal = get_transient( $transient_id );
75 if ( ! empty( $vcal ) ) {
76 if ( isset( $vcal['TIMEZONE'] ) ) {
77 $this->timezone = $this->timezone_from_string( $vcal['TIMEZONE'] );
78 }
79
80 if ( isset( $vcal['VEVENT'] ) ) {
81 $vevent = $vcal['VEVENT'];
82
83 if ( $count > 0 ) {
84 $vevent = array_slice( $vevent, 0, $count );
85 }
86
87 $this->cal['VEVENT'] = $vevent;
88
89 return $this->cal['VEVENT'];
90 }
91 }
92
93 if ( ! $this->parse( $url ) ) {
94 return false;
95 }
96
97 $vcal = array();
98
99 if ( $this->timezone ) {
100 $vcal['TIMEZONE'] = $this->timezone->getName();
101 } else {
102 $this->timezone = $this->timezone_from_string( '' );
103 }
104
105 if ( ! empty( $this->cal['VEVENT'] ) ) {
106 $vevent = $this->cal['VEVENT'];
107
108 // check for recurring events.
109 // $vevent = $this->add_recurring_events( $vevent );.
110
111 // remove before caching - no sense in hanging onto the past.
112 $vevent = $this->filter_past_and_recurring_events( $vevent );
113
114 // order by soonest start date.
115 $vevent = $this->sort_by_recent( $vevent );
116
117 $vcal['VEVENT'] = $vevent;
118 }
119
120 set_transient( $transient_id, $vcal, HOUR_IN_SECONDS );
121
122 if ( ! isset( $vcal['VEVENT'] ) ) {
123 return false;
124 }
125
126 if ( $count > 0 ) {
127 return array_slice( $vcal['VEVENT'], 0, $count );
128 }
129
130 return $vcal['VEVENT'];
131 }
132
133 /**
134 * Adjust event's time based on site's timezone.
135 *
136 * @param array $events Array of events.
137 *
138 * @return array
139 */
140 public function apply_timezone_offset( $events ) {
141 if ( ! $events ) {
142 return $events;
143 }
144
145 // get timezone offset from the timezone name.
146 $timezone = wp_timezone();
147
148 $offsetted_events = array();
149
150 foreach ( $events as $event ) {
151 // Don't handle all-day events.
152 if ( 8 < strlen( $event['DTSTART'] ) ) {
153 $start_time = preg_replace( '/Z$/', '', $event['DTSTART'] );
154 $start_time = new DateTime( $start_time, $this->timezone );
155 $start_time->setTimeZone( $timezone );
156 $event['DTSTART'] = $start_time->format( 'YmdHis\Z' );
157 if ( isset( $event['DTEND'] ) ) {
158 $end_time = preg_replace( '/Z$/', '', $event['DTEND'] );
159 $end_time = new DateTime( $end_time, $this->timezone );
160 $end_time->setTimeZone( $timezone );
161 $event['DTEND'] = $end_time->format( 'YmdHis\Z' );
162 }
163 }
164
165 $offsetted_events[] = $event;
166 }
167
168 return $offsetted_events;
169 }
170
171 /**
172 * Reorganize events into an array of events with standardized data.
173 *
174 * @param array $events Array of events.
175 *
176 * @return array
177 */
178 protected function filter_past_and_recurring_events( $events ) {
179 $upcoming = array();
180 $set_recurring_events = array();
181 /**
182 * This filter allows any time to be passed in for testing or changing timezones, etc...
183 *
184 * @module widgets
185 *
186 * @since 3.4.0
187 *
188 * @param object time() A time object.
189 */
190 $current = apply_filters( 'ical_get_current_time', time() );
191
192 foreach ( $events as $event ) {
193
194 $date_from_ics = strtotime( $event['DTSTART'] );
195 if ( isset( $event['DTEND'] ) ) {
196 $duration = strtotime( $event['DTEND'] ) - strtotime( $event['DTSTART'] );
197 } else {
198 $duration = 0;
199 }
200
201 if ( isset( $event['RRULE'] ) && $this->timezone->getName() && 8 !== strlen( $event['DTSTART'] ) ) {
202 try {
203 $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone( 'UTC' ) );
204 $adjusted_time->setTimeZone( new DateTimeZone( $this->timezone->getName() ) );
205 $event['DTSTART'] = $adjusted_time->format( 'Ymd\THis' );
206 $date_from_ics = strtotime( $event['DTSTART'] );
207
208 $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration );
209 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
210 // Invalid argument to DateTime.
211 }
212
213 if ( isset( $event['EXDATE'] ) ) {
214 $exdates = array();
215 foreach ( (array) $event['EXDATE'] as $exdate ) {
216 try {
217 $adjusted_time = new DateTime( $exdate, new DateTimeZone( 'UTC' ) );
218 $adjusted_time->setTimeZone( new DateTimeZone( $this->timezone->getName() ) );
219 if ( 8 === strlen( $event['DTSTART'] ) ) {
220 $exdates[] = $adjusted_time->format( 'Ymd' );
221 } else {
222 $exdates[] = $adjusted_time->format( 'Ymd\THis' );
223 }
224 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
225 // Invalid argument to DateTime.
226 }
227 }
228 $event['EXDATE'] = $exdates;
229 } else {
230 $event['EXDATE'] = array();
231 }
232 }
233
234 if ( ! isset( $event['DTSTART'] ) ) {
235 continue;
236 }
237
238 // Process events with RRULE before other events.
239 $rrule = $event['RRULE'] ?? false;
240 $uid = $event['UID'] ?? false;
241
242 if ( $rrule && $uid && ! in_array( $uid, $set_recurring_events, true ) ) {
243
244 // Break down the RRULE into digestible chunks.
245 $rrule_array = array();
246
247 foreach ( explode( ';', $event['RRULE'] ) as $rline ) {
248 list( $rkey, $rvalue ) = explode( '=', $rline, 2 );
249 $rrule_array[ $rkey ] = $rvalue;
250 }
251
252 $interval = $rrule_array['INTERVAL'] ?? 1;
253 $rrule_count = $rrule_array['COUNT'] ?? 0;
254 $until = ( isset( $rrule_array['UNTIL'] ) ) ? strtotime( $rrule_array['UNTIL'] ) : strtotime( '+1 year', $current );
255
256 // Used to bound event checks.
257 $echo_limit = 10;
258 $noop = false;
259
260 // Set bydays for the event.
261 $weekdays = array( 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA' );
262 $bydays = $weekdays;
263
264 // Calculate a recent start date for incrementing depending on the frequency and interval.
265 switch ( $rrule_array['FREQ'] ) {
266
267 case 'DAILY':
268 $frequency = 'day';
269 $echo_limit = 10;
270
271 if ( $date_from_ics >= $current ) {
272 $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) );
273 } else {
274 // Interval and count.
275 $catchup = floor( ( $current - strtotime( $event['DTSTART'] ) ) / ( $interval * DAY_IN_SECONDS ) );
276 if ( $rrule_count && $catchup > 0 ) {
277 if ( $catchup < $rrule_count ) {
278 $rrule_count -= $catchup;
279 $recurring_event_date_start = date(
280 'Ymd',
281 strtotime(
282 '+ ' . ( $interval * $catchup ) . ' days',
283 strtotime( $event['DTSTART'] )
284 )
285 ) . date(
286 '\THis',
287 strtotime( $event['DTSTART'] )
288 );
289 } else {
290 $noop = true;
291 }
292 } else {
293 $recurring_event_date_start = date(
294 'Ymd',
295 strtotime(
296 '+ ' . ( $interval * $catchup ) . ' days',
297 strtotime( $event['DTSTART'] )
298 )
299 ) . date(
300 '\THis',
301 strtotime( $event['DTSTART'] )
302 );
303 }
304 }
305 break;
306
307 case 'WEEKLY':
308 $frequency = 'week';
309 $echo_limit = 4;
310
311 // BYDAY exception to current date.
312 $day = false;
313 if ( ! isset( $rrule_array['BYDAY'] ) ) {
314 $rrule_array['BYDAY'] = strtoupper( substr( date( 'D', strtotime( $event['DTSTART'] ) ), 0, 2 ) );
315 $day = $rrule_array['BYDAY'];
316 }
317 $bydays = explode( ',', $rrule_array['BYDAY'] );
318
319 if ( $date_from_ics >= $current ) {
320 $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) );
321 } else {
322 // Interval and count.
323 $catchup = floor( ( $current - strtotime( $event['DTSTART'] ) ) / ( $interval * WEEK_IN_SECONDS ) );
324 if ( $rrule_count && $catchup > 0 ) {
325 if ( ( $catchup * count( $bydays ) ) < $rrule_count ) {
326 $rrule_count -= $catchup * count( $bydays ); // Estimate current event count.
327 $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' weeks', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
328 } else {
329 $noop = true;
330 }
331 } else {
332 $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' weeks', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
333 }
334 }
335
336 // Set to Sunday start.
337 if ( ! $noop && 'SU' !== strtoupper( substr( date( 'D', strtotime( $recurring_event_date_start ) ), 0, 2 ) ) ) {
338 $recurring_event_date_start = date( 'Ymd', strtotime( 'last Sunday', strtotime( $recurring_event_date_start ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
339 }
340 break;
341
342 case 'MONTHLY':
343 $frequency = 'month';
344 $echo_limit = 1;
345
346 if ( $date_from_ics >= $current ) {
347 $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) );
348 } else {
349 // Describe the date in the month.
350 if ( isset( $rrule_array['BYDAY'] )
351 && preg_match( '/^(-?\d)([A-Z]{2})/', $rrule_array['BYDAY'], $matches )
352 ) {
353 $day_number = $matches[1];
354 $week_day = $matches[2];
355
356 $day_cardinals = array(
357 -3 => 'third to last',
358 -2 => 'second to last',
359 -1 => 'last',
360 1 => 'first',
361 2 => 'second',
362 3 => 'third',
363 4 => 'fourth',
364 5 => 'fifth',
365 );
366 $weekdays = array(
367 'SU' => 'Sunday',
368 'MO' => 'Monday',
369 'TU' => 'Tuesday',
370 'WE' => 'Wednesday',
371 'TH' => 'Thursday',
372 'FR' => 'Friday',
373 'SA' => 'Saturday',
374 );
375
376 $day_cardinal = $day_cardinals[ $day_number ] ?? '';
377 $weekday = $weekdays[ $week_day ] ?? '';
378 $event_date_desc = "$day_cardinal $weekday of ";
379 } else {
380 $event_date_desc = date( 'd ', strtotime( $event['DTSTART'] ) );
381 }
382
383 // Interval only.
384 if ( $interval > 1 ) {
385 $catchup = 0;
386 $maybe = strtotime( $event['DTSTART'] );
387 while ( $maybe < $current ) {
388 $maybe = strtotime( '+ ' . ( $interval * $catchup ) . ' months', strtotime( $event['DTSTART'] ) );
389 ++$catchup;
390 }
391 $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', strtotime( '+ ' . ( $interval * ( $catchup - 1 ) ) . ' months', strtotime( $event['DTSTART'] ) ) ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
392 } else {
393 $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', $current ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
394 }
395
396 // Add one interval if necessary.
397 if ( strtotime( $recurring_event_date_start ) < $current ) {
398 if ( $interval > 1 ) {
399 $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', strtotime( '+ ' . ( $interval * $catchup ) . ' months', strtotime( $event['DTSTART'] ) ) ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
400 } else {
401 try {
402 $adjustment = new DateTime( date( 'Y-m-d', $current ) );
403 $adjustment->modify( 'first day of next month' );
404 $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . $adjustment->format( 'F Y' ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) );
405 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
406 // Invalid argument to DateTime.
407 }
408 }
409 }
410 }
411 break;
412
413 case 'YEARLY':
414 $frequency = 'year';
415 $echo_limit = 1;
416
417 if ( $date_from_ics >= $current ) {
418 $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) );
419 } else {
420 $recurring_event_date_start = date( 'Y', $current ) . date( 'md\THis', strtotime( $event['DTSTART'] ) );
421 if ( strtotime( $recurring_event_date_start ) < $current ) {
422 try {
423 $next = new DateTime( date( 'Y-m-d', $current ) );
424 $next->modify( 'first day of next year' );
425 $recurring_event_date_start = $next->format( 'Y' ) . date( 'md\THis', strtotime( $event['DTSTART'] ) );
426 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
427 // Invalid argument to DateTime.
428 }
429 }
430 }
431 break;
432
433 default:
434 $frequency = false;
435 }
436
437 if ( false !== $frequency && ! $noop ) {
438 $count_counter = 1;
439
440 // If no COUNT limit, go to 10.
441 if ( empty( $rrule_count ) ) {
442 $rrule_count = 10;
443 }
444
445 // Set up EXDATE handling for the event.
446 $exdates = $event['EXDATE'] ?? array();
447
448 for ( $i = 1; $i <= $echo_limit; $i++ ) {
449
450 // Weeks need a daily loop and must check for inclusion in BYDAYS.
451 if ( 'week' === $frequency ) {
452 $byday_event_date_start = strtotime( $recurring_event_date_start );
453
454 foreach ( $weekdays as $day ) {
455
456 $event_start_timestamp = $byday_event_date_start;
457 $start_time = date( 'His', $event_start_timestamp );
458 $event_end_timestamp = $event_start_timestamp + $duration;
459 $end_time = date( 'His', $event_end_timestamp );
460 if ( 8 === strlen( $event['DTSTART'] ) ) {
461 $exdate_compare = date( 'Ymd', $event_start_timestamp );
462 } else {
463 $exdate_compare = date( 'Ymd\THis', $event_start_timestamp );
464 }
465
466 if (
467 in_array( $day, $bydays, true )
468 && $event_end_timestamp > $current
469 && $event_start_timestamp < $until
470 && $count_counter <= $rrule_count
471 && $event_start_timestamp >= $date_from_ics
472 && ! in_array( $exdate_compare, $exdates, true )
473 ) {
474 if ( 8 === strlen( $event['DTSTART'] ) ) {
475 $event['DTSTART'] = date( 'Ymd', $event_start_timestamp );
476 $event['DTEND'] = date( 'Ymd', $event_end_timestamp );
477 } else {
478 $event['DTSTART'] = date( 'Ymd\THis', $event_start_timestamp );
479 $event['DTEND'] = date( 'Ymd\THis', $event_end_timestamp );
480 }
481 if ( $this->timezone->getName() && 8 !== strlen( $event['DTSTART'] ) ) {
482 try {
483 $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone( $this->timezone->getName() ) );
484 $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) );
485 $event['DTSTART'] = $adjusted_time->format( 'Ymd\THis' );
486
487 $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration );
488 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
489 // Invalid argument to DateTime.
490 }
491 }
492 $upcoming[] = $event;
493 ++$count_counter;
494 }
495
496 // Move forward one day.
497 $byday_event_date_start = strtotime( date( 'Ymd\T', strtotime( '+ 1 day', $event_start_timestamp ) ) . $start_time );
498 }
499
500 // Restore first event timestamp.
501 $event_start_timestamp = strtotime( $recurring_event_date_start );
502
503 } else {
504
505 $event_start_timestamp = strtotime( $recurring_event_date_start );
506 $start_time = date( 'His', $event_start_timestamp );
507 $event_end_timestamp = $event_start_timestamp + $duration;
508 $end_time = date( 'His', $event_end_timestamp );
509 if ( 8 === strlen( $event['DTSTART'] ) ) {
510 $exdate_compare = date( 'Ymd', $event_start_timestamp );
511 } else {
512 $exdate_compare = date( 'Ymd\THis', $event_start_timestamp );
513 }
514
515 if (
516 $event_end_timestamp > $current
517 && $event_start_timestamp < $until
518 && $count_counter <= $rrule_count
519 && $event_start_timestamp >= $date_from_ics
520 && ! in_array( $exdate_compare, $exdates, true )
521 ) {
522 if ( 8 === strlen( $event['DTSTART'] ) ) {
523 $event['DTSTART'] = date( 'Ymd', $event_start_timestamp );
524 $event['DTEND'] = date( 'Ymd', $event_end_timestamp );
525 } else {
526 $event['DTSTART'] = date( 'Ymd\T', $event_start_timestamp ) . $start_time;
527 $event['DTEND'] = date( 'Ymd\T', $event_end_timestamp ) . $end_time;
528 }
529 if ( $this->timezone->getName() && 8 !== strlen( $event['DTSTART'] ) ) {
530 try {
531 $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone( $this->timezone->getName() ) );
532 $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) );
533 $event['DTSTART'] = $adjusted_time->format( 'Ymd\THis' );
534
535 $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration );
536 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
537 // Invalid argument to DateTime.
538 }
539 }
540 $upcoming[] = $event;
541 ++$count_counter;
542 }
543 }
544
545 // Set up next interval and reset $event['DTSTART'] and $event['DTEND'], keeping timestamps intact.
546 $next_start_timestamp = strtotime( "+ {$interval} {$frequency}s", $event_start_timestamp );
547 if ( 8 === strlen( $event['DTSTART'] ) ) {
548 $event['DTSTART'] = date( 'Ymd', $next_start_timestamp );
549 $event['DTEND'] = date( 'Ymd', strtotime( $event['DTSTART'] ) + $duration );
550 } else {
551 $event['DTSTART'] = date( 'Ymd\THis', $next_start_timestamp );
552 $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration );
553 }
554
555 // Move recurring event date forward.
556 $recurring_event_date_start = $event['DTSTART'];
557 }
558 $set_recurring_events[] = $uid;
559
560 }
561 } elseif ( strtotime( $event['DTEND'] ?? $event['DTSTART'] ) >= $current ) { // Process normal events.
562 $upcoming[] = $event;
563 }
564 }
565 return $upcoming;
566 }
567
568 /**
569 * Parse events from an iCalendar feed
570 *
571 * @param string $url (default: '').
572 * @return array | false on failure
573 */
574 public function parse( $url = '' ) {
575 $cache_group = 'icalendar_reader_parse';
576 $disable_get_key = 'disable:' . md5( $url );
577
578 // Check to see if previous attempts have failed.
579 if ( false !== wp_cache_get( $disable_get_key, $cache_group ) ) {
580 return false;
581 }
582
583 // rewrite webcal: URI scheme to HTTP.
584 $url = preg_replace( '/^webcal/', 'http', $url );
585 // try to fetch.
586 $r = wp_safe_remote_get(
587 $url,
588 array(
589 'timeout' => 3,
590 'sslverify' => false,
591 )
592 );
593 if ( 200 !== wp_remote_retrieve_response_code( $r ) ) {
594 // We were unable to fetch any content, so don't try again for another 60 seconds.
595 wp_cache_set( $disable_get_key, 1, $cache_group, 60 );
596 return false;
597 }
598
599 $body = wp_remote_retrieve_body( $r );
600 if ( empty( $body ) ) {
601 return false;
602 }
603
604 $body = str_replace( "\r\n", "\n", $body );
605 $lines = preg_split( "/\n(?=[A-Z])/", $body );
606
607 if ( empty( $lines ) ) {
608 return false;
609 }
610
611 if ( false === stristr( $lines[0], 'BEGIN:VCALENDAR' ) ) {
612 return false;
613 }
614
615 $type = '';
616 foreach ( $lines as $line ) {
617 $add = $this->key_value_from_string( $line );
618 if ( ! $add ) {
619 $this->add_component( $type, false, $line );
620 continue;
621 }
622 list( $keyword, $value ) = $add;
623
624 switch ( $keyword ) {
625 case 'BEGIN':
626 case 'END':
627 switch ( $line ) {
628 case 'BEGIN:VTODO':
629 ++$this->todo_count;
630 $type = 'VTODO';
631 break;
632 case 'BEGIN:VEVENT':
633 ++$this->event_count;
634 $type = 'VEVENT';
635 break;
636 case 'BEGIN:VCALENDAR':
637 case 'BEGIN:DAYLIGHT':
638 case 'BEGIN:VTIMEZONE':
639 case 'BEGIN:STANDARD':
640 $type = $value;
641 break;
642 case 'END:VTODO':
643 case 'END:VEVENT':
644 case 'END:VCALENDAR':
645 case 'END:DAYLIGHT':
646 case 'END:VTIMEZONE':
647 case 'END:STANDARD':
648 $type = 'VCALENDAR';
649 break;
650 }
651 break;
652 case 'TZID':
653 if (
654 'VTIMEZONE' === $type
655 && ! $this->timezone
656 ) {
657 $this->timezone = $this->timezone_from_string( $value );
658 }
659 break;
660 case 'X-WR-TIMEZONE':
661 if ( ! $this->timezone ) {
662 $this->timezone = $this->timezone_from_string( $value );
663 }
664 break;
665 default:
666 $this->add_component( $type, $keyword, $value );
667 break;
668 }
669 }
670
671 // Filter for RECURRENCE-IDs.
672 $recurrences = array();
673 if ( array_key_exists( 'VEVENT', $this->cal ) ) {
674 foreach ( $this->cal['VEVENT'] as $event ) {
675 if ( isset( $event['RECURRENCE-ID'] ) ) {
676 $recurrences[] = $event;
677 }
678 }
679 foreach ( $recurrences as $recurrence ) {
680 $count_vevent = count( $this->cal['VEVENT'] );
681 for ( $i = 0; $i < $count_vevent; $i++ ) {
682 if (
683 $this->cal['VEVENT'][ $i ]['UID'] === $recurrence['UID']
684 && ! isset( $this->cal['VEVENT'][ $i ]['RECURRENCE-ID'] )
685 ) {
686 $this->cal['VEVENT'][ $i ]['EXDATE'][] = $recurrence['RECURRENCE-ID'];
687 break;
688 }
689 }
690 }
691 }
692
693 return $this->cal;
694 }
695
696 /**
697 * Parse key:value from a string
698 *
699 * @param string $text (default: '').
700 * @return array
701 */
702 public function key_value_from_string( $text = '' ) {
703 preg_match( '/([^:]+)(;[^:]+)?[:]([\w\W]*)/', $text, $matches );
704
705 if ( 0 === count( $matches ) ) {
706 return false;
707 }
708
709 return array( $matches[1], $matches[3] );
710 }
711
712 /**
713 * Convert a timezone name into a timezone object.
714 *
715 * @param string $text Timezone name. Example: America/Chicago.
716 * @return object|null A DateTimeZone object if the conversion was successful.
717 */
718 private function timezone_from_string( $text ) {
719 try {
720 $timezone = new DateTimeZone( $text );
721 } catch ( Exception $e ) {
722 $blog_timezone = get_option( 'timezone_string' );
723 if ( ! $blog_timezone ) {
724 $blog_timezone = 'Etc/UTC';
725 }
726
727 $timezone = new DateTimeZone( $blog_timezone );
728 }
729
730 return $timezone;
731 }
732
733 /**
734 * Add a component to the calendar array
735 *
736 * @param string $component (default: '').
737 * @param bool|string $keyword (default: '').
738 * @param string $value (default: '').
739 * @return void
740 */
741 public function add_component( $component = '', $keyword = '', $value = '' ) {
742 if ( ! $keyword ) {
743 $keyword = $this->last_keyword;
744 switch ( $component ) {
745 case 'VEVENT':
746 $value = $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ] . $value;
747 break;
748 case 'VTODO':
749 $value = $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ] . $value;
750 break;
751 }
752 }
753
754 /*
755 * Some events have a specific timezone set in their start/end date,
756 * and it may or may not be different than the calendar timzeone.
757 * Valid formats include:
758 * DTSTART;TZID=Pacific Standard Time:20141219T180000
759 * DTEND;TZID=Pacific Standard Time:20141219T200000
760 * EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z
761 * EXDATE;VALUE=DATE:2015050
762 * EXDATE;TZID=America/New_York:20150424T170000
763 * EXDATE;TZID=Pacific Standard Time:20120615T140000,20120629T140000,20120706T140000
764 */
765
766 // Always store EXDATE as an array.
767 if ( stristr( $keyword, 'EXDATE' ) ) {
768 $value = explode( ',', $value );
769 }
770
771 // Adjust DTSTART, DTEND, and EXDATE according to their TZID if set.
772 if ( strpos( $keyword, ';' ) && ( stristr( $keyword, 'DTSTART' ) || stristr( $keyword, 'DTEND' ) || stristr( $keyword, 'EXDATE' ) || stristr( $keyword, 'RECURRENCE-ID' ) ) ) {
773 $keyword = explode( ';', $keyword );
774
775 $tzid = false;
776 if ( 2 === count( $keyword ) ) {
777 $tparam = $keyword[1];
778
779 if ( str_contains( $tparam, 'TZID' ) ) {
780 $tzid = $this->timezone_from_string( str_replace( 'TZID=', '', $tparam ) );
781 }
782 }
783
784 // Normalize all times to default UTC.
785 if ( $tzid ) {
786 $adjusted_times = array();
787 foreach ( (array) $value as $v ) {
788 try {
789 $adjusted_time = new DateTime( $v, $tzid );
790 $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) );
791 $adjusted_times[] = $adjusted_time->format( 'Ymd\THis' );
792 } catch ( Exception $e ) {
793 // Invalid argument to DateTime.
794 return;
795 }
796 }
797 $value = $adjusted_times;
798 }
799
800 // Format for adding to event.
801 $keyword = $keyword[0];
802 if ( 'EXDATE' !== $keyword ) {
803 $value = implode( (array) $value );
804 }
805 }
806
807 foreach ( (array) $value as $v ) {
808 switch ( $component ) {
809 case 'VTODO':
810 if ( 'EXDATE' === $keyword ) {
811 $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ][] = $v;
812 } else {
813 $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ] = $v;
814 }
815 break;
816 case 'VEVENT':
817 if ( 'EXDATE' === $keyword ) {
818 $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ][] = $v;
819 } else {
820 $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ] = $v;
821 }
822 break;
823 default:
824 $this->cal[ $component ][ $keyword ] = $v;
825 break;
826 }
827 }
828 $this->last_keyword = $keyword;
829 }
830
831 /**
832 * Escape strings with wp_kses, allow links
833 *
834 * @param string $string (default: '') The string to escape.
835 * @return string
836 */
837 public function escape( $string = '' ) {
838 // Unfold content lines per RFC 5545.
839 $string = str_replace( "\n\t", '', $string );
840 $string = str_replace( "\n ", '', $string );
841
842 $allowed_html = array(
843 'a' => array(
844 'href' => array(),
845 'title' => array(),
846 ),
847 );
848
849 $allowed_tags = '';
850 foreach ( array_keys( $allowed_html ) as $tag ) {
851 $allowed_tags .= "<{$tag}>";
852 }
853
854 // Running strip_tags() first with allowed tags to get rid of remaining gallery markup, etc
855 // because wp_kses() would only htmlentity'fy that. Then still running wp_kses(), for extra
856 // safety and good measure.
857 return wp_kses( strip_tags( $string, $allowed_tags ), $allowed_html );
858 }
859
860 /**
861 * Render the events
862 *
863 * @param string $url (default: '') URL of the iCal feed.
864 * @param array $args Event options.
865 *
866 * @return mixed bool|string false on failure, rendered HTML string on success.
867 */
868 public function render( $url = '', $args = array() ) {
869
870 $args = wp_parse_args(
871 $args,
872 array(
873 'context' => 'widget',
874 'number' => 5,
875 )
876 );
877
878 $events = $this->get_events( $url, $args['number'] );
879 $events = $this->apply_timezone_offset( $events );
880
881 if ( empty( $events ) ) {
882 return false;
883 }
884
885 ob_start();
886
887 if ( 'widget' === $args['context'] ) : ?>
888 <ul class="upcoming-events">
889 <?php foreach ( $events as $event ) : ?>
890 <li>
891 <strong class="event-summary">
892 <?php
893 echo $this->escape( stripslashes( $event['SUMMARY'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
894 ?>
895 </strong>
896 <span class="event-when"><?php echo esc_html( $this->formatted_date( $event ) ); ?></span>
897 <?php if ( ! empty( $event['LOCATION'] ) ) : ?>
898 <span class="event-location">
899 <?php
900 echo $this->escape( stripslashes( $event['LOCATION'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
901 ?>
902 </span>
903 <?php endif; ?>
904 <?php if ( ! empty( $event['DESCRIPTION'] ) ) : ?>
905 <span class="event-description">
906 <?php
907 echo wp_trim_words( $this->escape( stripcslashes( $event['DESCRIPTION'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
908 ?>
909 </span>
910 <?php endif; ?>
911 </li>
912 <?php endforeach; ?>
913 </ul>
914 <?php
915 endif;
916
917 if ( 'shortcode' === $args['context'] ) :
918 ?>
919 <table class="upcoming-events">
920 <thead>
921 <tr>
922 <th><?php esc_html_e( 'Location', 'jetpack' ); ?></th>
923 <th><?php esc_html_e( 'When', 'jetpack' ); ?></th>
924 <th><?php esc_html_e( 'Summary', 'jetpack' ); ?></th>
925 <th><?php esc_html_e( 'Description', 'jetpack' ); ?></th>
926 </tr>
927 </thead>
928 <tbody>
929 <?php foreach ( $events as $event ) : ?>
930 <tr>
931 <td>
932 <?php
933 echo empty( $event['LOCATION'] )
934 ? '&nbsp;'
935 : $this->escape( stripslashes( $event['LOCATION'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
936 ?>
937 </td>
938 <td><?php echo esc_html( $this->formatted_date( $event ) ); ?></td>
939 <td>
940 <?php
941 echo empty( $event['SUMMARY'] )
942 ? '&nbsp;'
943 : $this->escape( stripslashes( $event['SUMMARY'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
944 ?>
945 </td>
946 <td>
947 <?php
948 echo empty( $event['DESCRIPTION'] )
949 ? '&nbsp;'
950 : wp_trim_words( $this->escape( stripcslashes( $event['DESCRIPTION'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this method is built to escape.
951 ?>
952 </td>
953 </tr>
954 <?php endforeach; ?>
955 </tbody>
956 </table>
957 <?php
958 endif;
959
960 $rendered = ob_get_clean();
961
962 if ( empty( $rendered ) ) {
963 return false;
964 }
965
966 return $rendered;
967 }
968
969 /**
970 * Return a localized string with information about the event's date and time,
971 * or starting date and end date.
972 *
973 * @param array $event Info about the event.
974 *
975 * @return string
976 */
977 public function formatted_date( $event ) {
978 $date_format = get_option( 'date_format' );
979 $time_format = get_option( 'time_format' );
980 $start = strtotime( $event['DTSTART'] );
981 $end = isset( $event['DTEND'] ) ? strtotime( $event['DTEND'] ) : false;
982
983 $all_day = ( 8 === strlen( $event['DTSTART'] ) );
984
985 if ( ! $all_day && $this->timezone ) {
986 try {
987 $timezone_offset = $this->timezone->getOffset( new DateTime( $event['DTSTART'] ) );
988 $start += $timezone_offset;
989
990 if ( $end ) {
991 $end += $timezone_offset;
992 }
993 } catch ( Exception $e ) {
994 // Invalid argument to DateTime.
995 return '';
996 }
997 }
998 $single_day = $end ? ( $end - $start ) <= DAY_IN_SECONDS : true;
999
1000 /* translators: Date and time */
1001 $date_with_time = __( '%1$s at %2$s', 'jetpack' );
1002 /* translators: Two dates with a separator */
1003 $two_dates = __( '%1$s &ndash; %2$s', 'jetpack' );
1004
1005 // we'll always have the start date. Maybe with time.
1006 if ( $all_day ) {
1007 $date = date_i18n( $date_format, $start );
1008 } else {
1009 $date = sprintf(
1010 $date_with_time,
1011 date_i18n( $date_format, $start ),
1012 date_i18n( $time_format, $start )
1013 );
1014 }
1015
1016 // single day, timed.
1017 if ( $single_day && ! $all_day && false !== $end ) {
1018 $date = sprintf( $two_dates, $date, date_i18n( $time_format, $end ) );
1019 }
1020
1021 // multi-day.
1022 if ( ! $single_day ) {
1023
1024 if ( $all_day ) {
1025 // DTEND for multi-day events represents "until", not "including", so subtract one minute.
1026 $end_date = date_i18n( $date_format, $end - 60 );
1027 } else {
1028 $end_date = sprintf( $date_with_time, date_i18n( $date_format, $end ), date_i18n( $time_format, $end ) );
1029 }
1030
1031 $date = sprintf( $two_dates, $date, $end_date );
1032
1033 }
1034
1035 return $date;
1036 }
1037
1038 /**
1039 * Sort list of events by event date.
1040 *
1041 * @param array $list List of events.
1042 *
1043 * @return array
1044 */
1045 protected function sort_by_recent( $list ) {
1046 $dates = array();
1047 $sorted_list = array();
1048
1049 foreach ( $list as $key => $row ) {
1050 $date = $row['DTSTART'];
1051 // pad some time onto an all day date.
1052 if ( 8 === strlen( $date ) ) {
1053 $date .= 'T000000Z';
1054 }
1055 $dates[ $key ] = $date;
1056 }
1057 asort( $dates );
1058 foreach ( $dates as $key => $value ) {
1059 $sorted_list[ $key ] = $list[ $key ];
1060 }
1061 unset( $list );
1062 return $sorted_list;
1063 }
1064
1065 // phpcs:enable WordPress.DateTime.RestrictedFunctions.date_date
1066 }
1067
1068 /**
1069 * Wrapper function for iCalendarReader->get_events()
1070 *
1071 * @param string $url (default: '').
1072 * @param int $count Number of events to fetch.
1073 * @return array
1074 */
1075 function icalendar_get_events( $url = '', $count = 5 ) {
1076 /*
1077 * Find your calendar's address
1078 * https://support.google.com/calendar/bin/answer.py?hl=en&answer=37103
1079 */
1080 return ( new iCalendarReader() )->get_events( $url, $count );
1081 }
1082
1083 /**
1084 * Wrapper function for iCalendarReader->render()
1085 *
1086 * @param string $url (default: '').
1087 * @param array $args Options when rendering events.
1088 *
1089 * @return mixed bool|string false on failure, rendered HTML string on success.
1090 */
1091 function icalendar_render_events( $url = '', $args = array() ) {
1092 return ( new iCalendarReader() )->render( $url, $args );
1093 }
1094