PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 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 All 508 releases
← All changes | _inc/lib/icalendar-reader.php +1093 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,1093 @@
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 +}