* @link https://github.com/rlanvin/php-rrule */ namespace RRule; /** * Check that a variable is not empty. 0 and '0' are considered NOT empty * * @return bool */ function not_empty($var) { return ! empty($var) || $var === 0 || $var === '0'; } /** * closure/goog/math/math.js:modulo * Copyright 2006 The Closure Library Authors. * * The % operator in PHP returns the remainder of a / b, but differs from * some other languages in that the result will have the same sign as the * dividend. For example, -1 % 8 == -1, whereas in some other languages * (such as Python) the result would be 7. This function emulates the more * correct modulo behavior, which is useful for certain applications such as * calculating an offset index in a circular list. * * @param int $a The dividend. * @param int $b The divisor. * * @return int $a % $b where the result is between 0 and $b * (either 0 <= x < $b * or $b < x <= 0, depending on the sign of $b). */ function pymod($a, $b) { $x = $a % $b; // If $x and $b differ in sign, add $b to wrap the result to the correct sign. return ($x * $b < 0) ? $x + $b : $x; } /** * Check is a year is a leap year. * @return bool */ function is_leap_year($year) { if ( $year % 4 !== 0 ) { return false; } if ( $year % 100 !== 0 ) { return true; } if ( $year % 400 !== 0 ) { return false; } return true; } /** * Implementation of RRULE as defined by RFC 5545 (iCalendar). * Heavily based on python-dateutil/rrule * * Some useful terms to understand the algorithms and variables naming: * * yearday = day of the year, from 0 to 365 (on leap years) - date('z') * weekday = day of the week (ISO-8601), from 1 (MO) to 7 (SU) - date('N') * monthday = day of the month, from 1 to 31 * wkst = week start, the weekday (1 to 7) which is the first day of week. * Default is Monday (1). In some countries it's Sunday (7). * weekno = number of the week in the year (ISO-8601) * * CAREFUL with this bug: https://bugs.php.net/bug.php?id=62476 * * @see https://tools.ietf.org/html/rfc5545 * @see https://labix.org/python-dateutil */ class RRule implements RRuleInterface { const SECONDLY = 7; const MINUTELY = 6; const HOURLY = 5; const DAILY = 4; const WEEKLY = 3; const MONTHLY = 2; const YEARLY = 1; // frequency names public static $frequencies = array( 'SECONDLY' => self::SECONDLY, 'MINUTELY' => self::MINUTELY, 'HOURLY' => self::HOURLY, 'DAILY' => self::DAILY, 'WEEKLY' => self::WEEKLY, 'MONTHLY' => self::MONTHLY, 'YEARLY' => self::YEARLY ); // weekdays numbered from 1 (ISO-8601 or date('N')) public static $week_days = array( 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 7 ); // original rule protected $rule = array( 'DTSTART' => null, 'FREQ' => null, 'UNTIL' => null, 'COUNT' => null, 'INTERVAL' => 1, 'BYSECOND' => null, 'BYMINUTE' => null, 'BYHOUR' => null, 'BYDAY' => null, 'BYMONTHDAY' => null, 'BYYEARDAY' => null, 'BYWEEKNO' => null, 'BYMONTH' => null, 'BYSETPOS' => null, 'WKST' => 'MO' ); // parsed and validated values protected $dtstart = null; protected $freq = null; protected $until = null; protected $count = null; protected $interval = null; protected $bysecond = null; protected $byminute = null; protected $byhour = null; protected $byweekday = null; protected $byweekday_nth = null; protected $bymonthday = null; protected $bymonthday_negative = null; protected $byyearday = null; protected $byweekno = null; protected $bymonth = null; protected $bysetpos = null; protected $wkst = null; protected $timeset = null; // cache variables protected $total = null; protected $cache = array(); /////////////////////////////////////////////////////////////////////////////// // Public interface /** * The constructor needs the entire rule at once. * There is no setter after the class has been instanciated, * because in order to validate some BYXXX parts, we need to know * the value of some other parts (FREQ or other BXXX parts). */ public function __construct($parts) { if ( is_string($parts) ) { $parts = self::parseRfcString($parts); } elseif ( is_array($parts) ) { $parts = array_change_key_case($parts, CASE_UPPER); } else { throw new \InvalidArgumentException(sprintf( 'The first argument must be a string or an array (%s provided)', gettype($parts) )); } // validate extra parts $unsupported = array_diff_key($parts, $this->rule); if ( ! empty($unsupported) ) { throw new \InvalidArgumentException( 'Unsupported parameter(s): ' .implode(',',array_keys($unsupported)) ); } $parts = array_merge($this->rule, $parts); $this->rule = $parts; // save original rule // WKST $parts['WKST'] = strtoupper($parts['WKST']); if ( ! array_key_exists($parts['WKST'], self::$week_days) ) { throw new \InvalidArgumentException( 'The WKST rule part must be one of the following: ' .implode(', ',array_keys(self::$week_days)) ); } $this->wkst = self::$week_days[$parts['WKST']]; // FREQ if ( is_integer($parts['FREQ']) ) { if ( $parts['FREQ'] > self::SECONDLY || $parts['FREQ'] < self::YEARLY ) { throw new \InvalidArgumentException( 'The FREQ rule part must be one of the following: ' .implode(', ',array_keys(self::$frequencies)) ); } $this->freq = $parts['FREQ']; } else { // string $parts['FREQ'] = strtoupper($parts['FREQ']); if ( ! array_key_exists($parts['FREQ'], self::$frequencies) ) { throw new \InvalidArgumentException( 'The FREQ rule part must be one of the following: ' .implode(', ',array_keys(self::$frequencies)) ); } $this->freq = self::$frequencies[$parts['FREQ']]; } // INTERVAL $parts['INTERVAL'] = (int) $parts['INTERVAL']; if ( $parts['INTERVAL'] < 1 ) { throw new \InvalidArgumentException( 'The INTERVAL rule part must be a positive integer (> 0)' ); } $this->interval = (int) $parts['INTERVAL']; // DTSTART if ( not_empty($parts['DTSTART']) ) { try { $this->dtstart = self::parseDate($parts['DTSTART']); } catch (\Exception $e) { throw new \InvalidArgumentException( 'Failed to parse DTSTART ; it must be a valid date, timestamp or \DateTime object' ); } } else { $this->dtstart = new \DateTime(); } // UNTIL (optional) if ( not_empty($parts['UNTIL']) ) { try { $this->until = self::parseDate($parts['UNTIL']); } catch (\Exception $e) { throw new \InvalidArgumentException( 'Failed to parse UNTIL ; it must be a valid date, timestamp or \DateTime object' ); } } // COUNT (optional) if ( not_empty($parts['COUNT']) ) { $parts['COUNT'] = (int) $parts['COUNT']; if ( $parts['COUNT'] < 1 ) { throw new \InvalidArgumentException('COUNT must be a positive integer (> 0)'); } $this->count = (int) $parts['COUNT']; } if ( $this->until && $this->count ) { throw new \InvalidArgumentException('The UNTIL or COUNT rule parts MUST NOT occur in the same rule'); } // infer necessary BYXXX rules from DTSTART, if not provided if ( ! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY']) || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY'])) ) { switch ( $this->freq ) { case self::YEARLY: if ( ! not_empty($parts['BYMONTH']) ) { $parts['BYMONTH'] = array((int) $this->dtstart->format('m')); } $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j')); break; case self::MONTHLY: $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j')); break; case self::WEEKLY: $parts['BYDAY'] = array(array_search($this->dtstart->format('N'), self::$week_days)); break; } } // BYDAY (translated to byweekday for convenience) if ( not_empty($parts['BYDAY']) ) { if ( ! is_array($parts['BYDAY']) ) { $parts['BYDAY'] = explode(',',$parts['BYDAY']); } $this->byweekday = array(); $this->byweekday_nth = array(); foreach ( $parts['BYDAY'] as $value ) { $value = trim(strtoupper($value)); $valid = preg_match('/^([+-]?[0-9]+)?([A-Z]{2})$/', $value, $matches); if ( ! $valid || (not_empty($matches[1]) && ($matches[1] == 0 || $matches[1] > 53 || $matches[1] < -53)) || ! array_key_exists($matches[2], self::$week_days) ) { throw new \InvalidArgumentException('Invalid BYDAY value: '.$value); } if ( $matches[1] ) { $this->byweekday_nth[] = array(self::$week_days[$matches[2]], (int)$matches[1]); } else { $this->byweekday[] = self::$week_days[$matches[2]]; } } if ( ! empty($this->byweekday_nth) ) { if ( ! ($this->freq === self::MONTHLY || $this->freq === self::YEARLY) ) { throw new \InvalidArgumentException('The BYDAY rule part MUST NOT be specified with a numeric value when the FREQ rule part is not set to MONTHLY or YEARLY.'); } if ( $this->freq === self::YEARLY && not_empty($parts['BYWEEKNO']) ) { throw new \InvalidArgumentException('The BYDAY rule part MUST NOT be specified with a numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO rule part is specified.'); } } } // The BYMONTHDAY rule part specifies a COMMA-separated list of days // of the month. Valid values are 1 to 31 or -31 to -1. For // example, -10 represents the tenth to the last day of the month. // The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule // part is set to WEEKLY. if ( not_empty($parts['BYMONTHDAY']) ) { if ( $this->freq === self::WEEKLY ) { throw new \InvalidArgumentException('The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule part is set to WEEKLY.'); } if ( ! is_array($parts['BYMONTHDAY']) ) { $parts['BYMONTHDAY'] = explode(',',$parts['BYMONTHDAY']); } $this->bymonthday = array(); $this->bymonthday_negative = array(); foreach ( $parts['BYMONTHDAY'] as $value ) { $value = (int) $value; if ( ! $value || $value < -31 || $value > 31 ) { throw new \InvalidArgumentException('Invalid BYMONTHDAY value: '.$value.' (valid values are 1 to 31 or -31 to -1)'); } if ( $value < 0 ) { $this->bymonthday_negative[] = $value; } else { $this->bymonthday[] = $value; } } } if ( not_empty($parts['BYYEARDAY']) ) { if ( $this->freq === self::DAILY || $this->freq === self::WEEKLY || $this->freq === self::MONTHLY ) { throw new \InvalidArgumentException('The BYYEARDAY rule part MUST NOT be specified when the FREQ rule part is set to DAILY, WEEKLY, or MONTHLY.'); } if ( ! is_array($parts['BYYEARDAY']) ) { $parts['BYYEARDAY'] = explode(',',$parts['BYYEARDAY']); } $this->bysetpos = array(); foreach ( $parts['BYYEARDAY'] as $value ) { $value = (int) $value; if ( ! $value || $value < -366 || $value > 366 ) { throw new \InvalidArgumentException('Invalid BYSETPOS value: '.$value.' (valid values are 1 to 366 or -366 to -1)'); } $this->byyearday[] = $value; } } // BYWEEKNO if ( not_empty($parts['BYWEEKNO']) ) { if ( $this->freq !== self::YEARLY ) { throw new \InvalidArgumentException('The BYWEEKNO rule part MUST NOT be used when the FREQ rule part is set to anything other than YEARLY.'); } if ( ! is_array($parts['BYWEEKNO']) ) { $parts['BYWEEKNO'] = explode(',',$parts['BYWEEKNO']); } $this->byweekno = array(); foreach ( $parts['BYWEEKNO'] as $value ) { $value = (int) $value; if ( ! $value || $value < -53 || $value > 53 ) { throw new \InvalidArgumentException('Invalid BYWEEKNO value: '.$value.' (valid values are 1 to 53 or -53 to -1)'); } $this->byweekno[] = $value; } } // The BYMONTH rule part specifies a COMMA-separated list of months // of the year. Valid values are 1 to 12. if ( not_empty($parts['BYMONTH']) ) { if ( ! is_array($parts['BYMONTH']) ) { $parts['BYMONTH'] = explode(',',$parts['BYMONTH']); } $this->bymonth = array(); foreach ( $parts['BYMONTH'] as $value ) { $value = (int) $value; if ( $value < 1 || $value > 12 ) { throw new \InvalidArgumentException('Invalid BYMONTH value: '.$value); } $this->bymonth[] = $value; } } if ( not_empty($parts['BYSETPOS']) ) { if ( ! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY']) || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY']) || not_empty($parts['BYMONTH']) || not_empty($parts['BYHOUR']) || not_empty($parts['BYMINUTE']) || not_empty($parts['BYSECOND'])) ) { throw new \InvalidArgumentException('The BYSETPOS rule part MUST only be used in conjunction with another BYxxx rule part.'); } if ( ! is_array($parts['BYSETPOS']) ) { $parts['BYSETPOS'] = explode(',',$parts['BYSETPOS']); } $this->bysetpos = array(); foreach ( $parts['BYSETPOS'] as $value ) { $value = (int) $value; if ( ! $value || $value < -366 || $value > 366 ) { throw new \InvalidArgumentException('Invalid BYSETPOS value: '.$value.' (valid values are 1 to 366 or -366 to -1)'); } $this->bysetpos[] = $value; } } if ( not_empty($parts['BYHOUR']) ) { if ( ! is_array($parts['BYHOUR']) ) { $parts['BYHOUR'] = explode(',',$parts['BYHOUR']); } $this->byhour = array(); foreach ( $parts['BYHOUR'] as $value ) { $value = (int) $value; if ( $value < 0 || $value > 23 ) { throw new \InvalidArgumentException('Invalid BYHOUR value: '.$value); } $this->byhour[] = $value; } sort($this->byhour); } elseif ( $this->freq < self::HOURLY ) { $this->byhour = array((int) $this->dtstart->format('G')); } if ( not_empty($parts['BYMINUTE']) ) { if ( ! is_array($parts['BYMINUTE']) ) { $parts['BYMINUTE'] = explode(',',$parts['BYMINUTE']); } $this->byminute = array(); foreach ( $parts['BYMINUTE'] as $value ) { $value = (int) $value; if ( $value < 0 || $value > 59 ) { throw new \InvalidArgumentException('Invalid BYMINUTE value: '.$value); } $this->byminute[] = $value; } sort($this->byminute); } elseif ( $this->freq < self::MINUTELY ) { $this->byminute = array((int) $this->dtstart->format('i')); } if ( not_empty($parts['BYSECOND']) ) { if ( ! is_array($parts['BYSECOND']) ) { $parts['BYSECOND'] = explode(',',$parts['BYSECOND']); } $this->bysecond = array(); foreach ( $parts['BYSECOND'] as $value ) { $value = (int) $value; // yes, "60" is a valid value, in (very rare) cases on leap seconds // December 31, 2005 23:59:60 UTC is a valid date... // so is 2012-06-30T23:59:60UTC if ( $value < 0 || $value > 60 ) { throw new \InvalidArgumentException('Invalid BYSECOND value: '.$value); } $this->bysecond[] = $value; } sort($this->bysecond); } elseif ( $this->freq < self::SECONDLY ) { $this->bysecond = array((int) $this->dtstart->format('s')); } if ( $this->freq < self::HOURLY ) { // for frequencies DAILY, WEEKLY, MONTHLY AND YEARLY, we can build // an array of every time of the day at which there should be an // occurrence - default, if no BYHOUR/BYMINUTE/BYSECOND are provided // is only one time, and it's the DTSTART time. This is a cached version // if you will, since it'll never change at these frequencies $this->timeset = array(); foreach ( $this->byhour as $hour ) { foreach ( $this->byminute as $minute ) { foreach ( $this->bysecond as $second ) { $this->timeset[] = array($hour,$minute,$second); } } } } } public function __toString() { return $this->rfcString(); } /** * Format a rule according to RFC 5545 * @return string */ public function rfcString() { $str = ''; if ( $this->rule['DTSTART'] ) { $str = sprintf( "DTSTART;TZID=%s:%s\nRRULE:", $this->dtstart->getTimezone()->getName(), $this->dtstart->format('Ymd\THis') ); } $parts = array(); foreach ( $this->rule as $key => $value ) { if ( $key === 'DTSTART' ) { continue; } if ( $key === 'INTERVAL' && $value == 1 ) { continue; } if ( $key === 'WKST' && $value === 'MO') { continue; } if ( $key === 'UNTIL' && $value ) { // for a reason that I do not understand, UNTIL seems to always // be in UTC (even when DTSTART includes TZID) $tmp = clone $this->until; $tmp->setTimezone(new \DateTimeZone('UTC')); $parts[] = 'UNTIL='.$tmp->format('Ymd\THis\Z'); continue; } if ( $value ) { if ( is_array($value) ) { $value = implode(',',$value); } $parts[] = strtoupper(str_replace(' ','',"$key=$value")); } } $str .= implode(';',$parts); return $str; } /** * Take a RFC 5545 string and returns an array (to be given to the constructor) * @return array */ static public function parseRfcString($string) { $parts = array(); $string = trim($string); foreach ( explode("\n", $string) as $line ) { $line = trim($line); if ( strpos($line,':') === false ) { $property_name = 'RRULE'; $property_value = $line; } else { list($property_name,$property_value) = explode(':',$line); } $tmp = explode(';',$property_name); $property_name = $tmp[0]; $property_params = array(); array_splice($tmp,0,1); foreach ( $tmp as $pair ) { if ( strpos($pair,'=') === false ) { throw new \InvalidArgumentException('Failed to parse RFC string, invlaid property parameters: '.$pair); } list($key,$value) = explode('=',$pair); $property_params[$key] = $value; } switch ( $property_name ) { case 'DTSTART': $tmp = null; if ( isset($property_params['TZID']) ) { $tmp = new \DateTimeZone($property_params['TZID']); } $parts['DTSTART'] = new \DateTime($property_value, $tmp); break; case 'RRULE': foreach ( explode(';',$property_value) as $pair ) { list($key, $value) = explode('=', $pair); if ( $key === 'UNTIL' ) { $value = new \DateTime($value); } $parts[$key] = $value; } break; default: throw new \InvalidArgumentException('Failed to parse RFC string, unsupported property: '.$property_name); } } return $parts; } /** * Clear the cache. Do NOT use while the class is iterating * @return $this */ public function clearCache() { $this->total = null; $this->cache = array(); return $this; } /////////////////////////////////////////////////////////////////////////////// // RRule interface /** * Return true if the rrule has an end condition, false otherwise * * @return bool */ public function isFinite() { return $this->count || $this->until; } /** * Return true if the rrule has no end condition (infite) * * @return bool */ public function isInfinite() { return ! $this->count && ! $this->until; } /** * Return all the occurrences in an array. * * For finite rules only. * * @return array */ public function getOccurrences() { if ( $this->isInfinite() ) { throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.'); } // cached version already computed if ( $this->total !== null ) { return $this->cache; } $res = array(); foreach ( $this as $occurrence ) { $res[] = $occurrence; } return $res; } /** * Return an array of all occurrences between two dates. * * @param date|null $begin Can be null to return all occurrences before $end * @param date|null $end Can be null to return all occurrences after $begin * * @return array */ public function getOccurrencesBetween($begin, $end) { if ( $begin !== null ) { $begin = self::parseDate($begin); } if ( $end !== null ) { $end = self::parseDate($end); } elseif ( $this->isInfinite() ) { throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.'); } $iterator = $this; if ( $this->total !== null ) { $iterator = $this->cache; } $res = array(); foreach ( $iterator as $occurrence ) { if ( $begin !== null && $occurrence < $begin ) { continue; } if ( $end !== null && $occurrence > $end ) { break; } $res[] = $occurrence; } return $res; } /** * Alias of occursAt * Because I think both are correct in English, aren't they? * @return bool */ public function occursOn($date) { return $this->occursAt($date); } /** * Return true if $date is an occurrence of the rule. * * This method will attempt to determine the result programmatically. * However depending on the BYXXX rule parts that have been set, it might * not always be possible. As a last resort, this method will loop * through all occurrences until $date. This will incurr some performance * penalty. * * @return bool */ public function occursAt($date) { $date = self::parseDate($date); if ( in_array($date, $this->cache) ) { // in the cache (whether cache is complete or not) return true; } elseif ( $this->total !== null ) { // cache complete and not in cache return false; } // let's start with the obvious if ( $date < $this->dtstart || ($this->until && $date > $this->until) ) { return false; } // now the BYXXX rules (expect BYSETPOS) if ( $this->byhour && ! in_array($date->format('G'), $this->byhour) ) { return false; } if ( $this->byminute && ! in_array((int) $date->format('i'), $this->byminute) ) { return false; } if ( $this->bysecond && ! in_array((int) $date->format('s'), $this->bysecond) ) { return false; } // we need some more variables before we continue list($year, $month, $day, $yearday, $weekday) = explode(' ',$date->format('Y n j z N')); $masks = array(); $masks['weekday_of_1st_yearday'] = date_create($year.'-01-01 00:00:00')->format('N'); $masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK, $masks['weekday_of_1st_yearday']-1); if ( is_leap_year($year) ) { $masks['year_len'] = 366; $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366; } else { $masks['year_len'] = 365; $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH; } $month_len = $masks['last_day_of_month'][$month] - $masks['last_day_of_month'][$month-1]; if ( $this->bymonth && ! in_array($month, $this->bymonth) ) { return false; } if ( $this->byweekday && ! in_array($weekday, $this->byweekday) ) { return false; } if ( $this->bymonthday || $this->bymonthday_negative ) { $monthday_negative = -1 * ($month_len - $day + 1); if ( ! in_array($day, $this->bymonthday) && ! in_array($monthday_negative, $this->bymonthday_negative) ) { return false; } } if ( $this->byyearday ) { // caution here, yearday starts from 0 ! $yearday_negative = -1*($masks['year_len'] - $yearday); if ( ! in_array($yearday+1, $this->byyearday) && ! in_array($yearday_negative, $this->byyearday) ) { return false; } } if ( $this->byweekday_nth ) { // we need to summon some magic here $this->buildNthWeekdayMask($year, $month, $day, $masks); if ( ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) { return false; } } if ( $this->byweekno ) { // more magic $this->buildWeeknoMask($year, $month, $day, $masks); if ( ! isset($masks['yearday_is_in_weekno'][$yearday]) ) { return false; } } // so now we have exhausted all the BYXXX rules (exept bysetpos), // we still need to consider frequency and interval list ($start_year, $start_month, $start_day) = explode('-',$this->dtstart->format('Y-m-d')); switch ( $this->freq ) { case self::YEARLY: if ( ($year - $start_year) % $this->interval !== 0 ) { return false; } break; case self::MONTHLY: // we need to count the number of months elapsed $diff = (12 - $start_month) + 12*($year - $start_year - 1) + $month; if ( ($diff % $this->interval) !== 0 ) { return false; } break; case self::WEEKLY: // count nb of days and divide by 7 to get number of weeks // we add some days to align dtstart with wkst $diff = $date->diff($this->dtstart); $diff = (int) (($diff->days + pymod($this->dtstart->format('N') - $this->wkst,7)) / 7); if ( $diff % $this->interval !== 0 ) { return false; } break; case self::DAILY: // count nb of days $diff = $date->diff($this->dtstart); if ( $diff->days % $this->interval !== 0 ) { return false; } break; // XXX: I'm not sure the 3 formulas below take the DST into account... case self::HOURLY: $diff = $date->diff($this->dtstart); $diff = $diff->h + $diff->days * 24; if ( $diff % $this->interval !== 0 ) { return false; } break; case self::MINUTELY: $diff = $date->diff($this->dtstart); $diff = $diff->i + $diff->h * 60 + $diff->days * 1440; if ( $diff % $this->interval !== 0 ) { return false; } break; case self::SECONDLY: $diff = $date->diff($this->dtstart); // XXX does not account for leap second (should it?) $diff = $diff->s + $diff->i * 60 + $diff->h * 3600 + $diff->days * 86400; if ( $diff % $this->interval !== 0 ) { return false; } break; throw new \Exception('Unimplemented frequency'); } // now we are left with 2 rules BYSETPOS and COUNT // // - I think BYSETPOS *could* be determined without loooping by considering // the current set, calculating all the occurrences of the current set // and determining the position of $date in the result set. // However I'm not convinced it's worth it. // // - I don't see any way to determine COUNT programmatically, because occurrences // might sometimes be dropped (e.g. a 29 Feb on a normal year, or during // the switch to DST) and not counted in the final set if ( ! $this->count && ! $this->bysetpos ) { return true; } // so... as a fallback we have to loop foreach ( $this as $occurrence ) { if ( $occurrence == $date ) { return true; // lucky you! } if ( $occurrence > $date ) { break; } } // we ended the loop without finding return false; } /////////////////////////////////////////////////////////////////////////////// // Iterator interface // Note: if cache is complete, we could probably avoid completely calling iterate() // and instead iterate directly on the $this->cache array protected $current = 0; protected $key = 0; public function rewind() { $this->current = $this->iterate(true); $this->key = 0; } public function current() { return $this->current; } public function key() { return $this->key; } public function next() { $this->current = $this->iterate(); $this->key += 1; } public function valid() { return $this->current !== null; } /////////////////////////////////////////////////////////////////////////////// // ArrayAccess interface public function offsetExists($offset) { return is_numeric($offset) && $offset >= 0 && $offset < count($this); } public function offsetGet($offset) { if ( isset($this->cache[$offset]) ) { // found in cache return $this->cache[$offset]; } elseif ( $this->total !== null ) { // cache complete and not found in cache return null; } // not in cache and cache not complete, we have to loop to find it $i = 0; foreach ( $this as $occurrence ) { if ( $i == $offset ) { return $occurrence; } $i++; if ( $i > $offset ) { break; } } return null; } public function offsetSet($offset, $value) { throw new \LogicException('Setting a Date in a RRule is not supported'); } public function offsetUnset($offset) { throw new \LogicException('Unsetting a Date in a RRule is not supported'); } /////////////////////////////////////////////////////////////////////////////// // Countable interface /** * Returns the number of occurrences in this rule. It will have go * through the whole recurrence, if this hasn't been done before, which * introduces a performance penality. * @return int */ public function count() { if ( $this->isInfinite() ) { throw new \LogicException('Cannot count an infinite recurrence rule.'); } if ( $this->total === null ) { foreach ( $this as $occurrence ) {} } return $this->total; } /////////////////////////////////////////////////////////////////////////////// // private methods // where all the magic happens /** * Convert any date into a DateTime object. * @throws InvalidArgumentException on error * @param mixed $date * @return DateTime */ static public function parseDate($date) { // DateTimeInterface is only on PHP 5.5+, and includes DateTimeImmutable if ( ! $date instanceof \DateTime && ! $date instanceof \DateTimeInterface ) { try { if ( is_integer($date) ) { $date = \DateTime::createFromFormat('U',$date); } else { $date = new \DateTime($date); } } catch (\Exception $e) { throw new \InvalidArgumentException( "Failed to parse the date" ); } } return $date; } /** * This method returns an array of days of the year (numbered from 0 to 365) * of the current timeframe (year, month, week, day) containing the current date * @return array */ protected function getDaySet($year, $month, $day, array $masks) { switch ( $this->freq ) { case self::YEARLY: return range(0,$masks['year_len']-1); case self::MONTHLY: $start = $masks['last_day_of_month'][$month-1]; $stop = $masks['last_day_of_month'][$month]; return range($start, $stop - 1); case self::WEEKLY: // on first iteration, the first week will not be complete // we don't backtrack to the first day of the week, to avoid // crossing year boundary in reverse (i.e. if the week started // during the previous year), because that would generate // negative indexes (which would not work with the masks) $set = array(); $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z'); $start = $i; for ( $j = 0; $j < 7; $j++ ) { $set[] = $i; $i += 1; if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) { break; } } return $set; case self::DAILY: case self::HOURLY: case self::MINUTELY: case self::SECONDLY: $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z'); return array($i); } } /** * Some serious magic is happening here. * This method will calculate the yeardays corresponding to each Nth weekday * (in BYDAY rule part). * For example, in Jan 1998, in a MONTHLY interval, "1SU,-1SU" (first Sunday * and last Sunday) would be transformed into [3=>true,24=>true] because * the first Sunday of Jan 1998 is yearday 3 (counting from 0) and the * last Sunday of Jan 1998 is yearday 24 (counting from 0). * @return null (modifies $mask parameter) */ protected function buildNthWeekdayMask($year, $month, $day, array & $masks) { $masks['yearday_is_nth_weekday'] = array(); if ( $this->byweekday_nth ) { $ranges = array(); if ( $this->freq == self::YEARLY ) { if ( $this->bymonth ) { foreach ( $this->bymonth as $bymonth ) { $ranges[] = array( $masks['last_day_of_month'][$bymonth - 1], $masks['last_day_of_month'][$bymonth] - 1 ); } } else { $ranges = array(array(0, $masks['year_len'] - 1)); } } elseif ( $this->freq == self::MONTHLY ) { $ranges[] = array( $masks['last_day_of_month'][$month - 1], $masks['last_day_of_month'][$month] - 1 ); } if ( $ranges ) { // Weekly frequency won't get here, so we may not // care about cross-year weekly periods. foreach ( $ranges as $tmp ) { list($first, $last) = $tmp; foreach ( $this->byweekday_nth as $tmp ) { list($weekday, $nth) = $tmp; if ( $nth < 0 ) { $i = $last + ($nth + 1) * 7; $i = $i - pymod($masks['yearday_to_weekday'][$i] - $weekday, 7); } else { $i = $first + ($nth - 1) * 7; $i = $i + (7 - $masks['yearday_to_weekday'][$i] + $weekday) % 7; } if ( $i >= $first && $i <= $last ) { $masks['yearday_is_nth_weekday'][$i] = true; } } } } } } /** * More serious magic. * This method calculates the yeardays corresponding to the week number * (in the WEEKNO rule part). * Because weeks can cross year boundaries (that is, week #1 can start the * previous year, and week 52/53 can continue till the next year), the * algorithm is quite long. * @return null (modifies $mask) */ protected function buildWeeknoMask($year, $month, $day, array & $masks) { $masks['yearday_is_in_weekno'] = array(); // calculate the index of the first wkst day of the year // 0 means the first day of the year is the wkst day (e.g. wkst is Monday and Jan 1st is a Monday) // n means there is n days before the first wkst day of the year. // if n >= 4, this is the first day of the year (even though it started the year before) $first_wkst = (7 - $masks['weekday_of_1st_yearday'] + $this->wkst) % 7; if( $first_wkst >= 4 ) { $first_wkst_offset = 0; // Number of days in the year, plus the days we got from last year. $nb_days = $masks['year_len'] + $masks['weekday_of_1st_yearday'] - $this->wkst; // $nb_days = $masks['year_len'] + pymod($masks['weekday_of_1st_yearday'] - $this->wkst,7); } else { $first_wkst_offset = $first_wkst; // Number of days in the year, minus the days we left in last year. $nb_days = $masks['year_len'] - $first_wkst; } $nb_weeks = (int) ($nb_days / 7) + (int) (($nb_days % 7) / 4); // alright now we now when the first week starts // and the number of weeks of the year // so we can generate a map of every yearday that are in the weeks // specified in byweekno foreach ( $this->byweekno as $n ) { if ( $n < 0 ) { $n = $n + $nb_weeks + 1; } if ( $n <= 0 || $n > $nb_weeks ) { continue; } if ( $n > 1 ) { $i = $first_wkst_offset + ($n - 1) * 7; if ( $first_wkst_offset != $first_wkst ) { // if week #1 started the previous year // realign the start of the week $i = $i - (7 - $first_wkst); } } else { $i = $first_wkst_offset; } // now add 7 days into the resultset, stopping either at 7 or // if we reach wkst before (in the case of short first week of year) for ( $j = 0; $j < 7; $j++ ) { $masks['yearday_is_in_weekno'][$i] = true; $i = $i + 1; if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) { break; } } } // if we asked for week #1, it's possible that the week #1 of next year // already started this year. Therefore we need to return also the matching // days of next year. if ( in_array(1, $this->byweekno) ) { // Check week number 1 of next year as well // TODO: Check -numweeks for next year. $i = $first_wkst_offset + $nb_weeks * 7; if ( $first_wkst_offset != $first_wkst ) { $i = $i - (7 - $first_wkst); } if ( $i < $masks['year_len'] ) { // If week starts in next year, we don't care about it. for ( $j = 0; $j < 7; $j++ ) { $masks['yearday_is_in_weekno'][$i] = true; $i += 1; if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) { break; } } } } if ( $first_wkst_offset ) { // Check last week number of last year as well. // If first_wkst_offset is 0, either the year started on week start, // or week number 1 got days from last year, so there are no // days from last year's last week number in this year. if ( ! in_array(-1, $this->byweekno) ) { $weekday_of_1st_yearday = date_create(($year-1).'-01-01 00:00:00')->format('N'); $first_wkst_offset_last_year = (7 - $weekday_of_1st_yearday + $this->wkst) % 7; $last_year_len = 365 + is_leap_year($year - 1); if ( $first_wkst_offset_last_year >= 4) { $first_wkst_offset_last_year = 0; $nb_weeks_last_year = 52 + (int) ((($last_year_len + ($weekday_of_1st_yearday - $this->wkst) % 7) % 7) / 4); } else { $nb_weeks_last_year = 52 + (int) ((($masks['year_len'] - $first_wkst_offset) % 7) /4); } } else { $nb_weeks_last_year = -1; } if ( in_array($nb_weeks_last_year, $this->byweekno) ) { for ( $i = 0; $i < $first_wkst_offset; $i++ ) { $masks['yearday_is_in_weekno'][$i] = true; } } } } /** * This builds an array of every time of the day that matches the BYXXX time * criteria. It will only process $this->frequency at one time. So: * - for HOURLY frequencies it builds the minutes and second of the given hour * - for MINUTELY frequencies it builds the seconds of the given minute * - for SECONDLY frequencies, it returns an array with one element * * This method is called everytime an increment of at least one hour is made. * @return array */ protected function getTimeSet($hour, $minute, $second) { switch ( $this->freq ) { case self::HOURLY: $set = array(); foreach ( $this->byminute as $minute ) { foreach ( $this->bysecond as $second ) { // should we use another type? $set[] = array($hour, $minute, $second); } } // sort ? return $set; case self::MINUTELY: $set = array(); foreach ( $this->bysecond as $second ) { // should we use another type? $set[] = array($hour, $minute, $second); } // sort ? return $set; case self::SECONDLY: return array(array($hour, $minute, $second)); default: throw new \LogicException('getTimeSet called with an invalid frequency'); } } /** * Variables for iterate() method, that will persist to allow iterate() * to resume where it stopped. For PHP >= 5.5, these would be local variables * inside a generator method using yield. However since we are compatible with * PHP 5.3 and 5.4, they have to be implemented this way. * * The original implementation used static local variables inside the class * method, which I think was cleaner scope-wise, but sadly this didn't work * when multiple instances of RRule existed and are iterated at the same time * (such as in a ruleset) * * DO NOT USE OUTSIDE OF iterate() */ private $_year = null; private $_month = null; private $_day = null; private $_hour = null; private $_minute = null; private $_second = null; private $_dayset = null; private $_masks = null; private $_timeset = null; private $_dtstart = null; private $_total = 0; private $_use_cache = true; /** * This is the main method, where all of the magic happens. * * This method is a generator that works for PHP 5.3/5.4 (using static variables) * * The main idea is: a brute force made fast by not relying on date() functions * * There is one big loop that examines every interval of the given frequency * (so every day, every week, every month or every year), constructs an * array of all the yeardays of the interval (for daily frequencies, the array * only has one element, for weekly 7, and so on), and then filters out any * day that do no match BYXXX parts. * * The algorithm does not try to be "smart" in calculating the increment of * the loop. That is, for a rule like "every day in January for 10 years" * the algorithm will loop through every day of the year, each year, generating * some 3650 iterations (+ some to account for the leap years). * This is a bit counter-intuitive, as it is obvious that the loop could skip * all the days in February till December since they are never going to match. * * Fortunately, this approach is still super fast because it doesn't rely * on date() or DateTime functions, and instead does all the date operations * manually, either arithmetically or using arrays as converters. * * Another quirk of this approach is that because the granularity is by day, * higher frequencies (hourly, minutely and secondly) have to have * their own special loops within the main loop, making the whole thing quite * convoluted. * Moreover, at such frequencies, the brute-force approach starts to really * suck. For example, a rule like * "Every minute, every Jan 1st between 10:00 and 10:59, for 10 years" * requires a tremendous amount of useless iterations to jump from Jan 1st 10:59 * at year 1 to Jan 1st 10.00 at year 2. * * In order to make a "smart jump", we would have to have a way to determine * the gap between the next occurence arithmetically. I think that would require * to analyze each "BYXXX" rule part that "Limit" the set (see the RFC page 43) * at the given frequency. For example, a YEARLY frequency doesn't need "smart * jump" at all; MONTHLY and WEEKLY frequencies only need to check BYMONTH; * DAILY frequency needs to check BYMONTH, BYMONTHDAY and BYDAY, and so on. * The check probably has to be done in reverse order, e.g. for DAILY frequencies * attempt to jump to the next weekday (BYDAY) or next monthday (BYMONTHDAY) * (I don't know yet which one first), and then if that results in a change of * month, attempt to jump to the next BYMONTH, and so on. * * @param $reset (bool) Whether to restart the iteration, or keep going * @return \DateTime|null */ protected function iterate($reset = false) { // for readability's sake, and because scope of the variables should be local anyway $year = & $this->_year; $month = & $this->_month; $day = & $this->_day; $hour = & $this->_hour; $minute = & $this->_minute; $second = & $this->_second; $dayset = & $this->_dayset; $masks = & $this->_masks; $timeset = & $this->_timeset; $dtstart = & $this->_dtstart; $total = & $this->_total; $use_cache = & $this->_use_cache; if ( $reset ) { $this->_year = $this->_month = $this->_day = null; $this->_hour = $this->_minute = $this->_second = null; $this->_dayset = $this->_masks = $this->_timeset = null; $this->_dtstart = null; $this->_total = 0; $this->_use_cache = true; reset($this->cache); } // go through the cache first if ( $use_cache ) { while ( ($occurrence = current($this->cache)) !== false ) { // echo "Cache hit\n"; $dtstart = $occurrence; next($this->cache); $total += 1; return $occurrence; } reset($this->cache); // now set use_cache to false to skip the all thing on next iteration // and start filling the cache instead $use_cache = false; // if the cache as been used up completely and we now there is nothing else if ( $total === $this->total ) { // echo "Cache used up, nothing else to compute\n"; return null; } // echo "Cache used up with occurrences remaining\n"; if ( $dtstart ) { $dtstart = clone $dtstart; // since DateTime is not immutable, avoid any problem // so we skip the last occurrence of the cache if ( $this->freq === self::SECONDLY ) { $dtstart->modify('+'.$this->interval.'second'); } else { $dtstart->modify('+1second'); } } } // stop once $total has reached COUNT if ( $this->count && $total >= $this->count ) { $this->total = $total; return null; } if ( $dtstart === null ) { $dtstart = clone $this->dtstart; } if ( $year === null ) { if ( $this->freq === self::WEEKLY ) { // we align the start date to the WKST, so we can then // simply loop by adding +7 days. The Python lib does some // calculation magic at the end of the loop (when incrementing) // to realign on first pass. $tmp = clone $dtstart; $tmp->modify('-'.pymod($this->dtstart->format('N') - $this->wkst,7).'days'); list($year,$month,$day,$hour,$minute,$second) = explode(' ',$tmp->format('Y n j G i s')); unset($tmp); } else { list($year,$month,$day,$hour,$minute,$second) = explode(' ',$dtstart->format('Y n j G i s')); } // remove leading zeros $minute = (int) $minute; $second = (int) $second; } // we initialize the timeset if ( $timeset == null ) { if ( $this->freq < self::HOURLY ) { // daily, weekly, monthly or yearly // we don't need to calculate a new timeset $timeset = $this->timeset; } else { // initialize empty if it's not going to occurs on the first iteration if ( ($this->freq >= self::HOURLY && $this->byhour && ! in_array($hour, $this->byhour)) || ($this->freq >= self::MINUTELY && $this->byminute && ! in_array($minute, $this->byminute)) || ($this->freq >= self::SECONDLY && $this->bysecond && ! in_array($second, $this->bysecond)) ) { $timeset = array(); } else { $timeset = $this->getTimeSet($hour, $minute, $second); } } } // while (true) { $max_cycles = self::$REPEAT_CYCLES[$this->freq <= self::DAILY ? $this->freq : self::DAILY]; for ( $i = 0; $i < $max_cycles; $i++ ) { // 1. get an array of all days in the next interval (day, month, week, etc.) // we filter out from this array all days that do not match the BYXXX conditions // to speed things up, we use days of the year (day numbers) instead of date if ( $dayset === null ) { // rebuild the various masks and converters // these arrays will allow fast date operations // without relying on date() methods if ( empty($masks) || $masks['year'] != $year || $masks['month'] != $month ) { $masks = array('year' => '','month'=>''); // only if year has changed if ( $masks['year'] != $year ) { $masks['leap_year'] = is_leap_year($year); $masks['year_len'] = 365 + (int) $masks['leap_year']; $masks['next_year_len'] = 365 + is_leap_year($year + 1); $masks['weekday_of_1st_yearday'] = date_create($year."-01-01 00:00:00")->format('N'); $masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK, $masks['weekday_of_1st_yearday']-1); if ( $masks['leap_year'] ) { $masks['yearday_to_month'] = self::$MONTH_MASK_366; $masks['yearday_to_monthday'] = self::$MONTHDAY_MASK_366; $masks['yearday_to_monthday_negative'] = self::$NEGATIVE_MONTHDAY_MASK_366; $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366; } else { $masks['yearday_to_month'] = self::$MONTH_MASK; $masks['yearday_to_monthday'] = self::$MONTHDAY_MASK; $masks['yearday_to_monthday_negative'] = self::$NEGATIVE_MONTHDAY_MASK; $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH; } if ( $this->byweekno ) { $this->buildWeeknoMask($year, $month, $day, $masks); } } // everytime month or year changes if ( $this->byweekday_nth ) { $this->buildNthWeekdayMask($year, $month, $day, $masks); } $masks['year'] = $year; $masks['month'] = $month; } // calculate the current set $dayset = $this->getDaySet($year, $month, $day, $masks); $filtered_set = array(); foreach ( $dayset as $yearday ) { if ( $this->bymonth && ! in_array($masks['yearday_to_month'][$yearday], $this->bymonth) ) { continue; } if ( $this->byweekno && ! isset($masks['yearday_is_in_weekno'][$yearday]) ) { continue; } if ( $this->byyearday ) { if ( $yearday < $masks['year_len'] ) { if ( ! in_array($yearday + 1, $this->byyearday) && ! in_array(- $masks['year_len'] + $yearday,$this->byyearday) ) { continue; } } else { // if ( ($yearday >= $masks['year_len'] if ( ! in_array($yearday + 1 - $masks['year_len'], $this->byyearday) && ! in_array(- $masks['next_year_len'] + $yearday - $mask['year_len'], $this->byyearday) ) { continue; } } } if ( ($this->bymonthday || $this->bymonthday_negative) && ! in_array($masks['yearday_to_monthday'][$yearday], $this->bymonthday) && ! in_array($masks['yearday_to_monthday_negative'][$yearday], $this->bymonthday_negative) ) { continue; } if ( $this->byweekday && ! in_array($masks['yearday_to_weekday'][$yearday], $this->byweekday) ) { continue; } if ( $this->byweekday_nth && ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) { continue; } $filtered_set[] = $yearday; } $dayset = $filtered_set; // if BYSETPOS is set, we need to expand the timeset to filter by pos // so we make a special loop to return while generating if ( $this->bysetpos && $timeset ) { $filtered_set = array(); foreach ( $this->bysetpos as $pos ) { $n = count($timeset); if ( $pos < 0 ) { $pos = $n * count($dayset) + $pos; } else { $pos = $pos - 1; } $div = (int) ($pos / $n); // daypos $mod = $pos % $n; // timepos if ( isset($dayset[$div]) && isset($timeset[$mod]) ) { $yearday = $dayset[$div]; $time = $timeset[$mod]; // used as array key to ensure uniqueness $tmp = $year.':'.$yearday.':'.$time[0].':'.$time[1].':'.$time[2]; if ( ! isset($filtered_set[$tmp]) ) { $occurrence = \DateTime::createFromFormat( 'Y z', "$year $yearday", $this->dtstart->getTimezone() ); $occurrence->setTime($time[0], $time[1], $time[2]); $filtered_set[$tmp] = $occurrence; } } } sort($filtered_set); $dayset = $filtered_set; } } // 2. loop, generate a valid date, and return the result (fake "yield") // at the same time, we check the end condition and return null if // we need to stop if ( $this->bysetpos && $timeset ) { while ( ($occurrence = current($dayset)) !== false ) { // consider end conditions if ( $this->until && $occurrence > $this->until ) { $this->total = $total; // save total for count() cache return null; } next($dayset); if ( $occurrence >= $dtstart ) { // ignore occurrences before DTSTART $total += 1; $this->cache[] = $occurrence; return $occurrence; // yield } } } else { // normal loop, without BYSETPOS while ( ($yearday = current($dayset)) !== false ) { $occurrence = \DateTime::createFromFormat( 'Y z', "$year $yearday", $this->dtstart->getTimezone() ); while ( ($time = current($timeset)) !== false ) { $occurrence->setTime($time[0], $time[1], $time[2]); // consider end conditions if ( $this->until && $occurrence > $this->until ) { $this->total = $total; // save total for count() cache return null; } next($timeset); if ( $occurrence >= $dtstart ) { // ignore occurrences before DTSTART $total += 1; $this->cache[] = $occurrence; return $occurrence; // yield } } reset($timeset); next($dayset); } } // 3. we reset the loop to the next interval $days_increment = 0; switch ( $this->freq ) { case self::YEARLY: // we do not care about $month or $day not existing, // they are not used in yearly frequency $year = $year + $this->interval; break; case self::MONTHLY: // we do not care about the day of the month not existing // it is not used in monthly frequency $month = $month + $this->interval; if ( $month > 12 ) { $div = (int) ($month / 12); $mod = $month % 12; $month = $mod; $year = $year + $div; if ( $month == 0 ) { $month = 12; $year = $year - 1; } } break; case self::WEEKLY: $days_increment = $this->interval*7; break; case self::DAILY: $days_increment = $this->interval; break; // For the time frequencies, things are a little bit different. // We could just add "$this->interval" hours, minutes or seconds // to the current time, and go through the main loop again, // but since the frequencies are so high and needs to much iteration // it's actually a bit faster to have custom loops and only // call the DateTime method at the very end. case self::HOURLY: if ( empty($dayset) ) { // an empty set means that this day has been filtered out // by one of the BYXXX rule. So there is no need to // examine it any further, we know nothing is going to // occur anyway. // so we jump to one iteration right before next day $hour += ((int) ((23 - $hour) / $this->interval)) * $this->interval; } $found = false; for ( $j = 0; $j < self::$REPEAT_CYCLES[self::HOURLY]; $j++ ) { $hour += $this->interval; $div = (int) ($hour / 24); $mod = $hour % 24; if ( $div ) { $hour = $mod; $days_increment += $div; } if ( ! $this->byhour || in_array($hour, $this->byhour)) { $found = true; break; } } if ( ! $found ) { $this->total = $total; // save total for count cache return null; // stop the iterator } $timeset = $this->getTimeSet($hour, $minute, $second); break; case self::MINUTELY: if ( empty($dayset) ) { $minute += ((int) ((1439 - ($hour*60+$minute)) / $this->interval)) * $this->interval; } $found = false; for ( $j = 0; $j < self::$REPEAT_CYCLES[self::MINUTELY]; $j++ ) { $minute += $this->interval; $div = (int) ($minute / 60); $mod = $minute % 60; if ( $div ) { $minute = $mod; $hour += $div; $div = (int) ($hour / 24); $mod = $hour % 24; if ( $div ) { $hour = $mod; $days_increment += $div; } } if ( (! $this->byhour || in_array($hour, $this->byhour)) && (! $this->byminute || in_array($minute, $this->byminute)) ) { $found = true; break; } } if ( ! $found ) { $this->total = $total; // save total for count cache return null; // stop the iterator } $timeset = $this->getTimeSet($hour, $minute, $second); break; case self::SECONDLY: if ( empty($dayset) ) { $second += ((int) ((86399 - ($hour*3600 + $minute*60 + $second)) / $this->interval)) * $this->interval; } $found = false; for ( $j = 0; $j < self::$REPEAT_CYCLES[self::SECONDLY]; $j++ ) { $second += $this->interval; $div = (int) ($second / 60); $mod = $second % 60; if ( $div ) { $second = $mod; $minute += $div; $div = (int) ($minute / 60); $mod = $minute % 60; if ( $div ) { $minute = $mod; $hour += $div; $div = (int) ($hour / 24); $mod = $hour % 24; if ( $div ) { $hour = $mod; $days_increment += $div; } } } if ( ( ! $this->byhour || in_array($hour, $this->byhour) ) && ( ! $this->byminute || in_array($minute, $this->byminute) ) && ( ! $this->bysecond || in_array($second, $this->bysecond) ) ) { $found = true; break; } } if ( ! $found ) { $this->total = $total; // save total for count cache return null; // stop the iterator } $timeset = $this->getTimeSet($hour, $minute, $second); break; } // here we take a little shortcut from the Python version, by using DateTime if ( $days_increment ) { list($year,$month,$day) = explode('-',date_create("$year-$month-$day")->modify("+ $days_increment days")->format('Y-n-j')); } $dayset = null; // reset the loop } $this->total = $total; // save total for count cache return null; // stop the iterator } /////////////////////////////////////////////////////////////////////////////// // constants // Every mask is 7 days longer to handle cross-year weekly periods. protected static $MONTH_MASK = array( 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3, 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5, 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10, 11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11, 12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12, 1,1,1,1,1,1,1 ); protected static $MONTH_MASK_366 = array( 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3, 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5, 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10, 11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11, 12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12, 1,1,1,1,1,1,1 ); protected static $MONTHDAY_MASK = array( 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7 ); protected static $MONTHDAY_MASK_366 = array( 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, 1,2,3,4,5,6,7 ); protected static $NEGATIVE_MONTHDAY_MASK = array( -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25 ); protected static $NEGATIVE_MONTHDAY_MASK_366 = array( -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, -31,-30,-29,-28,-27,-26,-25 ); protected static $WEEKDAY_MASK = array( 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7, 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7 ); protected static $LAST_DAY_OF_MONTH_366 = array( 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366 ); protected static $LAST_DAY_OF_MONTH = array( 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ); /** * Maximum number of cycles after which a calendar repeats itself. This * is used to detect infinite loop: if no occurrence has been found * after this numbers of cycles, we can abort. * * The Gregorian calendar cycle repeat completely every 400 years * (146,097 days or 20,871 weeks). * A smaller cycle would be 28 years (1,461 weeks), but it only works * if there is no dropped leap year in between. * 2100 will be a dropped leap year, but I'm going to assume it's not * going to be a problem anytime soon, so at the moment I use the 28 years * cycle. */ protected static $REPEAT_CYCLES = array( // self::YEARLY => 400, // self::MONTHLY => 4800, // self::WEEKLY => 20871, // self::DAILY => 146097, // that's a lot of cycles, it takes a few seconds to detect infinite loop self::YEARLY => 28, self::MONTHLY => 336, self::WEEKLY => 1461, self::DAILY => 10227, self::HOURLY => 24, self::MINUTELY => 1440, self::SECONDLY => 86400 // that's a lot of cycles too ); /////////////////////////////////////////////////////////////////////////////// // i18n methods (could be moved into a separate class, since it's not always necessary) /** * Stores translations once loaded (so we don't have to reload them all the time) */ static protected $i18n = array(); static protected $intl_loaded = null; /** * Select a translation in $array based on the value of $n * @return string */ static protected function i18nSelect($array, $n) { if ( ! is_array($array) ) { return $array; } if ( array_key_exists($n, $array) ) { return $array[$n]; } elseif ( array_key_exists('else', $array) ) { return $array['else']; } else { return ''; // or throw? } } /** * Create a comma-separated list, with the last item added with an " and " * Example : Monday, Tuesday and Friday * @return string */ static protected function i18nList(array $array, $and = 'and') { if ( count($array) > 1 ) { $last = array_splice($array, -1); return sprintf( '%s %s %s', implode(', ',$array), $and, implode('',$last) ); } else { return $array[0]; } } /** * Load a translation file in memory. * Will load the basic first (e.g. "en") and then the region-specific if any * (e.g. "en_GB"), merging as necessary. * So region-specific translation files don't need to redefine every strings. */ static protected function i18nLoad($locale) { if ( ! preg_match('/^([a-z]{2})(_[A-Z]{2})?$/', $locale, $matches) ) { throw new \InvalidArgumentException('The locale option does not look like a valid locale: '.$locale); } $files = array(); if ( isset($matches[2]) ) { $files[] = $matches[1]; } $files[] = $locale; $result = array(); foreach ( $files as $file ) { $path = __DIR__."/i18n/$file.php"; if ( isset(self::$i18n[$file]) ) { $result = array_merge($result, self::$i18n[$file]); } elseif ( is_file($path) && is_readable($path) ) { self::$i18n[$file] = include $path; $result = array_merge($result, self::$i18n[$file]); } else { self::$i18n[$file] = array(); } } if ( empty($result) ) { throw new \InvalidArgumentException("Failed to load translations for '$locale'"); } return $result; } /** * Format a rule in a human readable string * intl extension is required. */ public function humanReadable(array $opt = array()) { if ( self::$intl_loaded === null ) { self::$intl_loaded = extension_loaded('intl'); } $default_opt = array( 'locale' => \Locale::getDefault(), 'date_formatter' => null ); if ( self::$intl_loaded ) { $default_opt['date_format'] = \IntlDateFormatter::SHORT; if ( $this->freq >= self::SECONDLY || not_empty($this->rule['BYSECOND']) ) { $default_opt['time_format'] = \IntlDateFormatter::LONG; } elseif ( $this->freq >= self::HOURLY || not_empty($this->rule['BYHOUR']) || not_empty($this->rule['BYMINUTE']) ) { $default_opt['time_format'] = \IntlDateFormatter::SHORT; } else { $default_opt['time_format'] = \IntlDateFormatter::NONE; } } $opt = array_merge($default_opt, $opt); if ( $opt['date_formatter'] && ! is_callable($opt['date_formatter']) ) { throw new \InvalidArgumentException('The option date_formatter must callable'); } if ( ! $opt['date_formatter'] ) { if ( self::$intl_loaded ) { $formatter = \IntlDateFormatter::create( $opt['locale'], $opt['date_format'], $opt['time_format'], $this->dtstart->getTimezone()->getName() ); $opt['date_formatter'] = function($date) use ($formatter) { return $formatter->format($date); }; } else { $opt['date_formatter'] = function($date) { return $date->format('Y-m-d H:i:s'); }; } } $i18n = self::i18nLoad($opt['locale']); $parts = array( 'freq' => '', 'byweekday' => '', 'bymonth' => '', 'byweekno' => '', 'byyearday' => '', 'bymonthday' => '', 'byhour' => '', 'byminute' => '', 'bysecond' => '', 'bysetpos' => '' ); // Every (INTERVAL) FREQ... $freq_str = strtolower(array_search($this->freq, self::$frequencies)); $parts['freq'] = strtr( self::i18nSelect($i18n[$freq_str], $this->interval), array( '%{interval}' => $this->interval ) ); // BYXXX rules if ( not_empty($this->rule['BYMONTH']) ) { $tmp = $this->bymonth; foreach ( $tmp as & $value) { $value = $i18n['months'][$value]; } $parts['bymonth'] = strtr(self::i18nSelect($i18n['bymonth'], count($tmp)), array( '%{months}' => self::i18nList($tmp, $i18n['and']) )); } if ( not_empty($this->rule['BYWEEKNO']) ) { // XXX negative week number are not great here $tmp = $this->byweekno; foreach ( $tmp as & $value ) { $value = strtr($i18n['nth_weekno'], array( '%{n}' => $value )); } $parts['byweekno'] = strtr( self::i18nSelect($i18n['byweekno'], count($this->byweekno)), array( '%{weeks}' => self::i18nList($tmp, $i18n['and']) ) ); } if ( not_empty($this->rule['BYYEARDAY']) ) { $tmp = $this->byyearday; foreach ( $tmp as & $value ) { $value = strtr(self::i18nSelect($i18n[$value>0?'nth_yearday':'-nth_yearday'],$value), array( '%{n}' => abs($value) )); } $tmp = strtr(self::i18nSelect($i18n['byyearday'], count($tmp)), array( '%{yeardays}' => self::i18nList($tmp, $i18n['and']) )); // ... of the month $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'yearly'), array( '%{x}' => $tmp )); $parts['byyearday'] = $tmp; } if ( not_empty($this->rule['BYMONTHDAY']) ) { $parts['bymonthday'] = array(); if ( $this->bymonthday ) { $tmp = $this->bymonthday; foreach ( $tmp as & $value ) { $value = strtr(self::i18nSelect($i18n['nth_monthday'],$value), array( '%{n}' => $value )); } $tmp = strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array( '%{monthdays}' => self::i18nList($tmp, $i18n['and']) )); // ... of the month $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array( '%{x}' => $tmp )); $parts['bymonthday'][] = $tmp; } if ( $this->bymonthday_negative ) { $tmp = $this->bymonthday_negative; foreach ( $tmp as & $value ) { $value = strtr(self::i18nSelect($i18n['-nth_monthday'],$value), array( '%{n}' => -$value )); } $tmp = strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array( '%{monthdays}' => self::i18nList($tmp, $i18n['and']) )); // ... of the month $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array( '%{x}' => $tmp )); $parts['bymonthday'][] = $tmp; } $parts['bymonthday'] = implode(' '.$i18n['and'],$parts['bymonthday']); } if ( not_empty($this->rule['BYDAY']) ) { $parts['byweekday'] = array(); if ( $this->byweekday ) { $tmp = $this->byweekday; foreach ( $tmp as & $value ) { $value = $i18n['weekdays'][$value]; } $parts['byweekday'][] = strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array( '%{weekdays}' => self::i18nList($tmp, $i18n['and']) )); } if ( $this->byweekday_nth ) { $tmp = $this->byweekday_nth; foreach ( $tmp as & $value ) { list($day, $n) = $value; $value = strtr(self::i18nSelect($i18n[$n>0?'nth_weekday':'-nth_weekday'], $n), array( '%{weekday}' => $i18n['weekdays'][$day], '%{n}' => abs($n) )); } $tmp = strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array( '%{weekdays}' => self::i18nList($tmp, $i18n['and']) )); // ... of the year|month $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], $freq_str), array( '%{x}' => $tmp )); $parts['byweekday'][] = $tmp; } $parts['byweekday'] = implode(' '.$i18n['and'],$parts['byweekday']); } if ( not_empty($this->rule['BYHOUR']) ) { $tmp = $this->byhour; foreach ( $tmp as &$value) { $value = strtr($i18n['nth_hour'], array( '%{n}' => $value )); } $parts['byhour'] = strtr(self::i18nSelect($i18n['byhour'],count($tmp)), array( '%{hours}' => self::i18nList($tmp, $i18n['and']) )); } if ( not_empty($this->rule['BYMINUTE']) ) { $tmp = $this->byminute; foreach ( $tmp as &$value) { $value = strtr($i18n['nth_minute'], array( '%{n}' => $value )); } $parts['byminute'] = strtr(self::i18nSelect($i18n['byminute'],count($tmp)), array( '%{minutes}' => self::i18nList($tmp, $i18n['and']) )); } if ( not_empty($this->rule['BYSECOND']) ) { $tmp = $this->bysecond; foreach ( $tmp as &$value) { $value = strtr($i18n['nth_second'], array( '%{n}' => $value )); } $parts['bysecond'] = strtr(self::i18nSelect($i18n['bysecond'],count($tmp)), array( '%{seconds}' => self::i18nList($tmp, $i18n['and']) )); } if ( $this->bysetpos ) { $tmp = $this->bysetpos; foreach ( $tmp as & $value ) { $value = strtr(self::i18nSelect($i18n[$value>0?'nth_setpos':'-nth_setpos'],$value), array( '%{n}' => abs($value) )); } $tmp = strtr(self::i18nSelect($i18n['bysetpos'], count($tmp)), array( '%{setpos}' => self::i18nList($tmp, $i18n['and']) )); $parts['bysetpos'] = $tmp; } // from X $parts['start'] = strtr($i18n['dtstart'], array( '%{date}' => $opt['date_formatter']($this->dtstart) )); // to X, or N times, or indefinitely if ( ! $this->until && ! $this->count ) { $parts['end'] = $i18n['infinite']; } elseif ( $this->until ) { $parts['end'] = strtr($i18n['until'], array( '%{date}' => $opt['date_formatter']($this->until) )); } elseif ( $this->count ) { $parts['end'] = strtr( self::i18nSelect($i18n['count'], $this->count), array( '%{count}' => $this->count ) ); } // $str = strtr('%{frequency}%{byday}%{start}%{end}', array( // '%{frequency}' => $parts['frequency'], // '%{start}' => $parts['start'], // '%{end}' => $parts['end'], // '%{byday}' => $parts['byday'], // )); $parts = array_filter($parts); $str = implode('',$parts); return $str; } }