Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 538
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
icalendar_get_events
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
icalendar_render_events
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
iCalendarReader
0.00% covered (danger)
0.00%
0 / 536
0.00% covered (danger)
0.00%
0 / 12
35532
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_events
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
110
 apply_timezone_offset
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 filter_past_and_recurring_events
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 1
6320
 parse
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
1260
 key_value_from_string
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 timezone_from_string
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 add_component
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
506
 escape
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 render
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
156
 formatted_date
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
182
 sort_by_recent
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
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 */
16class 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 = isset( $event['RRULE'] ) ? $event['RRULE'] : false;
240            $uid   = isset( $event['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    = ( isset( $rrule_array['INTERVAL'] ) ) ? $rrule_array['INTERVAL'] : 1;
253                $rrule_count = ( isset( $rrule_array['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                = $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                = $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 = ( isset( $event['EXDATE'] ) ) ? $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( isset( $event['DTEND'] ) ? $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 */
1075function 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 */
1091function icalendar_render_events( $url = '', $args = array() ) {
1092    return ( new iCalendarReader() )->render( $url, $args );
1093}