vendor/rlanvin/php-rrule/src/RRule.php line 1116

Open in your IDE?
  1. <?php
  2. /**
  3.  * Licensed under the MIT license.
  4.  *
  5.  * For the full copyright and license information, please view the LICENSE file.
  6.  *
  7.  * @author RĂ©mi Lanvin <remi@cloudconnected.fr>
  8.  * @link https://github.com/rlanvin/php-rrule
  9.  */
  10. namespace RRule;
  11. /**
  12.  * Check that a variable is not empty.
  13.  *
  14.  * 0 and '0' are considered NOT empty.
  15.  *
  16.  * @param mixed $var Variable to be checked
  17.  * @return bool
  18.  */
  19. function not_empty($var)
  20. {
  21.     return ! empty($var) || $var === || $var === '0';
  22. }
  23. /**
  24.  * Python-like modulo.
  25.  *
  26.  * The % operator in PHP returns the remainder of a / b, but differs from
  27.  * some other languages in that the result will have the same sign as the
  28.  * dividend. For example, -1 % 8 == -1, whereas in some other languages
  29.  * (such as Python) the result would be 7. This function emulates the more
  30.  * correct modulo behavior, which is useful for certain applications such as
  31.  * calculating an offset index in a circular list.
  32.  *
  33.  * @param int $a The dividend.
  34.  * @param int $b The divisor.
  35.  *
  36.  * @return int $a % $b where the result is between 0 and $b
  37.  *   (either 0 <= x < $b
  38.  *     or $b < x <= 0, depending on the sign of $b).
  39.  *
  40.  * @copyright 2006 The Closure Library Authors.
  41.  */
  42. function pymod($a$b)
  43. {
  44.     $x $a $b;
  45.     // If $x and $b differ in sign, add $b to wrap the result to the correct sign.
  46.     return ($x $b 0) ? $x $b $x;
  47. }
  48. /**
  49.  * Check if a year is a leap year.
  50.  *
  51.  * @param int $year The year to be checked.
  52.  * @return bool
  53.  */
  54. function is_leap_year($year)
  55. {
  56.     if ( $year !== ) {
  57.         return false;
  58.     }
  59.     if ( $year 100 !== ) {
  60.         return true;
  61.     }
  62.     if ( $year 400 !== ) {
  63.         return false;
  64.     }
  65.     return true;
  66. }
  67. /**
  68.  * Implementation of RRULE as defined by RFC 5545 (iCalendar).
  69.  * Heavily based on python-dateutil/rrule
  70.  *
  71.  * Some useful terms to understand the algorithms and variables naming:
  72.  *
  73.  * - "yearday" = day of the year, from 0 to 365 (on leap years) - `date('z')`
  74.  * - "weekday" = day of the week (ISO-8601), from 1 (MO) to 7 (SU) - `date('N')`
  75.  * - "monthday" = day of the month, from 1 to 31
  76.  * - "wkst" = week start, the weekday (1 to 7) which is the first day of week.
  77.  *          Default is Monday (1). In some countries it's Sunday (7).
  78.  * - "weekno" = number of the week in the year (ISO-8601)
  79.  *
  80.  * CAREFUL with this bug: https://bugs.php.net/bug.php?id=62476
  81.  *
  82.  * @link https://tools.ietf.org/html/rfc5545
  83.  * @link https://labix.org/python-dateutil
  84.  */
  85. class RRule implements RRuleInterface
  86. {
  87.     const SECONDLY 7;
  88.     const MINUTELY 6;
  89.     const HOURLY 5;
  90.     const DAILY 4;
  91.     const WEEKLY 3;
  92.     const MONTHLY 2;
  93.     const YEARLY 1;
  94.     /**
  95.      * Frequency names.
  96.      * Used internally for conversion but public if a reference list is needed.
  97.      *
  98.      * @todo should probably be protected, with a static getter instead to avoid
  99.      * unintended modification.
  100.      *
  101.      * @var array The name as the key
  102.      */
  103.     public static $frequencies = array(
  104.         'SECONDLY' => self::SECONDLY,
  105.         'MINUTELY' => self::MINUTELY,
  106.         'HOURLY' => self::HOURLY,
  107.         'DAILY' => self::DAILY,
  108.         'WEEKLY' => self::WEEKLY,
  109.         'MONTHLY' => self::MONTHLY,
  110.         'YEARLY' => self::YEARLY
  111.     );
  112.     /** 
  113.      * Weekdays numbered from 1 (ISO-8601 or `date('N')`).
  114.      * Used internally but public if a reference list is needed.
  115.      *
  116.      * @todo should probably be protected, with a static getter instead
  117.      * to avoid unintended modification
  118.      *
  119.      * @var array The name as the key
  120.      */
  121.     public static $week_days = array(
  122.         'MO' => 1,
  123.         'TU' => 2,
  124.         'WE' => 3,
  125.         'TH' => 4,
  126.         'FR' => 5,
  127.         'SA' => 6,
  128.         'SU' => 7
  129.     );
  130.     /**
  131.      * @var array original rule
  132.      */
  133.     protected $rule = array(
  134.         'DTSTART' => null,
  135.         'FREQ' => null,
  136.         'UNTIL' => null,
  137.         'COUNT' => null,
  138.         'INTERVAL' => 1,
  139.         'BYSECOND' => null,
  140.         'BYMINUTE' => null,
  141.         'BYHOUR' => null,
  142.         'BYDAY' => null,
  143.         'BYMONTHDAY' => null,
  144.         'BYYEARDAY' => null,
  145.         'BYWEEKNO' => null,
  146.         'BYMONTH' => null,
  147.         'BYSETPOS' => null,
  148.         'WKST' => 'MO'
  149.     );
  150.     // parsed and validated values
  151.     protected $dtstart null;
  152.     protected $freq null;
  153.     protected $until null;
  154.     protected $count null;
  155.     protected $interval null;
  156.     protected $bysecond null;
  157.     protected $byminute null;
  158.     protected $byhour null;
  159.     protected $byweekday null;
  160.     protected $byweekday_nth null;
  161.     protected $bymonthday null;
  162.     protected $bymonthday_negative null;
  163.     protected $byyearday null;
  164.     protected $byweekno null;
  165.     protected $bymonth null;
  166.     protected $bysetpos null;
  167.     protected $wkst null;
  168.     protected $timeset null;
  169.     // cache variables
  170.     protected $total null;
  171.     protected $cache = array();
  172. ///////////////////////////////////////////////////////////////////////////////
  173. // Public interface
  174.     /**
  175.      * The constructor needs the entire rule at once.
  176.      * There is no setter after the class has been instanciated,
  177.      * because in order to validate some BYXXX parts, we need to know
  178.      * the value of some other parts (FREQ or other BXXX parts).
  179.      *
  180.      * @param mixed $parts An assoc array of parts, or a RFC string.
  181.      */
  182.     public function __construct($parts$dtstart null)
  183.     {
  184.         if ( is_string($parts) ) {
  185.             $parts RfcParser::parseRRule($parts$dtstart);
  186.             $parts array_change_key_case($partsCASE_UPPER);
  187.         }
  188.         else {
  189.             if ( $dtstart ) {
  190.                 throw new \InvalidArgumentException('$dtstart argument has no effect if not constructing from a string');
  191.             }
  192.             if ( is_array($parts) ) {
  193.                 $parts array_change_key_case($partsCASE_UPPER);
  194.             }
  195.             else {
  196.                 throw new \InvalidArgumentException(sprintf(
  197.                     'The first argument must be a string or an array (%s provided)',
  198.                     gettype($parts)
  199.                 ));
  200.             }
  201.         }
  202.         // validate extra parts
  203.         $unsupported array_diff_key($parts$this->rule);
  204.         if ( ! empty($unsupported) ) {
  205.             throw new \InvalidArgumentException(
  206.                 'Unsupported parameter(s): '
  207.                 .implode(',',array_keys($unsupported))
  208.             );
  209.         }
  210.         $parts array_merge($this->rule$parts);
  211.         $this->rule $parts// save original rule
  212.         // WKST
  213.         $parts['WKST'] = strtoupper($parts['WKST']);
  214.         if ( ! array_key_exists($parts['WKST'], self::$week_days) ) {
  215.             throw new \InvalidArgumentException(
  216.                 'The WKST rule part must be one of the following: '
  217.                 .implode(', ',array_keys(self::$week_days))
  218.             );
  219.         }
  220.         $this->wkst self::$week_days[$parts['WKST']];
  221.         // FREQ
  222.         if ( is_integer($parts['FREQ']) ) {
  223.             if ( $parts['FREQ'] > self::SECONDLY || $parts['FREQ'] < self::YEARLY ) {
  224.                 throw new \InvalidArgumentException(
  225.                     'The FREQ rule part must be one of the following: '
  226.                     .implode(', ',array_keys(self::$frequencies))
  227.                 );
  228.             }
  229.             $this->freq $parts['FREQ'];
  230.         }
  231.         else { // string
  232.             $parts['FREQ'] = strtoupper($parts['FREQ']);
  233.             if ( ! array_key_exists($parts['FREQ'], self::$frequencies) ) {
  234.                 throw new \InvalidArgumentException(
  235.                     'The FREQ rule part must be one of the following: '
  236.                     .implode(', ',array_keys(self::$frequencies))
  237.                 );
  238.             }
  239.             $this->freq self::$frequencies[$parts['FREQ']];
  240.         }
  241.         // INTERVAL
  242.         if ( filter_var($parts['INTERVAL'], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) === false ) {
  243.             throw new \InvalidArgumentException(
  244.                 'The INTERVAL rule part must be a positive integer (> 0)'
  245.             );
  246.         }
  247.         $this->interval = (int) $parts['INTERVAL'];
  248.         // DTSTART
  249.         if ( not_empty($parts['DTSTART']) ) {
  250.             try {
  251.                 $this->dtstart self::parseDate($parts['DTSTART']);
  252.             } catch (\Exception $e) {
  253.                 throw new \InvalidArgumentException(
  254.                     'Failed to parse DTSTART ; it must be a valid date, timestamp or \DateTime object'
  255.                 );
  256.             }
  257.         } 
  258.         else {
  259.             $this->dtstart = new \DateTime(); // for PHP 7.1+ this contains microseconds which causes many problems
  260.             if ( version_compare(PHP_VERSION'7.1.0') >= ) {
  261.                 // remove microseconds
  262.                 $this->dtstart->setTime(
  263.                     $this->dtstart->format('H'),
  264.                     $this->dtstart->format('i'),
  265.                     $this->dtstart->format('s'),
  266.                     0
  267.                 );
  268.             }
  269.         }
  270.         // UNTIL (optional)
  271.         if ( not_empty($parts['UNTIL']) ) {
  272.             try {
  273.                 $this->until self::parseDate($parts['UNTIL']);
  274.             } catch (\Exception $e) {
  275.                 throw new \InvalidArgumentException(
  276.                     'Failed to parse UNTIL ; it must be a valid date, timestamp or \DateTime object'
  277.                 );
  278.             }
  279.         }
  280.         // COUNT (optional)
  281.         if ( not_empty($parts['COUNT']) ) {
  282.             if ( filter_var($parts['COUNT'], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) === false ) {
  283.                 throw new \InvalidArgumentException('COUNT must be a positive integer (> 0)');
  284.             }
  285.             $this->count = (int) $parts['COUNT'];
  286.         }
  287.         if ( $this->until && $this->count ) {
  288.             throw new \InvalidArgumentException('The UNTIL or COUNT rule parts MUST NOT occur in the same rule');
  289.         }
  290.         // infer necessary BYXXX rules from DTSTART, if not provided
  291.         if ( ! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY']) || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY'])) ) {
  292.             switch ( $this->freq ) {
  293.                 case self::YEARLY:
  294.                     if ( ! not_empty($parts['BYMONTH']) ) {
  295.                         $parts['BYMONTH'] = array((int) $this->dtstart->format('m'));
  296.                     }
  297.                     $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j'));
  298.                     break;
  299.                 case self::MONTHLY:
  300.                     $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j'));
  301.                     break;
  302.                 case self::WEEKLY:
  303.                     $parts['BYDAY'] = array(array_search($this->dtstart->format('N'), self::$week_days));
  304.                     break;
  305.             }
  306.         }
  307.         // BYDAY (translated to byweekday for convenience)
  308.         if ( not_empty($parts['BYDAY']) ) {
  309.             if ( ! is_array($parts['BYDAY']) ) {
  310.                 $parts['BYDAY'] = explode(',',$parts['BYDAY']);
  311.             }
  312.             $this->byweekday = array();
  313.             $this->byweekday_nth = array();
  314.             foreach ( $parts['BYDAY'] as $value ) {
  315.                 $value trim(strtoupper($value));
  316.                 $valid preg_match('/^([+-]?[0-9]+)?([A-Z]{2})$/'$value$matches);
  317.                 if ( ! $valid || (not_empty($matches[1]) && ($matches[1] == || $matches[1] > 53 || $matches[1] < -53)) || ! array_key_exists($matches[2], self::$week_days) ) {
  318.                     throw new \InvalidArgumentException('Invalid BYDAY value: '.$value);
  319.                 }
  320.                 if ( $matches[1] ) {
  321.                     $this->byweekday_nth[] = array(self::$week_days[$matches[2]], (int)$matches[1]);
  322.                 }
  323.                 else {
  324.                     $this->byweekday[] = self::$week_days[$matches[2]];
  325.                 }
  326.             }
  327.             if ( ! empty($this->byweekday_nth) ) {
  328.                 if ( ! ($this->freq === self::MONTHLY || $this->freq === self::YEARLY) ) {
  329.                     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.');
  330.                 }
  331.                 if ( $this->freq === self::YEARLY && not_empty($parts['BYWEEKNO']) ) {
  332.                     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.');
  333.                 }
  334.             }
  335.         }
  336.         // The BYMONTHDAY rule part specifies a COMMA-separated list of days
  337.         // of the month.  Valid values are 1 to 31 or -31 to -1.  For
  338.         // example, -10 represents the tenth to the last day of the month.
  339.         // The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule
  340.         // part is set to WEEKLY.
  341.         if ( not_empty($parts['BYMONTHDAY']) ) {
  342.             if ( $this->freq === self::WEEKLY ) {
  343.                 throw new \InvalidArgumentException('The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule part is set to WEEKLY.');
  344.             }
  345.             if ( ! is_array($parts['BYMONTHDAY']) ) {
  346.                 $parts['BYMONTHDAY'] = explode(',',$parts['BYMONTHDAY']);
  347.             }
  348.             $this->bymonthday = array();
  349.             $this->bymonthday_negative = array();
  350.             foreach ( $parts['BYMONTHDAY'] as $value ) {
  351.                 if ( !$value || filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => -31'max_range' => 31))) === false ) {
  352.                     throw new \InvalidArgumentException('Invalid BYMONTHDAY value: '.$value.' (valid values are 1 to 31 or -31 to -1)');
  353.                 }
  354.                 $value = (int) $value;
  355.                 if ( $value ) {
  356.                     $this->bymonthday_negative[] = $value;
  357.                 }
  358.                 else {
  359.                     $this->bymonthday[] = $value;
  360.                 }
  361.             }
  362.         }
  363.         if ( not_empty($parts['BYYEARDAY']) ) {
  364.             if ( $this->freq === self::DAILY || $this->freq === self::WEEKLY || $this->freq === self::MONTHLY ) {
  365.                 throw new \InvalidArgumentException('The BYYEARDAY rule part MUST NOT be specified when the FREQ rule part is set to DAILY, WEEKLY, or MONTHLY.');
  366.             }
  367.             if ( ! is_array($parts['BYYEARDAY']) ) {
  368.                 $parts['BYYEARDAY'] = explode(',',$parts['BYYEARDAY']);
  369.             }
  370.             $this->bysetpos = array();
  371.             foreach ( $parts['BYYEARDAY'] as $value ) {
  372.                 if ( ! $value || filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => -366'max_range' => 366))) === false ) {
  373.                     throw new \InvalidArgumentException('Invalid BYSETPOS value: '.$value.' (valid values are 1 to 366 or -366 to -1)');
  374.                 }
  375.                 $this->byyearday[] = (int) $value;
  376.             }
  377.         }
  378.         // BYWEEKNO
  379.         if ( not_empty($parts['BYWEEKNO']) ) {
  380.             if ( $this->freq !== self::YEARLY ) {
  381.                 throw new \InvalidArgumentException('The BYWEEKNO rule part MUST NOT be used when the FREQ rule part is set to anything other than YEARLY.');
  382.             }
  383.             if ( ! is_array($parts['BYWEEKNO']) ) {
  384.                 $parts['BYWEEKNO'] = explode(',',$parts['BYWEEKNO']);
  385.             }
  386.             $this->byweekno = array();
  387.             foreach ( $parts['BYWEEKNO'] as $value ) {
  388.                 if ( ! $value || filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => -53'max_range' => 53))) === false ) {
  389.                     throw new \InvalidArgumentException('Invalid BYWEEKNO value: '.$value.' (valid values are 1 to 53 or -53 to -1)');
  390.                 }
  391.                 $this->byweekno[] = (int) $value;
  392.             }
  393.         }
  394.         // The BYMONTH rule part specifies a COMMA-separated list of months
  395.         // of the year.  Valid values are 1 to 12.
  396.         if ( not_empty($parts['BYMONTH']) ) {
  397.             if ( ! is_array($parts['BYMONTH']) ) {
  398.                 $parts['BYMONTH'] = explode(',',$parts['BYMONTH']);
  399.             }
  400.             $this->bymonth = array();
  401.             foreach ( $parts['BYMONTH'] as $value ) {
  402.                 if ( filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => 1'max_range' => 12))) === false ) {
  403.                     throw new \InvalidArgumentException('Invalid BYMONTH value: '.$value);
  404.                 }
  405.                 $this->bymonth[] = (int) $value;
  406.             }
  407.         }
  408.         if ( not_empty($parts['BYSETPOS']) ) {
  409.             if ( ! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY'])
  410.                 || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY'])
  411.                 || not_empty($parts['BYMONTH']) || not_empty($parts['BYHOUR'])
  412.                 || not_empty($parts['BYMINUTE']) || not_empty($parts['BYSECOND'])) ) {
  413.                 throw new \InvalidArgumentException('The BYSETPOS rule part MUST only be used in conjunction with another BYxxx rule part.');
  414.             }
  415.             if ( ! is_array($parts['BYSETPOS']) ) {
  416.                 $parts['BYSETPOS'] = explode(',',$parts['BYSETPOS']);
  417.             }
  418.             $this->bysetpos = array();
  419.             foreach ( $parts['BYSETPOS'] as $value ) {
  420.                 if ( ! $value || filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => -366'max_range' => 366))) === false ) {
  421.                     throw new \InvalidArgumentException('Invalid BYSETPOS value: '.$value.' (valid values are 1 to 366 or -366 to -1)');
  422.                 }
  423.                 $this->bysetpos[] = (int) $value;
  424.             }
  425.         }
  426.         if ( not_empty($parts['BYHOUR']) ) {
  427.             if ( ! is_array($parts['BYHOUR']) ) {
  428.                 $parts['BYHOUR'] = explode(',',$parts['BYHOUR']);
  429.             }
  430.             $this->byhour = array();
  431.             foreach ( $parts['BYHOUR'] as $value ) {
  432.                 if ( filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => 0'max_range' => 23))) === false ) {
  433.                     throw new \InvalidArgumentException('Invalid BYHOUR value: '.$value);
  434.                 }
  435.                 $this->byhour[] = (int) $value;
  436.             }
  437.             sort($this->byhour);
  438.         }
  439.         elseif ( $this->freq self::HOURLY ) { 
  440.             $this->byhour = array((int) $this->dtstart->format('G'));
  441.         }
  442.         if ( not_empty($parts['BYMINUTE']) ) {
  443.             if ( ! is_array($parts['BYMINUTE']) ) {
  444.                 $parts['BYMINUTE'] = explode(',',$parts['BYMINUTE']);
  445.             }
  446.             $this->byminute = array();
  447.             foreach ( $parts['BYMINUTE'] as $value ) {
  448.                 if ( filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => 0'max_range' => 59))) === false ) {
  449.                     throw new \InvalidArgumentException('Invalid BYMINUTE value: '.$value);
  450.                 }
  451.                 $this->byminute[] = (int) $value;
  452.             }
  453.             sort($this->byminute);
  454.         }
  455.         elseif ( $this->freq self::MINUTELY ) {
  456.             $this->byminute = array((int) $this->dtstart->format('i'));
  457.         }
  458.         if ( not_empty($parts['BYSECOND']) ) {
  459.             if ( ! is_array($parts['BYSECOND']) ) {
  460.                 $parts['BYSECOND'] = explode(',',$parts['BYSECOND']);
  461.             }
  462.             $this->bysecond = array();
  463.             foreach ( $parts['BYSECOND'] as $value ) {
  464.                 // yes, "60" is a valid value, in (very rare) cases on leap seconds
  465.                 //  December 31, 2005 23:59:60 UTC is a valid date...
  466.                 // so is 2012-06-30T23:59:60UTC
  467.                 if ( filter_var($valueFILTER_VALIDATE_INT, array('options' => array('min_range' => 0'max_range' => 60))) === false ) {
  468.                     throw new \InvalidArgumentException('Invalid BYSECOND value: '.$value);
  469.                 }
  470.                 $this->bysecond[] = (int) $value;
  471.             }
  472.             sort($this->bysecond);
  473.         }
  474.         elseif ( $this->freq self::SECONDLY ) {
  475.             $this->bysecond = array((int) $this->dtstart->format('s'));
  476.         }
  477.         if ( $this->freq self::HOURLY ) {
  478.             // for frequencies DAILY, WEEKLY, MONTHLY AND YEARLY, we can build
  479.             // an array of every time of the day at which there should be an
  480.             // occurrence - default, if no BYHOUR/BYMINUTE/BYSECOND are provided
  481.             // is only one time, and it's the DTSTART time. This is a cached version
  482.             // if you will, since it'll never change at these frequencies
  483.             $this->timeset = array();
  484.             foreach ( $this->byhour as $hour ) {
  485.                 foreach ( $this->byminute as $minute ) {
  486.                     foreach ( $this->bysecond as $second ) {
  487.                         $this->timeset[] = array($hour,$minute,$second);
  488.                     }
  489.                 }
  490.             }
  491.         }
  492.     }
  493.     /**
  494.      * Return the internal rule array, as it was passed to the constructor.
  495.      *
  496.      * @return array
  497.      */
  498.     public function getRule()
  499.     {
  500.         return $this->rule;
  501.     }
  502.     /**
  503.      * Magic string converter.
  504.      *
  505.      * @see RRule::rfcString()
  506.      * @return string a rfc string
  507.      */
  508.     public function __toString()
  509.     {
  510.         return $this->rfcString();
  511.     }
  512.     /**
  513.      * Format a rule according to RFC 5545
  514.      *
  515.      * @param bool $include_timezone Wether to generate a rule with timezone identifier on DTSTART (and UNTIL) or not.
  516.      * @return string
  517.      */
  518.     public function rfcString($include_timezone true)
  519.     {
  520.         $str '';
  521.         if ( $this->rule['DTSTART'] ) {
  522.             if ( ! $include_timezone ) {
  523.                 $str sprintf(
  524.                     "DTSTART:%s\nRRULE:",
  525.                     $this->dtstart->format('Ymd\THis')
  526.                 );
  527.             }
  528.             else {
  529.                 $dtstart = clone $this->dtstart;
  530.                 $timezone_name $dtstart->getTimeZone()->getName();
  531.                 if ( strpos($timezone_name,':') !== false ) {
  532.                     // handle unsupported timezones like "+02:00"
  533.                     // we convert them to UTC to generate a valid string
  534.                     // note: there is possibly other weird timezones out there that we should catch
  535.                     $dtstart->setTimezone(new \DateTimeZone('UTC'));
  536.                     $timezone_name 'UTC';
  537.                 }
  538.                 if ( in_array($timezone_name, array('UTC','GMT','Z')) ) {
  539.                     $str sprintf(
  540.                         "DTSTART:%s\nRRULE:",
  541.                         $dtstart->format('Ymd\THis\Z')
  542.                     );
  543.                 }
  544.                 else {
  545.                     $str sprintf(
  546.                         "DTSTART;TZID=%s:%s\nRRULE:",
  547.                         $timezone_name,
  548.                         $dtstart->format('Ymd\THis')
  549.                     );
  550.                 }
  551.             }
  552.         }
  553.         $parts = array();
  554.         foreach ( $this->rule as $key => $value ) {
  555.             if ( $key === 'DTSTART' ) {
  556.                 continue;
  557.             }
  558.             if ( $key === 'INTERVAL' && $value == ) {
  559.                 continue;
  560.             }
  561.             if ( $key === 'WKST' && $value === 'MO') {
  562.                 continue;
  563.             }
  564.             if ( $key === 'UNTIL' && $value ) {
  565.                 if ( ! $include_timezone ) {
  566.                     $tmp = clone $this->until;
  567.                     // put until on the same timezone as DTSTART
  568.                     $tmp->setTimeZone($this->dtstart->getTimezone());
  569.                     $parts[] = 'UNTIL='.$tmp->format('Ymd\THis');
  570.                 }
  571.                 else {
  572.                     // according to the RFC, UNTIL must be in UTC
  573.                     $tmp = clone $this->until;
  574.                     $tmp->setTimezone(new \DateTimeZone('UTC'));
  575.                     $parts[] = 'UNTIL='.$tmp->format('Ymd\THis\Z');
  576.                 }
  577.                 continue;
  578.             }
  579.             if ( $key === 'FREQ' && $value && !array_key_exists($value, static::$frequencies) ) {
  580.                 $frequency_key array_search($value, static::$frequencies);
  581.                 if ($frequency_key !== false) {
  582.                     $value $frequency_key;
  583.                 }
  584.             }
  585.             if ( $value !== NULL ) {
  586.                 if ( is_array($value) ) {
  587.                     $value implode(',',$value);
  588.                 }
  589.                 $parts[] = strtoupper(str_replace(' ','',"$key=$value"));
  590.             }
  591.         }
  592.         $str .= implode(';',$parts);
  593.         return $str;
  594.     }
  595.     /**
  596.      * Take a RFC 5545 string and returns an array (to be given to the constructor)
  597.      *
  598.      * @param string $string The rule to be parsed
  599.      * @return array
  600.      *
  601.      * @throws \InvalidArgumentException on error
  602.      */
  603.     static public function parseRfcString($string)
  604.     {
  605.         trigger_error('parseRfcString() is deprecated - use new RRule(), RRule::createFromRfcString() or \RRule\RfcParser::parseRRule() if necessary',E_USER_DEPRECATED);
  606.         return RfcParser::parseRRule($string);
  607.     }
  608.     /**
  609.      * Take a RFC 5545 string and returns either a RRule or a RSet.
  610.      *
  611.      * @param string $string The RFC string
  612.      * @param bool $force_rset Force a RSet to be returned.
  613.      * @return RRule|RSet
  614.      *
  615.      * @throws \InvalidArgumentException on error
  616.      */
  617.     static public function createFromRfcString($string$force_rset false)
  618.     {
  619.         $class '\RRule\RSet';
  620.         if ( ! $force_rset ) {
  621.             // try to detect if we have a RRULE or a set
  622.             $uppercased_string strtoupper($string);
  623.             $nb_rrule substr_count($string'RRULE');
  624.             if ( $nb_rrule == ) {
  625.                 $class '\RRule\RRule';
  626.             }
  627.             elseif ( $nb_rrule ) {
  628.                 $class '\RRule\RSet';
  629.             }
  630.             else {
  631.                 $class '\RRule\RRule';
  632.                 if ( strpos($string'EXDATE') !== false ||  strpos($string'RDATE') !== false ||  strpos($string'EXRULE') !== false ) {
  633.                     $class '\RRule\RSet';
  634.                 }
  635.             }
  636.         }
  637.         return new $class($string);
  638.     }
  639.     /**
  640.      * Clear the cache.
  641.      *
  642.      * It isn't recommended to use this method while iterating.
  643.      *
  644.      * @return $this
  645.      */
  646.     public function clearCache()
  647.     {
  648.         $this->total null;
  649.         $this->cache = array();
  650.         return $this;
  651.     }
  652. ///////////////////////////////////////////////////////////////////////////////
  653. // RRule interface
  654.     /**
  655.      * Return true if the rrule has an end condition, false otherwise
  656.      *
  657.      * @return bool
  658.      */
  659.     public function isFinite()
  660.     {
  661.         return $this->count || $this->until;
  662.     }
  663.     /**
  664.      * Return true if the rrule has no end condition (infite)
  665.      *
  666.      * @return bool
  667.      */
  668.     public function isInfinite()
  669.     {
  670.         return ! $this->count && ! $this->until;
  671.     }
  672.     /**
  673.      * Return all the occurrences in an array of \DateTime.
  674.      *
  675.      * @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
  676.      * @return array An array of \DateTime objects
  677.      */
  678.     public function getOccurrences($limit null)
  679.     {
  680.         if ( ! $limit && $this->isInfinite() ) {
  681.             throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.');
  682.         }
  683.         // cached version already computed
  684.         $iterator $this;
  685.         if ( $this->total !== null ) {
  686.             $iterator $this->cache;
  687.         }
  688.         $res = array();
  689.         $n 0;
  690.         foreach ( $iterator as $occurrence ) {
  691.             $res[] = clone $occurrence// we have to clone because DateTime is not immutable
  692.             $n += 1;
  693.             if ( $limit && $n >= $limit ) {
  694.                 break;
  695.             }
  696.         }
  697.         return $res;
  698.     }
  699.     /**
  700.      * Return all the ocurrences after a date, before a date, or between two dates.
  701.      *
  702.      * @param mixed $begin Can be null to return all occurrences before $end
  703.      * @param mixed $end Can be null to return all occurrences after $begin
  704.      * @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
  705.      * @return array An array of \DateTime objects
  706.      */
  707.     public function getOccurrencesBetween($begin$end$limit null)
  708.     {
  709.         if ( $begin !== null ) {
  710.             $begin self::parseDate($begin);
  711.         }
  712.         if ( $end !== null ) {
  713.             $end self::parseDate($end);
  714.         }
  715.         elseif ( ! $limit && $this->isInfinite() ) {
  716.             throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.');
  717.         }
  718.         $iterator $this;
  719.         if ( $this->total !== null ) {
  720.             $iterator $this->cache;
  721.         }
  722.         $res = array();
  723.         $n 0;
  724.         foreach ( $iterator as $occurrence ) {
  725.             if ( $begin !== null && $occurrence $begin ) {
  726.                 continue;
  727.             }
  728.             if ( $end !== null && $occurrence $end ) {
  729.                 break;
  730.             }
  731.             $res[] = clone $occurrence;
  732.             $n += 1;
  733.             if ( $limit && $n >= $limit ) {
  734.                 break;
  735.             }
  736.         }
  737.         return $res;
  738.     }
  739.     /**
  740.      * Return true if $date is an occurrence.
  741.      *
  742.      * This method will attempt to determine the result programmatically.
  743.      * However depending on the BYXXX rule parts that have been set, it might
  744.      * not always be possible. As a last resort, this method will loop
  745.      * through all occurrences until $date. This will incurr some performance
  746.      * penalty.
  747.      *
  748.      * @param mixed $date
  749.      * @return bool
  750.      */
  751.     public function occursAt($date)
  752.     {
  753.         $date self::parseDate($date);
  754.         // convert timezone to dtstart timezone for comparison
  755.         $date->setTimezone($this->dtstart->getTimezone());
  756.         if ( in_array($date$this->cache) ) {
  757.             // in the cache (whether cache is complete or not)
  758.             return true;
  759.         }
  760.         elseif ( $this->total !== null ) {
  761.             // cache complete and not in cache
  762.             return false;
  763.         }
  764.         // let's start with the obvious
  765.         if ( $date $this->dtstart || ($this->until && $date $this->until) ) {
  766.             return false;
  767.         }
  768.         // now the BYXXX rules (expect BYSETPOS)
  769.         if ( $this->byhour && ! in_array($date->format('G'), $this->byhour) ) {
  770.             return false;
  771.         }
  772.         if ( $this->byminute && ! in_array((int) $date->format('i'), $this->byminute) ) {
  773.             return false;
  774.         }
  775.         if ( $this->bysecond && ! in_array((int) $date->format('s'), $this->bysecond) ) {
  776.             return false;
  777.         }
  778.         // we need some more variables before we continue
  779.         list($year$month$day$yearday$weekday) = explode(' ',$date->format('Y n j z N'));
  780.         $masks = array();
  781.         $masks['weekday_of_1st_yearday'] = date_create($year.'-01-01 00:00:00')->format('N');
  782.         $masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK$masks['weekday_of_1st_yearday']-1);
  783.         if ( is_leap_year($year) ) {
  784.             $masks['year_len'] = 366;
  785.             $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366;
  786.         }
  787.         else {
  788.             $masks['year_len'] = 365;
  789.             $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH;
  790.         }
  791.         $month_len $masks['last_day_of_month'][$month] - $masks['last_day_of_month'][$month-1];
  792.         if ( $this->bymonth && ! in_array($month$this->bymonth) ) {
  793.             return false;
  794.         }
  795.         if ( $this->bymonthday || $this->bymonthday_negative ) {
  796.             $monthday_negative = -* ($month_len $day 1);
  797.             if ( ! in_array($day$this->bymonthday) && ! in_array($monthday_negative$this->bymonthday_negative) ) {
  798.                 return false;
  799.             }
  800.         }
  801.         if ( $this->byyearday ) {
  802.             // caution here, yearday starts from 0 !
  803.             $yearday_negative = -1*($masks['year_len'] - $yearday);
  804.             if ( ! in_array($yearday+1$this->byyearday) && ! in_array($yearday_negative$this->byyearday) ) {
  805.                 return false;
  806.             }
  807.         }
  808.         if ( $this->byweekday || $this->byweekday_nth ) {
  809.             // we need to summon some magic here
  810.             $this->buildNthWeekdayMask($year$month$day$masks);
  811.             if ( ! in_array($weekday$this->byweekday) && ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) {
  812.                 return false;
  813.             }
  814.         }
  815.         if ( $this->byweekno ) {
  816.             // more magic
  817.             $this->buildWeeknoMask($year$month$day$masks);
  818.             if ( ! isset($masks['yearday_is_in_weekno'][$yearday]) ) {
  819.                 return false;
  820.             }
  821.         }
  822.         // so now we have exhausted all the BYXXX rules (exept bysetpos),
  823.         // we still need to consider frequency and interval
  824.         list ($start_year$start_month$start_day) = explode('-',$this->dtstart->format('Y-m-d'));
  825.         switch ( $this->freq ) {
  826.             case self::YEARLY:
  827.                 if ( ($year $start_year) % $this->interval !== ) {
  828.                     return false;
  829.                 }
  830.                 break;
  831.             case self::MONTHLY:
  832.                 // we need to count the number of months elapsed
  833.                 $diff = (12 $start_month) + 12*($year $start_year 1) + $month;
  834.                 if ( ($diff $this->interval) !== ) {
  835.                     return false;
  836.                 }
  837.                 break;
  838.             case self::WEEKLY:
  839.                 // count nb of days and divide by 7 to get number of weeks
  840.                 // we add some days to align dtstart with wkst
  841.                 $diff $date->diff($this->dtstart);
  842.                 $diff = (int) (($diff->days pymod($this->dtstart->format('N') - $this->wkst,7)) / 7);
  843.                 if ( $diff $this->interval !== ) {
  844.                     return false;
  845.                 }
  846.                 break;
  847.             case self::DAILY:
  848.                 // count nb of days
  849.                 $diff $date->diff($this->dtstart);
  850.                 if ( $diff->days $this->interval !== ) {
  851.                     return false;
  852.                 }
  853.                 break;
  854.             // XXX: I'm not sure the 3 formulas below take the DST into account...
  855.             case self::HOURLY:
  856.                 $diff $date->diff($this->dtstart);
  857.                 $diff $diff->$diff->days 24;
  858.                 if ( $diff $this->interval !== ) {
  859.                     return false;
  860.                 }
  861.                 break;
  862.             case self::MINUTELY:
  863.                 $diff $date->diff($this->dtstart);
  864.                 $diff  $diff->$diff->60 $diff->days 1440;
  865.                 if ( $diff $this->interval !== ) {
  866.                     return false;
  867.                 }
  868.                 break;
  869.             case self::SECONDLY:
  870.                 $diff $date->diff($this->dtstart);
  871.                 // XXX does not account for leap second (should it?)
  872.                 $diff  $diff->$diff->60 $diff->3600 $diff->days 86400;
  873.                 if ( $diff $this->interval !== ) {
  874.                     return false;
  875.                 }
  876.                 break;
  877.                 throw new \Exception('Unimplemented frequency');
  878.         }
  879.         // now we are left with 2 rules BYSETPOS and COUNT
  880.         //
  881.         // - I think BYSETPOS *could* be determined without loooping by considering
  882.         // the current set, calculating all the occurrences of the current set
  883.         // and determining the position of $date in the result set.
  884.         // However I'm not convinced it's worth it.
  885.         //
  886.         // - I don't see any way to determine COUNT programmatically, because occurrences
  887.         // might sometimes be dropped (e.g. a 29 Feb on a normal year, or during
  888.         // the switch to DST) and not counted in the final set
  889.         if ( ! $this->count && ! $this->bysetpos ) {
  890.             return true;
  891.         }
  892.         // so... as a fallback we have to loop
  893.         foreach ( $this as $occurrence ) {
  894.             if ( $occurrence == $date ) {
  895.                 return true// lucky you!
  896.             }
  897.             if ( $occurrence $date ) {
  898.                 break;
  899.             }
  900.         }
  901.         // we ended the loop without finding
  902.         return false
  903.     }
  904. ///////////////////////////////////////////////////////////////////////////////
  905. // Iterator interface
  906. // Note: if cache is complete, we could probably avoid completely calling iterate()
  907. // and instead iterate directly on the $this->cache array
  908.     /** @internal */
  909.     protected $current 0;
  910.     /** @internal */
  911.     protected $key 0;
  912.     /**
  913.      * @internal
  914.      */
  915.     public function rewind()
  916.     {
  917.         $this->current $this->iterate(true);
  918.         $this->key 0;
  919.     }
  920.     /**
  921.      * @internal
  922.      */
  923.     public function current()
  924.     {
  925.         return $this->current;
  926.     }
  927.     /**
  928.      * @internal
  929.      */
  930.     public function key()
  931.     {
  932.         return $this->key;
  933.     }
  934.     /**
  935.      * @internal
  936.      */
  937.     public function next()
  938.     {
  939.         $this->current $this->iterate();
  940.         $this->key += 1;
  941.     }
  942.     /**
  943.      * @internal
  944.      */
  945.     public function valid()
  946.     {
  947.         return $this->current !== null;
  948.     }
  949. ///////////////////////////////////////////////////////////////////////////////
  950. // ArrayAccess interface
  951.     /**
  952.      * @internal
  953.      */
  954.     public function offsetExists($offset)
  955.     {
  956.         return is_numeric($offset) && $offset >= && ! is_float($offset) && $offset count($this);
  957.     }
  958.     /**
  959.      * @internal
  960.      */
  961.     public function offsetGet($offset)
  962.     {
  963.         if ( ! is_numeric($offset) || $offset || is_float($offset) ) {
  964.             throw new \InvalidArgumentException('Illegal offset type: '.gettype($offset));
  965.         }
  966.         if ( isset($this->cache[$offset]) ) {
  967.             // found in cache
  968.             return clone $this->cache[$offset];
  969.         }
  970.         elseif ( $this->total !== null ) {
  971.             // cache complete and not found in cache
  972.             return null;
  973.         }
  974.         // not in cache and cache not complete, we have to loop to find it
  975.         $i 0;
  976.         foreach ( $this as $occurrence ) {
  977.             if ( $i == $offset ) {
  978.                 return $occurrence;
  979.             }
  980.             $i++;
  981.             if ( $i $offset ) {
  982.                 break;
  983.             }
  984.         }
  985.         return null;
  986.     }
  987.     /**
  988.      * @internal
  989.      */
  990.     public function offsetSet($offset$value)
  991.     {
  992.         throw new \LogicException('Setting a Date in a RRule is not supported');
  993.     }
  994.     /**
  995.      * @internal
  996.      */
  997.     public function offsetUnset($offset)
  998.     {
  999.         throw new \LogicException('Unsetting a Date in a RRule is not supported');
  1000.     }
  1001. ///////////////////////////////////////////////////////////////////////////////
  1002. // Countable interface
  1003.     /**
  1004.      * Returns the number of occurrences in this rule. It will have go
  1005.      * through the whole recurrence, if this hasn't been done before, which
  1006.      * introduces a performance penality.
  1007.      *
  1008.      * @return int
  1009.      */
  1010.     public function count()
  1011.     {
  1012.         if ( $this->isInfinite() ) {
  1013.             throw new \LogicException('Cannot count an infinite recurrence rule.');
  1014.         }
  1015.         if ( $this->total === null ) {
  1016.             foreach ( $this as $occurrence ) {}
  1017.         }
  1018.         return $this->total;
  1019.     }
  1020. ///////////////////////////////////////////////////////////////////////////////
  1021. // Internal methods
  1022. // where all the magic happens
  1023.     /**
  1024.      * Convert any date into a DateTime object.
  1025.      *
  1026.      * @param mixed $date
  1027.      * @return \DateTime
  1028.      *
  1029.      * @throws \InvalidArgumentException on error
  1030.      */
  1031.     static public function parseDate($date)
  1032.     {
  1033.         // DateTimeInterface is only on PHP 5.5+, and includes DateTimeImmutable
  1034.         if ( ! $date instanceof \DateTime && ! $date instanceof \DateTimeInterface ) {
  1035.             try {
  1036.                 if ( is_integer($date) ) {
  1037.                     $date \DateTime::createFromFormat('U',$date);
  1038.                     $date->setTimezone(new \DateTimeZone('UTC')); // default is +00:00 (see issue #15)
  1039.                 }
  1040.                 else {
  1041.                     $date = new \DateTime($date);
  1042.                 }
  1043.             } catch (\Exception $e) {
  1044.                 throw new \InvalidArgumentException(
  1045.                     "Failed to parse the date"
  1046.                 );
  1047.             }
  1048.         }
  1049.         else {
  1050.             $date = clone $date// avoid reference problems
  1051.         }
  1052.         return $date;
  1053.     }
  1054.     /**
  1055.      * Return an array of days of the year (numbered from 0 to 365)
  1056.      * of the current timeframe (year, month, week, day) containing the current date
  1057.      *
  1058.      * @param int $year
  1059.      * @param int $month
  1060.      * @param int $day
  1061.      * @param array $masks
  1062.      * @return array
  1063.      */
  1064.     protected function getDaySet($year$month$day, array $masks)
  1065.     {
  1066.         switch ( $this->freq ) {
  1067.             case self::YEARLY:
  1068.                 return range(0,$masks['year_len']-1);
  1069.             case self::MONTHLY:
  1070.                 $start $masks['last_day_of_month'][$month-1];
  1071.                 $stop $masks['last_day_of_month'][$month];
  1072.                 return range($start$stop 1);
  1073.             case self::WEEKLY:
  1074.                 // on first iteration, the first week will not be complete
  1075.                 // we don't backtrack to the first day of the week, to avoid
  1076.                 // crossing year boundary in reverse (i.e. if the week started
  1077.                 // during the previous year), because that would generate
  1078.                 // negative indexes (which would not work with the masks)
  1079.                 $set = array();
  1080.                 $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z');
  1081.                 $start $i;
  1082.                 for ( $j 0$j 7$j++ ) {
  1083.                     $set[] = $i;
  1084.                     $i += 1;
  1085.                     if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) {
  1086.                         break;
  1087.                     }
  1088.                 }
  1089.                 return $set;
  1090.             case self::DAILY:
  1091.             case self::HOURLY:
  1092.             case self::MINUTELY:
  1093.             case self::SECONDLY:
  1094.                 $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z');
  1095.                 return array($i);
  1096.         }
  1097.     }
  1098.     /**
  1099.      * Calculate the yeardays corresponding to each Nth weekday
  1100.      * (in BYDAY rule part).
  1101.      *
  1102.      * For example, in Jan 1998, in a MONTHLY interval, "1SU,-1SU" (first Sunday
  1103.      * and last Sunday) would be transformed into [3=>true,24=>true] because
  1104.      * the first Sunday of Jan 1998 is yearday 3 (counting from 0) and the
  1105.      * last Sunday of Jan 1998 is yearday 24 (counting from 0).
  1106.      *
  1107.      * @param int $year
  1108.      * @param int $month
  1109.      * @param int $day
  1110.      * @param array $masks
  1111.      *
  1112.      * @return null (modifies $mask parameter)
  1113.      */
  1114.     protected function buildNthWeekdayMask($year$month$day, array & $masks)
  1115.     {
  1116.         $masks['yearday_is_nth_weekday'] = array();
  1117.         if ( $this->byweekday_nth ) {
  1118.             $ranges = array();
  1119.             if ( $this->freq == self::YEARLY ) {
  1120.                 if ( $this->bymonth ) {
  1121.                     foreach ( $this->bymonth as $bymonth ) {
  1122.                         $ranges[] = array(
  1123.                             $masks['last_day_of_month'][$bymonth 1],
  1124.                             $masks['last_day_of_month'][$bymonth] - 1
  1125.                         );
  1126.                     }
  1127.                 }
  1128.                 else {
  1129.                     $ranges = array(array(0$masks['year_len'] - 1));
  1130.                 }
  1131.             }
  1132.             elseif ( $this->freq == self::MONTHLY ) {
  1133.                 $ranges[] = array(
  1134.                     $masks['last_day_of_month'][$month 1],
  1135.                     $masks['last_day_of_month'][$month] - 1
  1136.                 );
  1137.             }
  1138.             if ( $ranges ) {
  1139.                 // Weekly frequency won't get here, so we may not
  1140.                 // care about cross-year weekly periods.
  1141.                 foreach ( $ranges as $tmp ) {
  1142.                     list($first$last) = $tmp;
  1143.                     foreach ( $this->byweekday_nth as $tmp ) {
  1144.                         list($weekday$nth) = $tmp;
  1145.                         if ( $nth ) {
  1146.                             $i $last + ($nth 1) * 7;
  1147.                             $i $i pymod($masks['yearday_to_weekday'][$i] - $weekday7);
  1148.                         }
  1149.                         else {
  1150.                             $i $first + ($nth 1) * 7;
  1151.                             $i $i + ($masks['yearday_to_weekday'][$i] + $weekday) % 7;
  1152.                         }
  1153.                         if ( $i >= $first && $i <= $last ) {
  1154.                             $masks['yearday_is_nth_weekday'][$i] = true;
  1155.                         }
  1156.                     }
  1157.                 }
  1158.             }
  1159.         }
  1160.     }
  1161.     /**
  1162.      * Calculate the yeardays corresponding to the week number
  1163.      * (in the WEEKNO rule part).
  1164.      *
  1165.      * Because weeks can cross year boundaries (that is, week #1 can start the
  1166.      * previous year, and week 52/53 can continue till the next year), the
  1167.      * algorithm is quite long.
  1168.      *
  1169.      * @param int $year
  1170.      * @param int $month
  1171.      * @param int $day
  1172.      * @param array $masks
  1173.      *
  1174.      * @return null (modifies $mask)
  1175.      */
  1176.     protected function buildWeeknoMask($year$month$day, array & $masks)
  1177.     {
  1178.         $masks['yearday_is_in_weekno'] = array();
  1179.         // calculate the index of the first wkst day of the year
  1180.         // 0 means the first day of the year is the wkst day (e.g. wkst is Monday and Jan 1st is a Monday)
  1181.         // n means there is n days before the first wkst day of the year.
  1182.         // if n >= 4, this is the first day of the year (even though it started the year before)
  1183.         $first_wkst = ($masks['weekday_of_1st_yearday'] + $this->wkst) % 7;
  1184.         if( $first_wkst >= ) {
  1185.             $first_wkst_offset 0;
  1186.             // Number of days in the year, plus the days we got from last year.
  1187.             $nb_days $masks['year_len'] + $masks['weekday_of_1st_yearday'] - $this->wkst;
  1188.             // $nb_days = $masks['year_len'] + pymod($masks['weekday_of_1st_yearday'] - $this->wkst,7);
  1189.         }
  1190.         else {
  1191.             $first_wkst_offset $first_wkst;
  1192.             // Number of days in the year, minus the days we left in last year.
  1193.             $nb_days $masks['year_len'] - $first_wkst;
  1194.         }
  1195.         $nb_weeks = (int) ($nb_days 7) + (int) (($nb_days 7) / 4);
  1196.         // alright now we now when the first week starts
  1197.         // and the number of weeks of the year
  1198.         // so we can generate a map of every yearday that are in the weeks
  1199.         // specified in byweekno
  1200.         foreach ( $this->byweekno as $n ) {
  1201.             if ( $n ) {
  1202.                 $n $n $nb_weeks 1;
  1203.             }
  1204.             if ( $n <= || $n $nb_weeks ) {
  1205.                 continue;
  1206.             }
  1207.             if ( $n ) {
  1208.                 $i $first_wkst_offset + ($n 1) * 7;
  1209.                 if ( $first_wkst_offset != $first_wkst ) {
  1210.                     // if week #1 started the previous year
  1211.                     // realign the start of the week
  1212.                     $i $i - ($first_wkst);
  1213.                 }
  1214.             }
  1215.             else {
  1216.                 $i $first_wkst_offset;
  1217.             }
  1218.             // now add 7 days into the resultset, stopping either at 7 or
  1219.             // if we reach wkst before (in the case of short first week of year)
  1220.             for ( $j 0$j 7$j++ ) {
  1221.                 $masks['yearday_is_in_weekno'][$i] = true;
  1222.                 $i $i 1;
  1223.                 if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) {
  1224.                     break;
  1225.                 }
  1226.             }
  1227.         }
  1228.         // if we asked for week #1, it's possible that the week #1 of next year
  1229.         // already started this year. Therefore we need to return also the matching
  1230.         // days of next year.
  1231.         if ( in_array(1$this->byweekno) ) {
  1232.             // Check week number 1 of next year as well
  1233.             // TODO: Check -numweeks for next year.
  1234.             $i $first_wkst_offset $nb_weeks 7;
  1235.             if ( $first_wkst_offset != $first_wkst ) {
  1236.                 $i $i - ($first_wkst);
  1237.             }
  1238.             if ( $i $masks['year_len'] ) {
  1239.                 // If week starts in next year, we don't care about it.
  1240.                 for ( $j 0$j 7$j++ ) {
  1241.                     $masks['yearday_is_in_weekno'][$i] = true;
  1242.                     $i += 1;
  1243.                     if ( $masks['yearday_to_weekday'][$i] == $this->wkst ) {
  1244.                         break;
  1245.                     }
  1246.                 }
  1247.             }
  1248.         }
  1249.         if ( $first_wkst_offset ) {
  1250.             // Check last week number of last year as well.
  1251.             // If first_wkst_offset is 0, either the year started on week start,
  1252.             // or week number 1 got days from last year, so there are no
  1253.             // days from last year's last week number in this year.
  1254.             if ( ! in_array(-1$this->byweekno) ) {
  1255.                 $weekday_of_1st_yearday date_create(($year-1).'-01-01 00:00:00')->format('N');
  1256.                 $first_wkst_offset_last_year = ($weekday_of_1st_yearday $this->wkst) % 7;
  1257.                 $last_year_len 365 is_leap_year($year 1);
  1258.                 if ( $first_wkst_offset_last_year >= 4) {
  1259.                     $first_wkst_offset_last_year 0;
  1260.                     $nb_weeks_last_year 52 + (int) ((($last_year_len + ($weekday_of_1st_yearday $this->wkst) % 7) % 7) / 4);
  1261.                 }
  1262.                 else {
  1263.                     $nb_weeks_last_year 52 + (int) ((($masks['year_len'] - $first_wkst_offset) % 7) /4);
  1264.                 }
  1265.             }
  1266.             else {
  1267.                 $nb_weeks_last_year = -1;
  1268.             }
  1269.             if ( in_array($nb_weeks_last_year$this->byweekno) ) {
  1270.                 for ( $i 0$i $first_wkst_offset$i++ ) {
  1271.                     $masks['yearday_is_in_weekno'][$i] = true;
  1272.                 }
  1273.             }
  1274.         }
  1275.     }
  1276.     /**
  1277.      * Build an array of every time of the day that matches the BYXXX time
  1278.      * criteria.
  1279.      *
  1280.      * It will only process $this->frequency at one time. So:
  1281.      * - for HOURLY frequencies it builds the minutes and second of the given hour
  1282.      * - for MINUTELY frequencies it builds the seconds of the given minute
  1283.      * - for SECONDLY frequencies, it returns an array with one element
  1284.      * 
  1285.      * This method is called everytime an increment of at least one hour is made.
  1286.      *
  1287.      * @param int $hour
  1288.      * @param int $minute
  1289.      * @param int $second
  1290.      *
  1291.      * @return array
  1292.      */
  1293.     protected function getTimeSet($hour$minute$second)
  1294.     {
  1295.         switch ( $this->freq ) {
  1296.             case self::HOURLY:
  1297.                 $set = array();
  1298.                 foreach ( $this->byminute as $minute ) {
  1299.                     foreach ( $this->bysecond as $second ) {
  1300.                         // should we use another type?
  1301.                         $set[] = array($hour$minute$second);
  1302.                     }
  1303.                 }
  1304.                 // sort ?
  1305.                 return $set;
  1306.             case self::MINUTELY:
  1307.                 $set = array();
  1308.                 foreach ( $this->bysecond as $second ) {
  1309.                     // should we use another type?
  1310.                     $set[] = array($hour$minute$second);
  1311.                 }
  1312.                 // sort ?
  1313.                 return $set;
  1314.             case self::SECONDLY:
  1315.                 return array(array($hour$minute$second));
  1316.             default:
  1317.                 throw new \LogicException('getTimeSet called with an invalid frequency');
  1318.         }
  1319.     }
  1320.     // Variables for iterate() method, that will persist to allow iterate()
  1321.     // to resume where it stopped. For PHP >= 5.5, these would be local variables
  1322.     // inside a generator method using yield. However since we are compatible with
  1323.     // PHP 5.3 and 5.4, they have to be implemented this way.
  1324.     //
  1325.     // The original implementation used static local variables inside the class
  1326.     // method, which I think was cleaner scope-wise, but sadly this didn't work
  1327.     // when multiple instances of RRule existed and are iterated at the same time
  1328.     // (such as in a ruleset)
  1329.     //
  1330.     // DO NOT USE OUTSIDE OF iterate()
  1331.     /** @internal */
  1332.     private $_year null;
  1333.     /** @internal */
  1334.     private $_month null;
  1335.     /** @internal */
  1336.     private $_day null;
  1337.     /** @internal */
  1338.     private $_hour null;
  1339.     /** @internal */
  1340.     private $_minute null;
  1341.     /** @internal */
  1342.     private $_second null;
  1343.     /** @internal */
  1344.     private $_dayset null;
  1345.     /** @internal */
  1346.     private $_masks null;
  1347.     /** @internal */
  1348.     private $_timeset null;
  1349.     /** @internal */
  1350.     private $_dtstart null;
  1351.     /** @internal */
  1352.     private $_total 0;
  1353.     /** @internal */
  1354.     private $_use_cache true;
  1355.     /**
  1356.      * This is the main method, where all of the magic happens.
  1357.      *
  1358.      * This method is a generator that works for PHP 5.3/5.4 (using static variables)
  1359.      *
  1360.      * The main idea is: a brute force made fast by not relying on date() functions
  1361.      * 
  1362.      * There is one big loop that examines every interval of the given frequency
  1363.      * (so every day, every week, every month or every year), constructs an
  1364.      * array of all the yeardays of the interval (for daily frequencies, the array
  1365.      * only has one element, for weekly 7, and so on), and then filters out any
  1366.      * day that do no match BYXXX parts.
  1367.      *
  1368.      * The algorithm does not try to be "smart" in calculating the increment of
  1369.      * the loop. That is, for a rule like "every day in January for 10 years"
  1370.      * the algorithm will loop through every day of the year, each year, generating
  1371.      * some 3650 iterations (+ some to account for the leap years).
  1372.      * This is a bit counter-intuitive, as it is obvious that the loop could skip
  1373.      * all the days in February till December since they are never going to match.
  1374.      *
  1375.      * Fortunately, this approach is still super fast because it doesn't rely
  1376.      * on date() or DateTime functions, and instead does all the date operations
  1377.      * manually, either arithmetically or using arrays as converters.
  1378.      *
  1379.      * Another quirk of this approach is that because the granularity is by day,
  1380.      * higher frequencies (hourly, minutely and secondly) have to have
  1381.      * their own special loops within the main loop, making the whole thing quite
  1382.      * convoluted.
  1383.      * Moreover, at such frequencies, the brute-force approach starts to really
  1384.      * suck. For example, a rule like
  1385.      * "Every minute, every Jan 1st between 10:00 and 10:59, for 10 years" 
  1386.      * requires a tremendous amount of useless iterations to jump from Jan 1st 10:59
  1387.      * at year 1 to Jan 1st 10.00 at year 2.
  1388.      *
  1389.      * In order to make a "smart jump", we would have to have a way to determine
  1390.      * the gap between the next occurence arithmetically. I think that would require
  1391.      * to analyze each "BYXXX" rule part that "Limit" the set (see the RFC page 43)
  1392.      * at the given frequency. For example, a YEARLY frequency doesn't need "smart
  1393.      * jump" at all; MONTHLY and WEEKLY frequencies only need to check BYMONTH;
  1394.      * DAILY frequency needs to check BYMONTH, BYMONTHDAY and BYDAY, and so on.
  1395.      * The check probably has to be done in reverse order, e.g. for DAILY frequencies
  1396.      * attempt to jump to the next weekday (BYDAY) or next monthday (BYMONTHDAY)
  1397.      * (I don't know yet which one first), and then if that results in a change of
  1398.      * month, attempt to jump to the next BYMONTH, and so on.
  1399.      *
  1400.      * @param $reset (bool) Whether to restart the iteration, or keep going
  1401.      * @return \DateTime|null
  1402.      */
  1403.     protected function iterate($reset false)
  1404.     {
  1405.         // for readability's sake, and because scope of the variables should be local anyway
  1406.         $year = & $this->_year;
  1407.         $month = & $this->_month;
  1408.         $day = & $this->_day;
  1409.         $hour = & $this->_hour;
  1410.         $minute = & $this->_minute;
  1411.         $second = & $this->_second;
  1412.         $dayset = & $this->_dayset;
  1413.         $masks = & $this->_masks;
  1414.         $timeset = & $this->_timeset;
  1415.         $dtstart = & $this->_dtstart;
  1416.         $total = & $this->_total;
  1417.         $use_cache = & $this->_use_cache;
  1418.         if ( $reset ) {
  1419.             $this->_year $this->_month $this->_day null;
  1420.             $this->_hour $this->_minute $this->_second null;
  1421.             $this->_dayset $this->_masks $this->_timeset null;
  1422.             $this->_dtstart null;
  1423.             $this->_total 0;
  1424.             $this->_use_cache true;
  1425.             reset($this->cache);
  1426.         }
  1427.         // go through the cache first
  1428.         if ( $use_cache ) {
  1429.             while ( ($occurrence current($this->cache)) !== false ) {
  1430.                 // echo "Cache hit\n";
  1431.                 $dtstart $occurrence;
  1432.                 next($this->cache);
  1433.                 $total += 1;
  1434.                 return clone $occurrence// since DateTime is not immutable, avoid any problem
  1435.             }
  1436.             reset($this->cache);
  1437.             // now set use_cache to false to skip the all thing on next iteration
  1438.             // and start filling the cache instead
  1439.             $use_cache false;
  1440.             // if the cache as been used up completely and we now there is nothing else
  1441.             if ( $total === $this->total ) {
  1442.                 // echo "Cache used up, nothing else to compute\n";
  1443.                 return null;
  1444.             }
  1445.             // echo "Cache used up with occurrences remaining\n";
  1446.             if ( $dtstart ) {
  1447.                 $dtstart = clone $dtstart// since DateTime is not immutable, avoid any problem
  1448.                 // so we skip the last occurrence of the cache
  1449.                 if ( $this->freq === self::SECONDLY ) {
  1450.                     $dtstart->modify('+'.$this->interval.'second');
  1451.                 }
  1452.                 else {
  1453.                     $dtstart->modify('+1second');
  1454.                 }
  1455.             }
  1456.         }
  1457.         // stop once $total has reached COUNT
  1458.         if ( $this->count && $total >= $this->count ) {
  1459.             $this->total $total;
  1460.             return null;
  1461.         }
  1462.         if ( $dtstart === null ) {
  1463.             $dtstart = clone $this->dtstart;
  1464.         }
  1465.         if ( $year === null ) {
  1466.             if ( $this->freq === self::WEEKLY ) {
  1467.                 // we align the start date to the WKST, so we can then
  1468.                 // simply loop by adding +7 days. The Python lib does some
  1469.                 // calculation magic at the end of the loop (when incrementing)
  1470.                 // to realign on first pass.
  1471.                 $tmp = clone $dtstart;
  1472.                 $tmp->modify('-'.pymod($dtstart->format('N') - $this->wkst,7).'days');
  1473.                 list($year,$month,$day,$hour,$minute,$second) = explode(' ',$tmp->format('Y n j G i s'));
  1474.                 unset($tmp);
  1475.             }
  1476.             else {
  1477.                 list($year,$month,$day,$hour,$minute,$second) = explode(' ',$dtstart->format('Y n j G i s'));
  1478.             }
  1479.             // remove leading zeros
  1480.             $minute = (int) $minute;
  1481.             $second = (int) $second;
  1482.         }
  1483.         // we initialize the timeset
  1484.         if ( $timeset == null ) {
  1485.             if ( $this->freq self::HOURLY ) {
  1486.                 // daily, weekly, monthly or yearly
  1487.                 // we don't need to calculate a new timeset
  1488.                 $timeset $this->timeset;
  1489.             }
  1490.             else {
  1491.                 // initialize empty if it's not going to occurs on the first iteration
  1492.                 if (
  1493.                     ($this->freq >= self::HOURLY && $this->byhour && ! in_array($hour$this->byhour))
  1494.                     || ($this->freq >= self::MINUTELY && $this->byminute && ! in_array($minute$this->byminute))
  1495.                     || ($this->freq >= self::SECONDLY && $this->bysecond && ! in_array($second$this->bysecond))
  1496.                 ) {
  1497.                     $timeset = array();
  1498.                 }
  1499.                 else {
  1500.                     $timeset $this->getTimeSet($hour$minute$second);
  1501.                 }
  1502.             }
  1503.         }
  1504.         // while (true) {
  1505.         $max_cycles self::$REPEAT_CYCLES[$this->freq <= self::DAILY $this->freq self::DAILY];
  1506.         for ( $i 0$i $max_cycles$i++ ) {
  1507.             // 1. get an array of all days in the next interval (day, month, week, etc.)
  1508.             // we filter out from this array all days that do not match the BYXXX conditions
  1509.             // to speed things up, we use days of the year (day numbers) instead of date
  1510.             if ( $dayset === null ) {
  1511.                 // rebuild the various masks and converters
  1512.                 // these arrays will allow fast date operations
  1513.                 // without relying on date() methods
  1514.                 if ( empty($masks) || $masks['year'] != $year || $masks['month'] != $month ) {
  1515.                     $masks = array('year' => '','month'=>'');
  1516.                     // only if year has changed
  1517.                     if ( $masks['year'] != $year ) {
  1518.                         $masks['leap_year'] = is_leap_year($year);
  1519.                         $masks['year_len'] = 365 + (int) $masks['leap_year'];
  1520.                         $masks['next_year_len'] = 365 is_leap_year($year 1);
  1521.                         $masks['weekday_of_1st_yearday'] = date_create($year."-01-01 00:00:00")->format('N');
  1522.                         $masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK$masks['weekday_of_1st_yearday']-1);
  1523.                         if ( $masks['leap_year'] ) {
  1524.                             $masks['yearday_to_month'] = self::$MONTH_MASK_366;
  1525.                             $masks['yearday_to_monthday'] = self::$MONTHDAY_MASK_366;
  1526.                             $masks['yearday_to_monthday_negative'] = self::$NEGATIVE_MONTHDAY_MASK_366;
  1527.                             $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366;
  1528.                         }
  1529.                         else {
  1530.                             $masks['yearday_to_month'] = self::$MONTH_MASK;
  1531.                             $masks['yearday_to_monthday'] = self::$MONTHDAY_MASK;
  1532.                             $masks['yearday_to_monthday_negative'] = self::$NEGATIVE_MONTHDAY_MASK;
  1533.                             $masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH;
  1534.                         }
  1535.                         if ( $this->byweekno ) {
  1536.                             $this->buildWeeknoMask($year$month$day$masks);
  1537.                         }
  1538.                     }
  1539.                     // everytime month or year changes
  1540.                     if ( $this->byweekday_nth ) {
  1541.                         $this->buildNthWeekdayMask($year$month$day$masks);
  1542.                     }
  1543.                     $masks['year'] = $year;
  1544.                     $masks['month'] = $month;
  1545.                 }
  1546.                 // calculate the current set
  1547.                 $dayset $this->getDaySet($year$month$day$masks);
  1548.                 $filtered_set = array();
  1549.                 // filter out the days based on the BY*** rules
  1550.                 foreach ( $dayset as $yearday ) {
  1551.                     if ( $this->bymonth && ! in_array($masks['yearday_to_month'][$yearday], $this->bymonth) ) {
  1552.                         continue;
  1553.                     }
  1554.                     if ( $this->byweekno && ! isset($masks['yearday_is_in_weekno'][$yearday]) ) {
  1555.                         continue;
  1556.                     }
  1557.                     if ( $this->byyearday ) {
  1558.                         if ( $yearday $masks['year_len'] ) {
  1559.                             if ( ! in_array($yearday 1$this->byyearday) && ! in_array(- $masks['year_len'] + $yearday,$this->byyearday) ) {
  1560.                                 continue;
  1561.                             }
  1562.                         }
  1563.                         else { // if ( ($yearday >= $masks['year_len']
  1564.                             if ( ! in_array($yearday $masks['year_len'], $this->byyearday) && ! in_array(- $masks['next_year_len'] + $yearday $mask['year_len'], $this->byyearday) ) {
  1565.                                 continue;
  1566.                             }
  1567.                         }
  1568.                     }
  1569.                     if ( ($this->bymonthday || $this->bymonthday_negative)
  1570.                         && ! in_array($masks['yearday_to_monthday'][$yearday], $this->bymonthday)
  1571.                         && ! in_array($masks['yearday_to_monthday_negative'][$yearday], $this->bymonthday_negative) ) {
  1572.                         continue;
  1573.                     }
  1574.                     if ( ( $this->byweekday || $this->byweekday_nth )
  1575.                         && ! in_array($masks['yearday_to_weekday'][$yearday], $this->byweekday)
  1576.                         && ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) {
  1577.                         continue;
  1578.                     }
  1579.                     $filtered_set[] = $yearday;
  1580.                 }
  1581.                 $dayset $filtered_set;
  1582.                 // if BYSETPOS is set, we need to expand the timeset to filter by pos
  1583.                 // so we make a special loop to return while generating
  1584.                 if ( $this->bysetpos && $timeset ) {
  1585.                     $filtered_set = array();
  1586.                     foreach ( $this->bysetpos as $pos ) {
  1587.                         $n count($timeset);
  1588.                         if ( $pos ) {
  1589.                             $pos $n count($dayset) + $pos;
  1590.                         }
  1591.                         else {
  1592.                             $pos $pos 1;
  1593.                         }
  1594.                         $div = (int) ($pos $n); // daypos
  1595.                         $mod $pos $n// timepos
  1596.                         if ( isset($dayset[$div]) && isset($timeset[$mod]) ) {
  1597.                             $yearday $dayset[$div];
  1598.                             $time $timeset[$mod];
  1599.                             // used as array key to ensure uniqueness
  1600.                             $tmp $year.':'.$yearday.':'.$time[0].':'.$time[1].':'.$time[2];
  1601.                             if ( ! isset($filtered_set[$tmp]) ) {
  1602.                                 $occurrence \DateTime::createFromFormat(
  1603.                                     'Y z',
  1604.                                     "$year $yearday",
  1605.                                     $this->dtstart->getTimezone()
  1606.                                 );
  1607.                                 $occurrence->setTime($time[0], $time[1], $time[2]);
  1608.                                 $filtered_set[$tmp] = $occurrence;
  1609.                             }
  1610.                         }
  1611.                     }
  1612.                     sort($filtered_set);
  1613.                     $dayset $filtered_set;
  1614.                 }
  1615.             }
  1616.             // 2. loop, generate a valid date, and return the result (fake "yield")
  1617.             // at the same time, we check the end condition and return null if
  1618.             // we need to stop
  1619.             if ( $this->bysetpos && $timeset ) {
  1620.                 while ( ($occurrence current($dayset)) !== false ) {
  1621.                     // consider end conditions
  1622.                     if ( $this->until && $occurrence $this->until ) {
  1623.                         $this->total $total// save total for count() cache
  1624.                         return null;
  1625.                     }
  1626.                     next($dayset);
  1627.                     if ( $occurrence >= $dtstart ) { // ignore occurrences before DTSTART
  1628.                         $total += 1;
  1629.                         $this->cache[] = $occurrence;
  1630.                         return clone $occurrence// yield
  1631.                     }
  1632.                 }
  1633.             }
  1634.             else {
  1635.                 // normal loop, without BYSETPOS
  1636.                 while ( ($yearday current($dayset)) !== false ) {
  1637.                     $occurrence \DateTime::createFromFormat(
  1638.                         'Y z',
  1639.                         "$year $yearday",
  1640.                         $this->dtstart->getTimezone()
  1641.                     );
  1642.                     while ( ($time current($timeset)) !== false ) {
  1643.                         $occurrence->setTime($time[0], $time[1], $time[2]);
  1644.                         // consider end conditions
  1645.                         if ( $this->until && $occurrence $this->until ) {
  1646.                             $this->total $total// save total for count() cache
  1647.                             return null;
  1648.                         }
  1649.                         next($timeset);
  1650.                         if ( $occurrence >= $dtstart ) { // ignore occurrences before DTSTART
  1651.                             $total += 1;
  1652.                             $this->cache[] = $occurrence;
  1653.                             return clone $occurrence// yield
  1654.                         }
  1655.                     }
  1656.                     reset($timeset);
  1657.                     next($dayset);
  1658.                 }
  1659.             }
  1660.             // 3. we reset the loop to the next interval
  1661.             $days_increment 0;
  1662.             switch ( $this->freq ) {
  1663.                 case self::YEARLY:
  1664.                     // we do not care about $month or $day not existing,
  1665.                     // they are not used in yearly frequency
  1666.                     $year $year $this->interval;
  1667.                     break;
  1668.                 case self::MONTHLY:
  1669.                     // we do not care about the day of the month not existing
  1670.                     // it is not used in monthly frequency
  1671.                     $month $month $this->interval;
  1672.                     if ( $month 12 ) {
  1673.                         $div = (int) ($month 12);
  1674.                         $mod $month 12;
  1675.                         $month $mod;
  1676.                         $year $year $div;
  1677.                         if ( $month == ) {
  1678.                             $month 12;
  1679.                             $year $year 1;
  1680.                         }
  1681.                     }
  1682.                     break;
  1683.                 case self::WEEKLY:
  1684.                     $days_increment $this->interval*7;
  1685.                     break;
  1686.                 case self::DAILY:
  1687.                     $days_increment $this->interval;
  1688.                     break;
  1689.                 // For the time frequencies, things are a little bit different.
  1690.                 // We could just add "$this->interval" hours, minutes or seconds
  1691.                 // to the current time, and go through the main loop again,
  1692.                 // but since the frequencies are so high and needs to much iteration
  1693.                 // it's actually a bit faster to have custom loops and only
  1694.                 // call the DateTime method at the very end.
  1695.                 case self::HOURLY:
  1696.                     if ( empty($dayset) ) {
  1697.                         // an empty set means that this day has been filtered out
  1698.                         // by one of the BYXXX rule. So there is no need to
  1699.                         // examine it any further, we know nothing is going to
  1700.                         // occur anyway.
  1701.                         // so we jump to one iteration right before next day
  1702.                         $hour += ((int) ((23 $hour) / $this->interval)) * $this->interval;
  1703.                     }
  1704.                     $found false;
  1705.                     for ( $j 0$j self::$REPEAT_CYCLES[self::HOURLY]; $j++ ) {
  1706.                         $hour += $this->interval;
  1707.                         $div = (int) ($hour 24);
  1708.                         $mod $hour 24;
  1709.                         if ( $div ) {
  1710.                             $hour $mod;
  1711.                             $days_increment += $div;
  1712.                         }
  1713.                         if ( ! $this->byhour || in_array($hour$this->byhour)) {
  1714.                             $found true;
  1715.                             break;
  1716.                         }
  1717.                     }
  1718.                     if ( ! $found ) {
  1719.                         $this->total $total// save total for count cache
  1720.                         return null// stop the iterator
  1721.                     }
  1722.                     $timeset $this->getTimeSet($hour$minute$second);
  1723.                     break;
  1724.                 case self::MINUTELY:
  1725.                     if ( empty($dayset) ) {
  1726.                         $minute += ((int) ((1439 - ($hour*60+$minute)) / $this->interval)) * $this->interval;
  1727.                     }
  1728.                     $found false;
  1729.                     for ( $j 0$j self::$REPEAT_CYCLES[self::MINUTELY]; $j++ ) {
  1730.                         $minute += $this->interval;
  1731.                         $div = (int) ($minute 60);
  1732.                         $mod $minute 60;
  1733.                         if ( $div ) {
  1734.                             $minute $mod;
  1735.                             $hour += $div;
  1736.                             $div = (int) ($hour 24);
  1737.                             $mod $hour 24;
  1738.                             if ( $div ) {
  1739.                                 $hour $mod;
  1740.                                 $days_increment += $div;
  1741.                             }
  1742.                         }
  1743.                         if ( (! $this->byhour || in_array($hour$this->byhour)) &&
  1744.                         (! $this->byminute || in_array($minute$this->byminute)) ) {
  1745.                             $found true;
  1746.                             break;
  1747.                         }
  1748.                     }
  1749.                     if ( ! $found ) {
  1750.                         $this->total $total// save total for count cache
  1751.                         return null// stop the iterator
  1752.                     }
  1753.                     $timeset $this->getTimeSet($hour$minute$second);
  1754.                     break;
  1755.                 case self::SECONDLY:
  1756.                     if ( empty($dayset) ) {
  1757.                         $second += ((int) ((86399 - ($hour*3600 $minute*60 $second)) / $this->interval)) * $this->interval;
  1758.                     }
  1759.                     $found false;
  1760.                     for ( $j 0$j self::$REPEAT_CYCLES[self::SECONDLY]; $j++ ) {
  1761.                         $second += $this->interval;
  1762.                         $div = (int) ($second 60);
  1763.                         $mod $second 60;
  1764.                         if ( $div ) {
  1765.                             $second $mod;
  1766.                             $minute += $div;
  1767.                             $div = (int) ($minute 60);
  1768.                             $mod $minute 60;
  1769.                             if ( $div ) {
  1770.                                 $minute $mod;
  1771.                                 $hour += $div;
  1772.                                 $div = (int) ($hour 24);
  1773.                                 $mod $hour 24;
  1774.                                 if ( $div ) {
  1775.                                     $hour $mod;
  1776.                                     $days_increment += $div;
  1777.                                 }
  1778.                             }
  1779.                         }
  1780.                         if ( ( ! $this->byhour || in_array($hour$this->byhour) )
  1781.                             && ( ! $this->byminute || in_array($minute$this->byminute) ) 
  1782.                             && ( ! $this->bysecond || in_array($second$this->bysecond) ) ) {
  1783.                             $found true;
  1784.                             break;
  1785.                         }
  1786.                     }
  1787.                     if ( ! $found ) {
  1788.                         $this->total $total// save total for count cache
  1789.                         return null// stop the iterator
  1790.                     }
  1791.                     $timeset $this->getTimeSet($hour$minute$second);
  1792.                     break;
  1793.             }
  1794.             // here we take a little shortcut from the Python version, by using DateTime
  1795.             if ( $days_increment ) {
  1796.                 list($year,$month,$day) = explode('-',date_create("$year-$month-$day")->modify("+ $days_increment days")->format('Y-n-j'));
  1797.             }
  1798.             $dayset null// reset the loop
  1799.         }
  1800.         $this->total $total// save total for count cache
  1801.         return null// stop the iterator
  1802.     }
  1803. ///////////////////////////////////////////////////////////////////////////////
  1804. // constants
  1805. // Every mask is 7 days longer to handle cross-year weekly periods.
  1806.     /** 
  1807.      * @var array
  1808.      */
  1809.     protected static $MONTH_MASK = array(
  1810.         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,
  1811.         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,
  1812.         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,
  1813.         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,
  1814.         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,
  1815.         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,
  1816.         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,
  1817.         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,
  1818.         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,
  1819.         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,
  1820.         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,
  1821.         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,
  1822.         1,1,1,1,1,1,1
  1823.     );
  1824.     /** 
  1825.      * @var array
  1826.      */
  1827.     protected static $MONTH_MASK_366 = array(
  1828.         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,
  1829.         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,
  1830.         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,
  1831.         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,
  1832.         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,
  1833.         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,
  1834.         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,
  1835.         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,
  1836.         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,
  1837.         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,
  1838.         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,
  1839.         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,
  1840.         1,1,1,1,1,1,1
  1841.     );
  1842.     /** 
  1843.      * @var array
  1844.      */
  1845.     protected static $MONTHDAY_MASK = array(
  1846.         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,
  1847.         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,
  1848.         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,
  1849.         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,
  1850.         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,
  1851.         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,
  1852.         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,
  1853.         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,
  1854.         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,
  1855.         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,
  1856.         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,
  1857.         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,
  1858.         1,2,3,4,5,6,7
  1859.     );
  1860.     /** 
  1861.      * @var array
  1862.      */
  1863.     protected static $MONTHDAY_MASK_366 = array(
  1864.         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,
  1865.         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,
  1866.         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,
  1867.         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,
  1868.         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,
  1869.         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,
  1870.         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,
  1871.         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,
  1872.         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,
  1873.         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,
  1874.         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,
  1875.         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,
  1876.         1,2,3,4,5,6,7
  1877.     );
  1878.     /** 
  1879.      * @var array
  1880.      */
  1881.     protected static $NEGATIVE_MONTHDAY_MASK = array(
  1882.         -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,
  1883.         -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,
  1884.         -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,
  1885.         -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,
  1886.         -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,
  1887.         -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,
  1888.         -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,
  1889.         -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,
  1890.         -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,
  1891.         -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,
  1892.         -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,
  1893.         -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,
  1894.         -31,-30,-29,-28,-27,-26,-25
  1895.     );
  1896.     /** 
  1897.      * @var array
  1898.      */
  1899.     protected static $NEGATIVE_MONTHDAY_MASK_366 = array(
  1900.         -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,
  1901.         -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,
  1902.         -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,
  1903.         -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,
  1904.         -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,
  1905.         -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,
  1906.         -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,
  1907.         -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,
  1908.         -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,
  1909.         -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,
  1910.         -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,
  1911.         -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,
  1912.         -31,-30,-29,-28,-27,-26,-25
  1913.     );
  1914.     /** 
  1915.      * @var array
  1916.      */
  1917.     protected static $WEEKDAY_MASK = array(
  1918.         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,
  1919.         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,
  1920.         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,
  1921.         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,
  1922.         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,
  1923.         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,
  1924.         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,
  1925.         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,
  1926.         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,
  1927.         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,
  1928.         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
  1929.     );
  1930.     /** 
  1931.      * @var array
  1932.      */
  1933.     protected static $LAST_DAY_OF_MONTH_366 = array(
  1934.         0316091121152182213244274305335366
  1935.     );
  1936.     /** 
  1937.      * @var array
  1938.      */
  1939.     protected static $LAST_DAY_OF_MONTH = array(
  1940.         0315990120151181212243273304334365
  1941.     );
  1942.     /**
  1943.      * @var array
  1944.      * Maximum number of cycles after which a calendar repeats itself. This
  1945.      * is used to detect infinite loop: if no occurrence has been found 
  1946.      * after this numbers of cycles, we can abort.
  1947.      *
  1948.      * The Gregorian calendar cycle repeat completely every 400 years
  1949.      * (146,097 days or 20,871 weeks).
  1950.      * A smaller cycle would be 28 years (1,461 weeks), but it only works
  1951.      * if there is no dropped leap year in between.
  1952.      * 2100 will be a dropped leap year, but I'm going to assume it's not
  1953.      * going to be a problem anytime soon, so at the moment I use the 28 years
  1954.      * cycle.
  1955.      */
  1956.     protected static $REPEAT_CYCLES = array(
  1957.         // self::YEARLY => 400,
  1958.         // self::MONTHLY => 4800,
  1959.         // self::WEEKLY => 20871,
  1960.         // self::DAILY =>  146097, // that's a lot of cycles, it takes a few seconds to detect infinite loop
  1961.         self::YEARLY => 28,
  1962.         self::MONTHLY => 336,
  1963.         self::WEEKLY => 1461,
  1964.         self::DAILY => 10227,
  1965.         self::HOURLY => 24,
  1966.         self::MINUTELY => 1440,
  1967.         self::SECONDLY => 86400 // that's a lot of cycles too
  1968.     );
  1969. ///////////////////////////////////////////////////////////////////////////////
  1970. // i18n methods
  1971. // these could be moved into a separate class maybe, since it's not always necessary
  1972.     /**
  1973.      * @var array Stores translations once loaded (so we don't have to reload them all the time)
  1974.      */
  1975.     static protected $i18n = array();
  1976.     /**
  1977.      * @var bool if intl extension is loaded
  1978.      */
  1979.     static protected $intl_loaded null;
  1980.     /** 
  1981.      * Select a translation in $array based on the value of $n
  1982.      *
  1983.      * Used for selecting plural forms.
  1984.      *
  1985.      * @param mixed $array Array with multiple forms or a string
  1986.      * @param string $n
  1987.      *
  1988.      * @return string
  1989.      */
  1990.     static protected function i18nSelect($array$n)
  1991.     {
  1992.         if ( ! is_array($array) ) {
  1993.             return $array;
  1994.         }
  1995.         if ( array_key_exists($n$array) ) {
  1996.             return $array[$n];
  1997.         }
  1998.         elseif ( array_key_exists('else'$array) ) {
  1999.             return $array['else'];
  2000.         }
  2001.         else {
  2002.             return ''// or throw?
  2003.         }
  2004.     }
  2005.     /**
  2006.      * Create a comma-separated list, with the last item added with an " and "
  2007.      * Example: Monday, Tuesday and Friday
  2008.      *
  2009.      * @param array $array
  2010.      * @param string $and Translation for "and"
  2011.      *
  2012.      * @return string
  2013.      */
  2014.     static protected function i18nList(array $array$and 'and')
  2015.     {
  2016.         if ( count($array) > ) {
  2017.             $last array_splice($array, -1);
  2018.             return sprintf(
  2019.                 '%s %s %s',
  2020.                 implode(', ',$array),
  2021.                 $and,
  2022.                 implode('',$last)
  2023.             );
  2024.         }
  2025.         else {
  2026.             return $array[0];
  2027.         }
  2028.     }
  2029.     /** 
  2030.      * Test if intl extension is loaded
  2031.      * @return bool
  2032.      */
  2033.     static public function intlLoaded()
  2034.     {
  2035.         if ( self::$intl_loaded === null ) {
  2036.             self::$intl_loaded extension_loaded('intl');
  2037.         }
  2038.         return self::$intl_loaded;
  2039.     }
  2040.     /**
  2041.      * Parse a locale and returns a list of files to load.
  2042.      *
  2043.      * @return array
  2044.      */
  2045.     static public function i18nFilesToLoad($locale$use_intl null)
  2046.     {
  2047.         if ( $use_intl === null ) {
  2048.             $use_intl self::intlLoaded();
  2049.         }
  2050.         $files = array();
  2051.         
  2052.         if ( $use_intl ) {
  2053.             $parsed \Locale::parseLocale($locale);
  2054.             $files[] = $parsed['language'];
  2055.             if ( isset($parsed['region']) ) {
  2056.                 $files[] = $parsed['language'].'_'.$parsed['region'];
  2057.             }
  2058.         }
  2059.         else {
  2060.             if ( ! preg_match('/^([a-z]{2})(?:(?:_|-)[A-Z][a-z]+)?(?:(?:_|-)([A-Za-z]{2}))?(?:(?:_|-)[A-Z]*)?(?:\.[a-zA-Z\-0-9]*)?$/'$locale$matches) ) {
  2061.                 throw new \InvalidArgumentException("The locale option does not look like a valid locale: $locale. For more option install the intl extension.");
  2062.             }
  2063.             $files[] = $matches[1];
  2064.             if ( isset($matches[2]) ) {
  2065.                 $files[] = $matches[1].'_'.strtoupper($matches[2]);
  2066.             }
  2067.         }
  2068.         return $files;
  2069.     }
  2070.     /**
  2071.      * Load a translation file in memory.
  2072.      * Will load the basic first (e.g. "en") and then the region-specific if any
  2073.      * (e.g. "en_GB"), merging as necessary.
  2074.      * So region-specific translation files don't need to redefine every strings.
  2075.      *
  2076.      * @param string      $locale
  2077.      * @param string|null $fallback
  2078.      *
  2079.      * @return array
  2080.      * @throws \InvalidArgumentException
  2081.      */
  2082.     static protected function i18nLoad($locale$fallback null$use_intl null)
  2083.     {
  2084.         $files self::i18nFilesToLoad($locale$use_intl);
  2085.         $result = array();
  2086.         foreach ( $files as $file ) {
  2087.             $path __DIR__."/i18n/$file.php";
  2088.             if ( isset(self::$i18n[$file]) ) {
  2089.                 $result array_merge($resultself::$i18n[$file]);
  2090.             }
  2091.             elseif ( is_file($path) && is_readable($path) ) {
  2092.                 self::$i18n[$file] = include $path;
  2093.                 $result array_merge($resultself::$i18n[$file]);
  2094.             }
  2095.             else {
  2096.                 self::$i18n[$file] = array();
  2097.             }
  2098.         }
  2099.         if ( empty($result) ) {
  2100.             if (!is_null($fallback)) {
  2101.                 return self::i18nLoad($fallbacknull$use_intl);
  2102.             }
  2103.             throw new \RuntimeException("Failed to load translations for '$locale'");
  2104.         }
  2105.         return $result;
  2106.     }
  2107.     /**
  2108.      * Format a rule in a human readable string
  2109.      * intl extension is required.
  2110.      *
  2111.      * Available options
  2112.      *
  2113.      * | Name              | Type    | Description
  2114.      * |-------------------|---------|------------
  2115.      * | `use_intl`        | bool    | Use the intl extension or not (autodetect)
  2116.      * | `locale`          | string  | The locale to use (autodetect)
  2117.      * | `fallback`        | string  | Fallback locale if main locale is not found (default en)
  2118.      * | `date_formatter`  | callable| Function used to format the date (takes date, returns formatted)
  2119.      * | `explicit_inifite`| bool    | Mention "forever" if the rule is infinite (true)
  2120.      * | `dtstart`         | bool    | Mention the start date (true)
  2121.      *
  2122.      * @param array  $opt
  2123.      *
  2124.      * @return string
  2125.      */
  2126.     public function humanReadable(array $opt = array())
  2127.     {
  2128.         if ( ! isset($opt['use_intl']) ) {
  2129.             $opt['use_intl'] = self::intlLoaded();
  2130.         }
  2131.         $default_opt = array(
  2132.             'use_intl' => self::intlLoaded(),
  2133.             'locale' => null,
  2134.             'date_formatter' => null,
  2135.             'fallback' => 'en',
  2136.             'explicit_infinite' => true,
  2137.             'include_start' => true,
  2138.             'include_until' => true
  2139.         );
  2140.         // attempt to detect default locale
  2141.         if ( $opt['use_intl'] ) {
  2142.             $default_opt['locale'] = \Locale::getDefault();
  2143.         } else {
  2144.             $default_opt['locale'] = setlocale(LC_ALL0);
  2145.             if ( $default_opt['locale'] == 'C' ) {
  2146.                 $default_opt['locale'] = 'en';
  2147.             }
  2148.         }
  2149.         if ( $opt['use_intl'] ) {
  2150.             $default_opt['date_format'] = \IntlDateFormatter::SHORT;
  2151.             if ( $this->freq >= self::SECONDLY || not_empty($this->rule['BYSECOND']) ) {
  2152.                 $default_opt['time_format'] = \IntlDateFormatter::LONG;
  2153.             }
  2154.             elseif ( $this->freq >= self::HOURLY || not_empty($this->rule['BYHOUR']) || not_empty($this->rule['BYMINUTE']) ) {
  2155.                 $default_opt['time_format'] = \IntlDateFormatter::SHORT;
  2156.             }
  2157.             else {
  2158.                 $default_opt['time_format'] = \IntlDateFormatter::NONE;
  2159.             }
  2160.         }
  2161.         $opt array_merge($default_opt$opt);
  2162.         $i18n self::i18nLoad($opt['locale'], $opt['fallback'], $opt['use_intl']);
  2163.         if ( $opt['date_formatter'] && ! is_callable($opt['date_formatter']) ) {
  2164.             throw new \InvalidArgumentException('The option date_formatter must callable');
  2165.         }
  2166.         if ( ! $opt['date_formatter'] ) {
  2167.             if (  $opt['use_intl'] ) {
  2168.                 $timezone $this->dtstart->getTimezone()->getName();
  2169.                 if ( $timezone === 'Z' ) {
  2170.                     $timezone 'GMT'// otherwise IntlDateFormatter::create fails because... reasons.
  2171.                 } elseif ( preg_match('/[-+]\d{2}/',$timezone) ) {
  2172.                     $timezone 'GMT'.$timezone// otherwise IntlDateFormatter::create fails because... other reasons.
  2173.                 }
  2174.                 $formatter \IntlDateFormatter::create(
  2175.                     $opt['locale'],
  2176.                     $opt['date_format'],
  2177.                     $opt['time_format'],
  2178.                     $timezone
  2179.                 );
  2180.                 if ( ! $formatter ) {
  2181.                     throw new \RuntimeException('IntlDateFormatter::create() failed. Error Code: '.intl_get_error_code().' "'intl_get_error_message().'" (this should not happen, please open a bug report!)');
  2182.                 }
  2183.                 $opt['date_formatter'] = function($date) use ($formatter) {
  2184.                     return $formatter->format($date);
  2185.                 };
  2186.             }
  2187.             else {
  2188.                 $opt['date_formatter'] = function($date) {
  2189.                     return $date->format('Y-m-d H:i:s');
  2190.                 };
  2191.             }
  2192.         }
  2193.         $parts = array(
  2194.             'freq' => '',
  2195.             'byweekday' => '',
  2196.             'bymonth' => '',
  2197.             'byweekno' => '',
  2198.             'byyearday' => '',
  2199.             'bymonthday' => '',
  2200.             'byhour' => '',
  2201.             'byminute' => '',
  2202.             'bysecond' => '',
  2203.             'bysetpos' => ''
  2204.         );
  2205.         // Every (INTERVAL) FREQ...
  2206.         $freq_str strtolower(array_search($this->freqself::$frequencies));
  2207.         $parts['freq'] = strtr(
  2208.             self::i18nSelect($i18n[$freq_str], $this->interval),
  2209.             array(
  2210.                 '%{interval}' => $this->interval
  2211.             )
  2212.         );
  2213.         // BYXXX rules
  2214.         if ( not_empty($this->rule['BYMONTH']) ) {
  2215.             $tmp $this->bymonth;
  2216.             foreach ( $tmp as & $value) {
  2217.                 $value $i18n['months'][$value];
  2218.             }
  2219.             $parts['bymonth'] = strtr(self::i18nSelect($i18n['bymonth'], count($tmp)), array(
  2220.                 '%{months}' => self::i18nList($tmp$i18n['and'])
  2221.             ));
  2222.         }
  2223.         if ( not_empty($this->rule['BYWEEKNO']) ) {
  2224.             // XXX negative week number are not great here
  2225.             $tmp $this->byweekno;
  2226.             foreach ( $tmp as & $value ) {
  2227.                 $value strtr($i18n['nth_weekno'], array(
  2228.                     '%{n}' => $value
  2229.                 ));
  2230.             }
  2231.             $parts['byweekno'] = strtr(
  2232.                 self::i18nSelect($i18n['byweekno'], count($this->byweekno)),
  2233.                 array(
  2234.                     '%{weeks}' => self::i18nList($tmp$i18n['and'])
  2235.                 )
  2236.             );
  2237.         }
  2238.         if ( not_empty($this->rule['BYYEARDAY']) ) {
  2239.             $tmp $this->byyearday;
  2240.             foreach ( $tmp as & $value ) {
  2241.                 $value strtr(self::i18nSelect($i18n[$value>0?'nth_yearday':'-nth_yearday'],$value), array(
  2242.                     '%{n}' => abs($value)
  2243.                 ));
  2244.             }
  2245.             $tmp strtr(self::i18nSelect($i18n['byyearday'], count($tmp)), array(
  2246.                 '%{yeardays}' => self::i18nList($tmp$i18n['and'])
  2247.             ));
  2248.             // ... of the month
  2249.             $tmp strtr(self::i18nSelect($i18n['x_of_the_y'], 'yearly'), array(
  2250.                 '%{x}' => $tmp
  2251.             ));
  2252.             $parts['byyearday'] = $tmp;
  2253.         }
  2254.         if ( not_empty($this->rule['BYMONTHDAY']) ) {
  2255.             $parts['bymonthday'] = array();
  2256.             if ( $this->bymonthday ) {
  2257.                 $tmp $this->bymonthday;
  2258.                 foreach ( $tmp as & $value ) {
  2259.                     $value strtr(self::i18nSelect($i18n['nth_monthday'],$value), array(
  2260.                         '%{n}' => $value
  2261.                     ));
  2262.                 }
  2263.                 $tmp strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array(
  2264.                     '%{monthdays}' => self::i18nList($tmp$i18n['and'])
  2265.                 ));
  2266.                 // ... of the month
  2267.                 $tmp strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array(
  2268.                     '%{x}' => $tmp
  2269.                 ));
  2270.                 $parts['bymonthday'][] = $tmp;
  2271.             }
  2272.             if ( $this->bymonthday_negative ) {
  2273.                 $tmp $this->bymonthday_negative;
  2274.                 foreach ( $tmp as & $value ) {
  2275.                     $value strtr(self::i18nSelect($i18n['-nth_monthday'],$value), array(
  2276.                         '%{n}' => -$value
  2277.                     ));
  2278.                 }
  2279.                 $tmp strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array(
  2280.                     '%{monthdays}' => self::i18nList($tmp$i18n['and'])
  2281.                 ));
  2282.                 // ... of the month
  2283.                 $tmp strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array(
  2284.                     '%{x}' => $tmp
  2285.                 ));
  2286.                 $parts['bymonthday'][] = $tmp;
  2287.             }
  2288.             $parts['bymonthday'] = implode(' '.$i18n['and'],$parts['bymonthday']);
  2289.         }
  2290.         if ( not_empty($this->rule['BYDAY']) ) {
  2291.             $parts['byweekday'] = array();
  2292.             if ( $this->byweekday ) {
  2293.                 $tmp $this->byweekday;
  2294.                 foreach ( $tmp as & $value ) {
  2295.                     $value $i18n['weekdays'][$value];
  2296.                 }
  2297.                 $parts['byweekday'][] = strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array(
  2298.                     '%{weekdays}' =>  self::i18nList($tmp$i18n['and'])
  2299.                 ));
  2300.             }
  2301.             if ( $this->byweekday_nth ) {
  2302.                 $tmp $this->byweekday_nth;
  2303.                 foreach ( $tmp as & $value ) {
  2304.                     list($day$n) = $value;
  2305.                     $value strtr(self::i18nSelect($i18n[$n>0?'nth_weekday':'-nth_weekday'], $n), array(
  2306.                         '%{weekday}' => $i18n['weekdays'][$day],
  2307.                         '%{n}' => abs($n)
  2308.                     ));
  2309.                 }
  2310.                 $tmp strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array(
  2311.                     '%{weekdays}' => self::i18nList($tmp$i18n['and'])
  2312.                 ));
  2313.                 // ... of the year|month
  2314.                 $tmp strtr(self::i18nSelect($i18n['x_of_the_y'], $freq_str), array(
  2315.                     '%{x}' => $tmp
  2316.                 ));
  2317.                 $parts['byweekday'][] = $tmp;
  2318.             }
  2319.             $parts['byweekday'] = implode(' '.$i18n['and'],$parts['byweekday']);
  2320.         }
  2321.         if ( not_empty($this->rule['BYHOUR']) ) {
  2322.             $tmp $this->byhour;
  2323.             foreach ( $tmp as &$value) {
  2324.                 $value strtr($i18n['nth_hour'], array(
  2325.                     '%{n}' => $value
  2326.                 ));
  2327.             }
  2328.             $parts['byhour'] = strtr(self::i18nSelect($i18n['byhour'],count($tmp)), array(
  2329.                 '%{hours}' => self::i18nList($tmp$i18n['and'])
  2330.             ));
  2331.         }
  2332.         if ( not_empty($this->rule['BYMINUTE']) ) {
  2333.             $tmp $this->byminute;
  2334.             foreach ( $tmp as &$value) {
  2335.                 $value strtr($i18n['nth_minute'], array(
  2336.                     '%{n}' => $value
  2337.                 ));
  2338.             }
  2339.             $parts['byminute'] = strtr(self::i18nSelect($i18n['byminute'],count($tmp)), array(
  2340.                 '%{minutes}' => self::i18nList($tmp$i18n['and'])
  2341.             ));
  2342.         }
  2343.         if ( not_empty($this->rule['BYSECOND']) ) {
  2344.             $tmp $this->bysecond;
  2345.             foreach ( $tmp as &$value) {
  2346.                 $value strtr($i18n['nth_second'], array(
  2347.                     '%{n}' => $value
  2348.                 ));
  2349.             }
  2350.             $parts['bysecond'] = strtr(self::i18nSelect($i18n['bysecond'],count($tmp)), array(
  2351.                 '%{seconds}' => self::i18nList($tmp$i18n['and'])
  2352.             ));
  2353.         }
  2354.         if ( $this->bysetpos ) {
  2355.             $tmp $this->bysetpos;
  2356.             foreach ( $tmp as & $value ) {
  2357.                 $value strtr(self::i18nSelect($i18n[$value>0?'nth_setpos':'-nth_setpos'],$value), array(
  2358.                     '%{n}' => abs($value)
  2359.                 ));
  2360.             }
  2361.             $tmp strtr(self::i18nSelect($i18n['bysetpos'], count($tmp)), array(
  2362.                 '%{setpos}' => self::i18nList($tmp$i18n['and'])
  2363.             ));
  2364.             $parts['bysetpos'] = $tmp;
  2365.         }
  2366.         if ( $opt['include_start'] ) {
  2367.             // from X
  2368.             $parts['start'] = strtr($i18n['dtstart'], array(
  2369.                 '%{date}' => $opt['date_formatter']($this->dtstart)
  2370.             ));
  2371.         }
  2372.         // to X, or N times, or indefinitely
  2373.         if ( $opt['include_until'] ) {
  2374.             if ( ! $this->until && ! $this->count ) {
  2375.                 if ( $opt['explicit_infinite'] ) {
  2376.                     $parts['end'] = $i18n['infinite'];
  2377.                 }
  2378.             }
  2379.             elseif ( $this->until ) {
  2380.                 $parts['end'] = strtr($i18n['until'], array(
  2381.                     '%{date}' => $opt['date_formatter']($this->until)
  2382.                 ));
  2383.             }
  2384.             elseif ( $this->count ) {
  2385.                 $parts['end'] = strtr(
  2386.                     self::i18nSelect($i18n['count'], $this->count),
  2387.                     array(
  2388.                         '%{count}' => $this->count
  2389.                     )
  2390.                 );
  2391.             }
  2392.         }
  2393.         $parts array_filter($parts);
  2394.         $str implode('',$parts);
  2395.         return $str;
  2396.     }
  2397. }