<?php
/**
* Licensed under the MIT license.
*
* For the full copyright and license information, please view the LICENSE file.
*
* @author RĂ©mi Lanvin <remi@cloudconnected.fr>
* @link https://github.com/rlanvin/php-rrule
*/
namespace RRule;
/**
* Recurrence set
*/
class RSet implements RRuleInterface
{
/**
* @var array List of RDATE (single dates)
*/
protected $rdates = array();
/**
* @var array List of RRULE
*/
protected $rrules = array();
/**
* @var array List of EXDATE (single dates to be excluded)
*/
protected $exdates = array();
/**
* @var array List of EXRULES (single rules to be excluded)
*/
protected $exrules = array();
// cache variable
/**
* @var int|null Cache for the total number of occurrences
*/
protected $total = null;
/**
* @var int|null Cache for the finite status of the RSet
*/
protected $infinite = null;
/**
* @var array Cache for all the occurrences
*/
protected $cache = array();
/**
* Constructor
*
* @param string $string a RFC compliant text block
*/
public function __construct($string = null, $default_dtstart = null)
{
if ( $string && is_string($string) ) {
$string = trim($string);
$rrules = array();
$exrules = array();
$rdates = array();
$exdates = array();
$dtstart = null;
// parse
$lines = explode("\n", $string);
foreach ( $lines as $line ) {
$line = trim($line);
if ( strpos($line,':') === false ) {
throw new \InvalidArgumentException('Failed to parse RFC string, line is not starting with a property name followed by ":"');
}
list($property_name,$property_value) = explode(':',$line);
$tmp = explode(";",$property_name);
$property_name = $tmp[0];
switch ( strtoupper($property_name) ) {
case 'DTSTART':
if ( $default_dtstart || $dtstart !== null ) {
throw new \InvalidArgumentException('Failed to parse RFC string, multiple DTSTART found');
}
$dtstart = $line;
break;
case 'RRULE':
$rrules[] = $line;
break;
case 'EXRULE':
$exrules[] = $line;
break;
case 'RDATE':
$rdates = array_merge($rdates, RfcParser::parseRDate($line));
break;
case 'EXDATE':
$exdates = array_merge($exdates, RfcParser::parseExDate($line));
break;
default:
throw new \InvalidArgumentException("Failed to parse RFC, unknown property: $property_name");
}
}
foreach ( $rrules as $rrule ) {
if ( $dtstart ) {
$rrule = $dtstart."\n".$rrule;
}
$this->addRRule(new RRule($rrule, $default_dtstart));
}
foreach ( $exrules as $rrule ) {
if ( $dtstart ) {
$rrule = $dtstart."\n".$rrule;
}
$this->addExRule(new RRule($rrule, $default_dtstart));
}
foreach ( $rdates as $date ) {
$this->addDate($date);
}
foreach ( $exdates as $date ) {
$this->addExDate($date);
}
}
}
/**
* Add a RRule (or another RSet)
*
* @param mixed $rrule an instance of RRuleInterface or something that can be transformed into a RRule (string or array)
* @return $this
*/
public function addRRule($rrule)
{
if ( is_string($rrule) || is_array($rrule) ) {
$rrule = new RRule($rrule);
}
elseif ( ! $rrule instanceof RRuleInterface ) {
throw new \InvalidArgumentException('The rule must be a string, an array, or implement RRuleInterface');
}
// cloning because I want to iterate it without being disturbed
$this->rrules[] = clone $rrule;
$this->clearCache();
return $this;
}
/**
* Return the RRULE(s) contained in this set
*
* @todo check if a deep copy is needed.
*
* @return array Array of RRule
*/
public function getRRules()
{
return $this->rrules;
}
/**
* Add a RRule with exclusion rules.
* In RFC 2445 but deprecated in RFC 5545
*
* @param mixed $rrule an instance of RRuleInterface or something that can be transformed into a RRule (string or array)
* @return $this
*/
public function addExRule($rrule)
{
if ( is_string($rrule) || is_array($rrule) ) {
$rrule = new RRule($rrule);
}
elseif ( ! $rrule instanceof RRuleInterface ) {
throw new \InvalidArgumentException('The rule must be a string, an array or implement RRuleInterface');
}
// cloning because I want to iterate it without being disturbed
$this->exrules[] = clone $rrule;
$this->clearCache();
return $this;
}
/**
* Return the EXRULE(s) contained in this set
*
* @todo check if a deep copy is needed.
*
* @return array Array of RRule
*/
public function getExRules()
{
return $this->exrules;
}
/**
* Add a RDATE (renamed Date for simplicy, since we don't support full RDATE syntax at the moment)
*
* @param mixed $date a valid date representation or a \DateTime object
* @return $this
*/
public function addDate($date)
{
try {
$this->rdates[] = RRule::parseDate($date);
sort($this->rdates);
} catch (\Exception $e) {
throw new \InvalidArgumentException(
'Failed to parse RDATE - it must be a valid date, timestamp or \DateTime object'
);
}
$this->clearCache();
return $this;
}
/**
* Return the RDATE(s) contained in this set
*
* @todo check if a deep copy is needed.
*
* @return array Array of \DateTime
*/
public function getDates()
{
return $this->rdates;
}
/**
* Add a EXDATE
*
* @param mixed $date a valid date representation or a \DateTime object
* @return $this
*/
public function addExDate($date)
{
try {
$this->exdates[] = RRule::parseDate($date);
sort($this->exdates);
} catch (\Exception $e) {
throw new \InvalidArgumentException(
'Failed to parse EXDATE - it must be a valid date, timestamp or \DateTime object'
);
}
$this->clearCache();
return $this;
}
/**
* Return the EXDATE(s) contained in this set
*
* @todo check if a deep copy is needed.
*
* @return array Array of \DateTime
*/
public function getExDates()
{
return $this->exdates;
}
/**
* Clear the cache.
* Do NOT use while the class is iterating.
* @return $this
*/
public function clearCache()
{
$this->total = null;
$this->infinite = null;
$this->cache = array();
$this->rlist_heap = null;
$this->rlist_iterator = null;
$this->exlist_heap = null;
$this->exlist_iterator = null;
return $this;
}
///////////////////////////////////////////////////////////////////////////////
// RRule interface
/**
* Return true if the rrule has an end condition, false otherwise
*
* @return bool
*/
public function isFinite()
{
return ! $this->isInfinite();
}
/**
* Return true if the rrule has no end condition (infite)
*
* @return bool
*/
public function isInfinite()
{
if ( $this->infinite === null ) {
$this->infinite = false;
foreach ( $this->rrules as $rrule ) {
if ( $rrule->isInfinite() ) {
$this->infinite = true;
break;
}
}
}
return $this->infinite;
}
/**
* Return all the occurrences in an array of \DateTime.
*
* @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
* @return array An array of \DateTime objects
*/
public function getOccurrences($limit = null)
{
if ( !$limit && $this->isInfinite() ) {
throw new \LogicException('Cannot get all occurrences of an infinite recurrence set.');
}
// cached version already computed
$iterator = $this;
if ( $this->total !== null ) {
$iterator = $this->cache;
}
$res = array();
$n = 0;
foreach ( $iterator as $occurrence ) {
$res[] = clone $occurrence; // we have to clone because DateTime is not immutable
$n += 1;
if ( $limit && $n >= $limit ) {
break;
}
}
return $res;
}
/**
* Return all the ocurrences after a date, before a date, or between two dates.
*
* @param mixed $begin Can be null to return all occurrences before $end
* @param mixed $end Can be null to return all occurrences after $begin
* @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
* @return array An array of \DateTime objects
*/
public function getOccurrencesBetween($begin, $end, $limit = null)
{
if ( $begin !== null ) {
$begin = RRule::parseDate($begin);
}
if ( $end !== null ) {
$end = RRule::parseDate($end);
}
elseif ( ! $limit && $this->isInfinite() ) {
throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.');
}
$iterator = $this;
if ( $this->total !== null ) {
$iterator = $this->cache;
}
$res = array();
$n = 0;
foreach ( $iterator as $occurrence ) {
if ( $begin !== null && $occurrence < $begin ) {
continue;
}
if ( $end !== null && $occurrence > $end ) {
break;
}
$res[] = clone $occurrence;
$n += 1;
if ( $limit && $n >= $limit ) {
break;
}
}
return $res;
}
/**
* Return true if $date is an occurrence.
*
* @param mixed $date
* @return bool
*/
public function occursAt($date)
{
$date = RRule::parseDate($date);
if ( in_array($date, $this->cache) ) {
// in the cache (whether cache is complete or not)
return true;
}
elseif ( $this->total !== null ) {
// cache complete and not in cache
return false;
}
// test if it *should* occur (before exclusion)
$occurs = false;
foreach ( $this->rdates as $rdate ) {
if ( $rdate == $date ) {
$occurs = true;
break;
}
}
if ( ! $occurs ) {
foreach ( $this->rrules as $rrule ) {
if ( $rrule->occursAt($date) ) {
$occurs = true;
break;
}
}
}
// if it should occur, test if it's excluded
if ( $occurs ) {
foreach ( $this->exdates as $exdate ) {
if ( $exdate == $date ) {
return false;
}
}
foreach ( $this->exrules as $exrule ) {
if ( $exrule->occursAt($date) ) {
return false;
}
}
}
return $occurs;
}
///////////////////////////////////////////////////////////////////////////////
// Iterator interface
/** @internal */
protected $current = 0;
/** @internal */
protected $key = 0;
/**
* @internal
*/
public function rewind()
{
$this->current = $this->iterate(true);
$this->key = 0;
}
/**
* @internal
*/
public function current()
{
return $this->current;
}
/**
* @internal
*/
public function key()
{
return $this->key;
}
/**
* @internal
*/
public function next()
{
$this->current = $this->iterate();
$this->key += 1;
}
/**
* @internal
*/
public function valid()
{
return $this->current !== null;
}
///////////////////////////////////////////////////////////////////////////////
// ArrayAccess interface
/**
* @internal
*/
public function offsetExists($offset)
{
return is_numeric($offset) && $offset >= 0 && ! is_float($offset) && $offset < count($this);
}
/**
* @internal
*/
public function offsetGet($offset)
{
if ( ! is_numeric($offset) || $offset < 0 || is_float($offset) ) {
throw new \InvalidArgumentException('Illegal offset type: '.gettype($offset));
}
if ( isset($this->cache[$offset]) ) {
// found in cache
return clone $this->cache[$offset];
}
elseif ( $this->total !== null ) {
// cache complete and not found in cache
return null;
}
// not in cache and cache not complete, we have to loop to find it
$i = 0;
foreach ( $this as $occurrence ) {
if ( $i == $offset ) {
return $occurrence;
}
$i++;
if ( $i > $offset ) {
break;
}
}
return null;
}
/**
* @internal
*/
public function offsetSet($offset, $value)
{
throw new \LogicException('Setting a Date in a RSet is not supported (use addDate)');
}
/**
* @internal
*/
public function offsetUnset($offset)
{
throw new \LogicException('Unsetting a Date in a RSet is not supported (use addDate)');
}
///////////////////////////////////////////////////////////////////////////////
// Countable interface
/**
* Returns the number of recurrences in this set. It will have go
* through the whole recurrence, if this hasn't been done before, which
* introduces a performance penality.
* @return int
*/
public function count()
{
if ( $this->isInfinite() ) {
throw new \LogicException('Cannot count an infinite recurrence set.');
}
if ( $this->total === null ) {
foreach ( $this as $occurrence ) {}
}
return $this->total;
}
///////////////////////////////////////////////////////////////////////////////
// Private methods
// cache variables
protected $rlist_heap = null;
protected $rlist_iterator = null;
protected $exlist_heap = null;
protected $exlist_iterator = null;
// local variables for iterate() (see comment in RRule about that)
/** @internal */
private $_previous_occurrence = null;
/** @internal */
private $_total = 0;
/** @internal */
private $_use_cache = 0;
/**
* This method will iterate over a bunch of different iterators (rrules and arrays),
* keeping the results *in order*, while never attempting to merge or sort
* anything in memory. It can combine both finite and infinite rrule.
*
* What we need to do it to build two heaps: rlist and exlist
* Each heap contains multiple iterators (either RRule or ArrayIterator)
* At each step of the loop, it calls all of the iterators to generate a new item,
* and stores them in the heap, that keeps them in order.
*
* This is made slightly more complicated because this method is a generator.
*
* @param $reset (bool) Whether to restart the iteration, or keep going
* @return \DateTime|null
*/
protected function iterate($reset = false)
{
$previous_occurrence = & $this->_previous_occurrence;
$total = & $this->_total;
$use_cache = & $this->_use_cache;
if ( $reset ) {
$this->_previous_occurrence = null;
$this->_total = 0;
$this->_use_cache = true;
reset($this->cache);
}
// go through the cache first
if ( $use_cache ) {
while ( ($occurrence = current($this->cache)) !== false ) {
next($this->cache);
$total += 1;
return clone $occurrence;
}
reset($this->cache);
// now set use_cache to false to skip the all thing on next iteration
// and start filling the cache instead
$use_cache = false;
// if the cache as been used up completely and we now there is nothing else
if ( $total === $this->total ) {
return null;
}
}
if ( $this->rlist_heap === null ) {
// rrules + rdate
$this->rlist_heap = new \SplMinHeap();
$this->rlist_iterator = new \MultipleIterator(\MultipleIterator::MIT_NEED_ANY);
$this->rlist_iterator->attachIterator(new \ArrayIterator($this->rdates));
foreach ( $this->rrules as $rrule ) {
$this->rlist_iterator->attachIterator($rrule);
}
$this->rlist_iterator->rewind();
// exrules + exdate
$this->exlist_heap = new \SplMinHeap();
$this->exlist_iterator = new \MultipleIterator(\MultipleIterator::MIT_NEED_ANY);
$this->exlist_iterator->attachIterator(new \ArrayIterator($this->exdates));
foreach ( $this->exrules as $rrule ) {
$this->exlist_iterator->attachIterator($rrule);
}
$this->exlist_iterator->rewind();
}
while ( true ) {
foreach ( $this->rlist_iterator->current() as $date ) {
if ( $date !== null ) {
$this->rlist_heap->insert($date);
}
}
$this->rlist_iterator->next(); // advance the iterator for the next call
if ( $this->rlist_heap->isEmpty() ) {
break; // exit the loop to stop the iterator
}
$occurrence = $this->rlist_heap->top();
$this->rlist_heap->extract(); // remove the occurrence from the heap
if ( $occurrence == $previous_occurrence ) {
continue; // skip, was already considered
}
// now we need to check against exlist
// we need to iterate exlist as long as it contains dates lower than occurrence
// (they will be discarded), and then check if the date is the same
// as occurrence (in which case it is discarded)
$excluded = false;
while ( true ) {
foreach ( $this->exlist_iterator->current() as $date ) {
if ( $date !== null ) {
$this->exlist_heap->insert($date);
}
}
$this->exlist_iterator->next(); // advance the iterator for the next call
if ( $this->exlist_heap->isEmpty() ) {
break 1; // break this loop only
}
$exdate = $this->exlist_heap->top();
if ( $exdate < $occurrence ) {
$this->exlist_heap->extract();
continue;
}
elseif ( $exdate == $occurrence ) {
$excluded = true;
break 1;
}
else {
break 1; // exdate is > occurrence, so we'll keep it for later
}
}
$previous_occurrence = $occurrence;
if ( $excluded ) {
continue;
}
$total += 1;
$this->cache[] = $occurrence;
return clone $occurrence; // = yield
}
$this->total = $total; // save total for count cache
return null; // stop the iterator
}
}