'MO', 2 => 'TU', 3 => 'WE', 4 => 'TH', 5 => 'FR', 6 => 'SA', 7 => 'SU', ); } /** * Builds a rule string from form values. * * @param string $freq daily|weekly|monthly|yearly. * @param int $interval Interval. * @param array $byday ISO weekday numbers (weekly rules). * @param string $until Y-m-d or empty. * @param int $count Count or 0. * @param string $monthly_byday Monthly rules: weekday with ordinal such as * "3TH" or "-1FR"; empty repeats on the same * day of the month. * @return string */ function bcal_build_rrule( $freq, $interval = 1, $byday = array(), $until = '', $count = 0, $monthly_byday = '' ) { $freq = strtoupper( (string) $freq ); if ( ! in_array( $freq, array( 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY' ), true ) ) { return ''; } $parts = array( 'FREQ=' . $freq, 'INTERVAL=' . max( 1, min( 99, (int) $interval ) ) ); if ( 'WEEKLY' === $freq && $byday ) { $codes = bcal_weekday_codes(); $days = array(); foreach ( (array) $byday as $num ) { $num = (int) $num; if ( isset( $codes[ $num ] ) ) { $days[ $num ] = $codes[ $num ]; } } ksort( $days ); if ( $days ) { $parts[] = 'BYDAY=' . implode( ',', $days ); } } if ( 'MONTHLY' === $freq && preg_match( '/^(-1|[1-5])(MO|TU|WE|TH|FR|SA|SU)$/', strtoupper( (string) $monthly_byday ) ) ) { $parts[] = 'BYDAY=' . strtoupper( (string) $monthly_byday ); } if ( $until && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $until ) ) { $parts[] = 'UNTIL=' . str_replace( '-', '', $until ); } elseif ( (int) $count > 0 ) { $parts[] = 'COUNT=' . min( 999, (int) $count ); } return implode( ';', $parts ); } /** * Parses a rule string. Accepts rules as written by other calendars * (lower case keys, a leading "RRULE:", UTC or local UNTIL). * * @param string $rule Rule. * @return array|null Null when the rule is empty or its frequency is not supported. */ function bcal_parse_rrule( $rule ) { $rule = strtoupper( trim( (string) $rule ) ); $rule = preg_replace( '/^(RRULE|EXRULE):/', '', $rule ); if ( '' === $rule ) { return null; } $out = array( 'freq' => '', 'interval' => 1, 'byday' => array(), // ISO weekday numbers, kept for add-ons and the editor. 'byday_full' => array(), // array( ordinal, ISO weekday ), ordinal 0 = every. 'bymonthday' => array(), 'bymonth' => array(), 'bysetpos' => array(), 'until' => '', // Local "Y-m-d" of the last allowed day. 'until_dt' => '', // Local "Y-m-d H:i:s", the exact limit. 'count' => 0, 'unsupported' => array(), ); $codes = array_flip( bcal_weekday_codes() ); $ints = function ( $value, $min, $max ) { $list = array(); foreach ( explode( ',', (string) $value ) as $item ) { $item = trim( $item ); if ( preg_match( '/^[+-]?\d+$/', $item ) ) { $num = (int) $item; if ( 0 !== $num && abs( $num ) >= $min && abs( $num ) <= $max ) { $list[] = $num; } } } return array_values( array_unique( $list ) ); }; foreach ( explode( ';', $rule ) as $pair ) { $bits = explode( '=', $pair, 2 ); if ( 2 !== count( $bits ) ) { continue; } $key = trim( $bits[0] ); $value = trim( $bits[1] ); switch ( $key ) { case 'FREQ': $out['freq'] = $value; break; case 'INTERVAL': $out['interval'] = max( 1, min( 999, (int) $value ) ); break; case 'BYDAY': foreach ( explode( ',', $value ) as $code ) { if ( preg_match( '/^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/', trim( $code ), $m ) ) { $ord = isset( $m[1] ) && '' !== $m[1] ? (int) $m[1] : 0; $num = $codes[ $m[2] ]; $out['byday_full'][] = array( $ord, $num ); $out['byday'][] = $num; } } $out['byday'] = array_values( array_unique( $out['byday'] ) ); sort( $out['byday'] ); break; case 'BYMONTHDAY': $out['bymonthday'] = $ints( $value, 1, 31 ); break; case 'BYMONTH': $out['bymonth'] = array_filter( $ints( $value, 1, 12 ), function ( $m ) { return $m > 0; } ); break; case 'BYSETPOS': $out['bysetpos'] = $ints( $value, 1, 366 ); break; case 'UNTIL': $out['until_dt'] = bcal_rrule_until_local( $value ); $out['until'] = $out['until_dt'] ? substr( $out['until_dt'], 0, 10 ) : ''; break; case 'COUNT': $out['count'] = max( 0, (int) $value ); break; case 'WKST': break; case 'BYHOUR': case 'BYMINUTE': case 'BYSECOND': case 'BYWEEKNO': case 'BYYEARDAY': $out['unsupported'][] = $key; break; } } if ( ! in_array( $out['freq'], array( 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY' ), true ) ) { return null; } return $out; } /** * Converts an UNTIL value to a local limit. A date means "until the end of * that day"; a UTC moment ("…Z") is converted to the site time zone, so an * UNTIL of 20261115T235959Z does not add a day in zones ahead of UTC. * * @param string $value UNTIL value. * @return string Local "Y-m-d H:i:s" or empty string. */ function bcal_rrule_until_local( $value ) { $value = strtoupper( trim( (string) $value ) ); $tz = wp_timezone(); if ( preg_match( '/^(\d{4})(\d{2})(\d{2})$/', $value, $m ) ) { return $m[1] . '-' . $m[2] . '-' . $m[3] . ' 23:59:59'; } if ( preg_match( '/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/', $value, $m ) ) { $stamp = sprintf( '%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6] ); try { if ( 'Z' === $m[7] ) { $date = new DateTimeImmutable( $stamp, new DateTimeZone( 'UTC' ) ); return $date->setTimezone( $tz )->format( 'Y-m-d H:i:s' ); } $date = new DateTimeImmutable( $stamp, $tz ); return $date->format( 'Y-m-d H:i:s' ); } catch ( Exception $e ) { return ''; } } return ''; } /** * Whether the engine can expand a rule completely. * * @param string $rule Rule. * @return bool */ function bcal_rrule_supported( $rule ) { if ( '' === trim( (string) $rule ) ) { return true; } $parsed = bcal_parse_rrule( $rule ); return $parsed && ! $parsed['unsupported']; } /** * Day of the month of the n-th weekday (n < 0 counts from the end). * * @param int $year Year. * @param int $month Month. * @param int $weekday ISO weekday. * @param int $n Ordinal. * @return int Day or 0 when the month has no such day. */ function bcal_nth_weekday( $year, $month, $weekday, $n ) { $days = (int) gmdate( 't', gmmktime( 12, 0, 0, $month, 1, $year ) ); $first = (int) gmdate( 'N', gmmktime( 12, 0, 0, $month, 1, $year ) ); if ( $n > 0 ) { $day = 1 + ( ( $weekday - $first + 7 ) % 7 ) + ( $n - 1 ) * 7; return $day <= $days ? $day : 0; } $last = (int) gmdate( 'N', gmmktime( 12, 0, 0, $month, $days, $year ) ); $day = $days - ( ( $last - $weekday + 7 ) % 7 ) + ( $n + 1 ) * 7; return $day >= 1 ? $day : 0; } /** * Candidate days of one month for a monthly or yearly rule. * * @param int $year Year. * @param int $month Month. * @param array $p Parsed rule. * @param int $dom Day of month of the first occurrence. * @return array Days of the month. */ function bcal_rrule_month_days( $year, $month, $p, $dom ) { $total = (int) gmdate( 't', gmmktime( 12, 0, 0, $month, 1, $year ) ); $days = array(); if ( $p['byday_full'] ) { foreach ( $p['byday_full'] as $item ) { list( $ord, $weekday ) = $item; if ( 0 !== $ord ) { $day = bcal_nth_weekday( $year, $month, $weekday, $ord ); if ( $day ) { $days[] = $day; } continue; } $day = bcal_nth_weekday( $year, $month, $weekday, 1 ); while ( $day && $day <= $total ) { $days[] = $day; $day += 7; } } if ( $p['bymonthday'] ) { $allowed = array(); foreach ( $p['bymonthday'] as $md ) { $allowed[] = $md > 0 ? $md : $total + $md + 1; } $days = array_intersect( $days, $allowed ); } } elseif ( $p['bymonthday'] ) { foreach ( $p['bymonthday'] as $md ) { $day = $md > 0 ? $md : $total + $md + 1; if ( $day >= 1 && $day <= $total ) { $days[] = $day; } } } elseif ( $dom <= $total ) { // No 31st in this month: skipped, as iCalendar does. $days[] = $dom; } $days = array_values( array_unique( array_map( 'intval', $days ) ) ); sort( $days ); return $days; } /** * Applies BYSETPOS to the sorted candidates of one period. * * @param array $items Sorted candidates. * @param array $positions Positions (1-based, negative from the end). * @return array */ function bcal_rrule_setpos( $items, $positions ) { if ( ! $positions ) { return $items; } $count = count( $items ); $out = array(); foreach ( $positions as $pos ) { $index = $pos > 0 ? $pos - 1 : $count + $pos; if ( $index >= 0 && $index < $count ) { $out[ $index ] = $items[ $index ]; } } ksort( $out ); return array_values( $out ); } /** * Generates the occurrence starts of a rule, in order, from the first * occurrence until the limit. Stops early when the callback returns false. * * @param DateTimeImmutable $first Start of the first occurrence. * @param array $p Parsed rule. * @param DateTimeImmutable $limit Last allowed start. * @param callable $emit Receives each DateTimeImmutable. */ function bcal_rrule_walk( DateTimeImmutable $first, $p, DateTimeImmutable $limit, $emit ) { $interval = max( 1, (int) $p['interval'] ); $hms = array( (int) $first->format( 'G' ), (int) $first->format( 'i' ), (int) $first->format( 's' ) ); $year0 = (int) $first->format( 'Y' ); $month0 = (int) $first->format( 'n' ); $dom = (int) $first->format( 'j' ); $guard = 0; $at = function ( $y, $m, $d ) use ( $first, $hms ) { return $first->setDate( $y, $m, $d )->setTime( $hms[0], $hms[1], $hms[2] ); }; // Emits the candidates of one period; returns false to stop everything. $period = function ( $dates ) use ( $first, $limit, $p, $emit ) { $dates = bcal_rrule_setpos( $dates, $p['bysetpos'] ); foreach ( $dates as $candidate ) { if ( $candidate < $first ) { continue; } if ( $candidate > $limit ) { return false; } if ( false === call_user_func( $emit, $candidate ) ) { return false; } } return true; }; switch ( $p['freq'] ) { case 'DAILY': $cursor = $first->setTime( $hms[0], $hms[1], $hms[2] ); while ( $cursor <= $limit && $guard++ < 40000 ) { $ok = true; if ( $p['bymonth'] && ! in_array( (int) $cursor->format( 'n' ), $p['bymonth'], true ) ) { $ok = false; } if ( $ok && $p['byday'] && ! in_array( (int) $cursor->format( 'N' ), $p['byday'], true ) ) { $ok = false; } if ( $ok && $p['bymonthday'] ) { $total = (int) $cursor->format( 't' ); $day = (int) $cursor->format( 'j' ); $matches = false; foreach ( $p['bymonthday'] as $md ) { if ( ( $md > 0 ? $md : $total + $md + 1 ) === $day ) { $matches = true; } } $ok = $matches; } if ( $ok && ! $period( array( $cursor ) ) ) { return; } $cursor = $cursor->modify( '+' . $interval . ' days' ); } return; case 'WEEKLY': $days = $p['byday'] ? $p['byday'] : array( (int) $first->format( 'N' ) ); // Monday of the first week, computed by hand to avoid "monday this week" quirks. $week = $first->modify( '-' . ( (int) $first->format( 'N' ) - 1 ) . ' days' )->setTime( $hms[0], $hms[1], $hms[2] ); while ( $week <= $limit && $guard++ < 20000 ) { $dates = array(); foreach ( $days as $day ) { $candidate = $week->modify( '+' . ( $day - 1 ) . ' days' ); if ( $p['bymonth'] && ! in_array( (int) $candidate->format( 'n' ), $p['bymonth'], true ) ) { continue; } $dates[] = $candidate; } usort( $dates, function ( $a, $b ) { return $a <=> $b; } ); if ( ! $period( $dates ) ) { return; } $week = $week->modify( '+' . ( 7 * $interval ) . ' days' ); } return; case 'MONTHLY': for ( $step = 0; $guard++ < 20000; $step += $interval ) { $index = ( $month0 - 1 ) + $step; $y = $year0 + intdiv( $index, 12 ); $m = ( $index % 12 ) + 1; if ( $at( $y, $m, 1 ) > $limit ) { return; } if ( $p['bymonth'] && ! in_array( $m, $p['bymonth'], true ) ) { continue; } $dates = array(); foreach ( bcal_rrule_month_days( $y, $m, $p, $dom ) as $d ) { $dates[] = $at( $y, $m, $d ); } if ( ! $period( $dates ) ) { return; } } return; case 'YEARLY': for ( $step = 0; $guard++ < 2000; $step += $interval ) { $y = $year0 + $step; if ( $at( $y, 1, 1 ) > $limit ) { return; } $dates = array(); if ( $p['byday_full'] && ! $p['bymonth'] && ! $p['bymonthday'] ) { // n-th weekday of the year, e.g. BYDAY=20MO. foreach ( $p['byday_full'] as $item ) { list( $ord, $weekday ) = $item; $all = array(); $cursor = $at( $y, 1, 1 ); $cursor = $cursor->modify( '+' . ( ( $weekday - (int) $cursor->format( 'N' ) + 7 ) % 7 ) . ' days' ); while ( (int) $cursor->format( 'Y' ) === $y ) { $all[] = $cursor; $cursor = $cursor->modify( '+7 days' ); } if ( 0 === $ord ) { $dates = array_merge( $dates, $all ); } else { $index = $ord > 0 ? $ord - 1 : count( $all ) + $ord; if ( isset( $all[ $index ] ) ) { $dates[] = $all[ $index ]; } } } } else { $months = $p['bymonth'] ? $p['bymonth'] : array( $month0 ); sort( $months ); foreach ( $months as $m ) { $rule = $p; if ( ! $p['bymonth'] && ! $p['byday_full'] && ! $p['bymonthday'] ) { $rule['bymonthday'] = array( $dom ); } foreach ( bcal_rrule_month_days( $y, $m, $rule, $dom ) as $d ) { $dates[] = $at( $y, $m, $d ); } } } usort( $dates, function ( $a, $b ) { return $a <=> $b; } ); if ( ! $period( $dates ) ) { return; } } return; } } /** * Normalizes a list of extra dates to local "Y-m-d H:i:s" values. * A date without time gets the time of the first occurrence. * * @param array|string $dates Dates. * @param string $time "H:i:s" of the first occurrence. * @return array Sorted unique values. */ function bcal_normalize_rdates( $dates, $time = '00:00:00' ) { $items = is_array( $dates ) ? $dates : explode( ',', (string) $dates ); $out = array(); foreach ( $items as $item ) { $item = trim( (string) $item ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $item ) ) { $out[] = $item . ' ' . $time; } elseif ( preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $item ) ) { $out[] = strlen( $item ) === 16 ? $item . ':00' : $item; } } $out = array_values( array_unique( $out ) ); sort( $out ); return $out; } /** * Expands an event into occurrences. * * Events without an explicit end get an empty end here; the index decides * how long such an event stays "upcoming". * * @param string $start Local start "Y-m-d H:i:s". * @param string $end Local end or empty. * @param string $rule Rule string or empty. * @param array $exdates Excluded dates "Y-m-d". * @param string $horizon Last moment to generate, local "Y-m-d H:i:s". * @param int $max Safety cap for stored occurrences. * @param string $from Optional local datetime: occurrences that ended before * it are counted (for COUNT) but not returned, so old * dates of long-running series do not use up the cap. * @param array $extra Optional: "rdates" (extra local datetimes) and * "exrule" (rule of excluded occurrences). * @return array List of array( start, end ). */ function bcal_expand_occurrences( $start, $end, $rule, $exdates, $horizon, $max = 500, $from = '', $extra = array() ) { $tz = wp_timezone(); $first = date_create_immutable( $start, $tz ); if ( ! $first ) { return array(); } $has_end = false; $duration = 0; if ( $end ) { $last = date_create_immutable( $end, $tz ); if ( $last && $last >= $first ) { $has_end = true; $duration = $last->getTimestamp() - $first->getTimestamp(); } } $make = function ( DateTimeImmutable $occ_start ) use ( $has_end, $duration ) { return array( $occ_start->format( 'Y-m-d H:i:s' ), $has_end ? $occ_start->modify( '+' . $duration . ' seconds' )->format( 'Y-m-d H:i:s' ) : '', ); }; $rdates = isset( $extra['rdates'] ) ? bcal_normalize_rdates( $extra['rdates'], $first->format( 'H:i:s' ) ) : array(); $parsed = bcal_parse_rrule( $rule ); if ( ! $parsed && ! $rdates ) { return array( $make( $first ) ); } $limit = date_create_immutable( $horizon, $tz ); if ( ! $limit ) { return array( $make( $first ) ); } $excluded = array_flip( array_filter( (array) $exdates ) ); $from_dt = $from ? date_create_immutable( $from, $tz ) : false; $from_ts = $from_dt ? $from_dt->getTimestamp() - max( $duration, DAY_IN_SECONDS ) : 0; // Occurrences removed by an exclusion rule. $exrule_hits = array(); if ( ! empty( $extra['exrule'] ) ) { $ex = bcal_parse_rrule( $extra['exrule'] ); if ( $ex && ! $ex['unsupported'] ) { $ex_limit = $limit; if ( $ex['until_dt'] ) { $ex_until = date_create_immutable( $ex['until_dt'], $tz ); if ( $ex_until && $ex_until < $ex_limit ) { $ex_limit = $ex_until; } } $ex_seen = 0; bcal_rrule_walk( $first, $ex, $ex_limit, function ( DateTimeImmutable $candidate ) use ( &$exrule_hits, &$ex_seen, $ex ) { ++$ex_seen; if ( $ex['count'] && $ex_seen > $ex['count'] ) { return false; } $exrule_hits[ $candidate->format( 'Y-m-d H:i' ) ] = true; return $ex_seen < 5000; } ); } } $starts = array(); if ( $parsed ) { $rule_limit = $limit; if ( $parsed['until_dt'] ) { $until = date_create_immutable( $parsed['until_dt'], $tz ); if ( $until && $until < $rule_limit ) { $rule_limit = $until; } } $count = (int) $parsed['count']; $seen = 0; bcal_rrule_walk( $first, $parsed, $rule_limit, function ( DateTimeImmutable $candidate ) use ( &$starts, &$seen, $count ) { ++$seen; if ( $count && $seen > $count ) { return false; } $starts[ $candidate->format( 'Y-m-d H:i:s' ) ] = $candidate; return $seen < 20000; } ); } else { // Extra dates on an event without a rule: the first date still counts. $starts[ $first->format( 'Y-m-d H:i:s' ) ] = $first; } foreach ( $rdates as $rdate ) { $candidate = date_create_immutable( $rdate, $tz ); if ( $candidate && $candidate <= $limit && ! isset( $starts[ $rdate ] ) ) { $starts[ $rdate ] = $candidate; } } ksort( $starts ); $out = array(); foreach ( $starts as $candidate ) { if ( $from_ts && $candidate->getTimestamp() < $from_ts ) { continue; } if ( isset( $excluded[ $candidate->format( 'Y-m-d' ) ] ) || isset( $exrule_hits[ $candidate->format( 'Y-m-d H:i' ) ] ) ) { continue; } $out[] = $make( $candidate ); if ( count( $out ) >= $max ) { break; } } return $out; } /** * Editor state for a rule, or null when the rule cannot be shown with the * simple options of the event editor (it is then kept as a custom rule). * * @param string $rule Rule. * @param string $start Local start of the event. * @return array|null { freq, interval, byday, monthly, until, count } */ function bcal_rrule_editor_state( $rule, $start ) { if ( '' === trim( (string) $rule ) ) { return array( 'freq' => '', 'interval' => 1, 'byday' => array(), 'monthly' => 'day', 'until' => '', 'count' => 0, ); } $p = bcal_parse_rrule( $rule ); if ( ! $p || $p['unsupported'] || $p['bysetpos'] || $p['bymonth'] || $p['bymonthday'] ) { return null; } $state = array( 'freq' => strtolower( $p['freq'] ), 'interval' => (int) $p['interval'], 'byday' => array(), 'monthly' => 'day', 'until' => (string) $p['until'], 'count' => (int) $p['count'], ); // A time-of-day UNTIL from another calendar cannot be edited as a date without changing it. if ( $p['until_dt'] && '23:59:59' !== substr( $p['until_dt'], 11 ) ) { return null; } switch ( $p['freq'] ) { case 'DAILY': case 'YEARLY': if ( $p['byday_full'] ) { return null; } break; case 'WEEKLY': foreach ( $p['byday_full'] as $item ) { if ( 0 !== $item[0] ) { return null; } } $state['byday'] = $p['byday']; break; case 'MONTHLY': if ( ! $p['byday_full'] ) { break; } if ( count( $p['byday_full'] ) !== 1 || ! $start ) { return null; } list( $ord, $weekday ) = $p['byday_full'][0]; $ts = bcal_ts( $start ); if ( ! $ts || (int) wp_date( 'N', $ts ) !== $weekday ) { return null; } $nth = (int) ceil( (int) wp_date( 'j', $ts ) / 7 ); $last = (int) wp_date( 'j', $ts ) + 7 > (int) wp_date( 't', $ts ); if ( $ord === $nth && $nth <= 4 ) { $state['monthly'] = 'nth'; } elseif ( -1 === $ord && $last ) { $state['monthly'] = 'last'; } else { return null; } break; } return $state; }