1
0
mirror of https://github.com/rlanvin/php-rrule.git synced 2025-02-20 09:54:16 +01:00

First implementation of occursAt

This commit is contained in:
rlanvin 2015-06-26 21:24:52 +03:00
parent b943f9f55b
commit 5bc02593fe
2 changed files with 246 additions and 24 deletions

View File

@ -134,7 +134,7 @@ class RRule implements \Iterator, \ArrayAccess
protected $byminute = null;
protected $byhour = null;
protected $byweekday = null;
protected $byweekday_relative = null;
protected $byweekday_nth = null;
protected $bymonthday = null;
protected $bymonthday_negative = null;
protected $byyearday = null;
@ -147,7 +147,10 @@ class RRule implements \Iterator, \ArrayAccess
// Public interface
/**
* Constructor
* The constructor needs the entire rule at once.
* There is no setter after the class has been instanciated,
* because in order to validate some BYXXX parts, we need to know
* the value of some other parts (FREQ or other BXXX parts).
*/
public function __construct(array $parts)
{
@ -273,7 +276,7 @@ class RRule implements \Iterator, \ArrayAccess
$parts['BYDAY'] = explode(',',$parts['BYDAY']);
}
$this->byweekday = [];
$this->byweekday_relative = [];
$this->byweekday_nth = [];
foreach ( $parts['BYDAY'] as $value ) {
$value = trim($value);
$valid = preg_match('/^([+-]?[0-9]+)?([A-Z]{2})$/', $value, $matches);
@ -282,14 +285,14 @@ class RRule implements \Iterator, \ArrayAccess
}
if ( $matches[1] ) {
$this->byweekday_relative[] = [self::$week_days[$matches[2]], (int)$matches[1]];
$this->byweekday_nth[] = [self::$week_days[$matches[2]], (int)$matches[1]];
}
else {
$this->byweekday[] = self::$week_days[$matches[2]];
}
}
if ( ! empty($this->byweekday_relative) ) {
if ( ! empty($this->byweekday_nth) ) {
if ( ! ($this->freq === self::MONTHLY || $this->freq === self::YEARLY) ) {
throw new \InvalidArgumentException('The BYDAY rule part MUST NOT be specified with a numeric value when the FREQ rule part is not set to MONTHLY or YEARLY.');
}
@ -470,7 +473,7 @@ class RRule implements \Iterator, \ArrayAccess
if ( $this->freq < self::HOURLY ) {
// for frequencies DAILY, WEEKLY, MONTHLY AND YEARLY, we build
// an array of every time of the day at which there should be an
// occurence - default, if no BYHOUR/BYMINUTE/BYSECOND are provided
// occurrence - default, if no BYHOUR/BYMINUTE/BYSECOND are provided
// is only one time, and it's the DTSTART time.
$this->timeset = array();
foreach ( $this->byhour as $hour ) {
@ -485,14 +488,17 @@ class RRule implements \Iterator, \ArrayAccess
}
}
/**
* @return array
*/
public function getOccurrences()
{
if ( ! $this->count && ! $this->until ) {
throw new \LogicException('Cannot get all occurences of an infinite recurrence rule.');
throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.');
}
$res = [];
foreach ( $this as $occurence ) {
$res[] = $occurence;
foreach ( $this as $occurrence ) {
$res[] = $occurrence;
}
return $res;
}
@ -503,24 +509,190 @@ class RRule implements \Iterator, \ArrayAccess
public function getOccurrencesBetween($begin, $end)
{
$res = [];
foreach ( $this as $occurence ) {
if ( $occurence < $begin ) {
foreach ( $this as $occurrence ) {
if ( $occurrence < $begin ) {
continue;
}
if ( $occurence > $end ) {
if ( $occurrence > $end ) {
break;
}
$res[] = $occurence;
$res[] = $occurrence;
}
return $res;
}
/**
* @return bool
* Alias of occursAt
* Because I think both are correct in English, aren't they?
*/
public function occursOn($date)
{
return $this->occursAt($date);
}
/**
* Return true if $date is an occurrence of the rule.
*
* This method will attempt to determine the result programmatically.
* However depending on the BYXXX rule parts that have been set, it might
* not always be possible. As a last resort, this method will loop
* through all occurrences until $date. This will incurr some performance
* penalty.
*
* @return bool
*/
public function occursAt($date)
{
if ( ! $date instanceof \DateTime ) {
try {
if ( is_integer($date) ) {
$date = \DateTime::createFromFormat('U',$date);
}
else {
$date = new \DateTime($date);
}
} catch ( \Exception $e ) {
throw new \InvalidArgumentException('Failed to parse the date');
}
}
// let's start with the obvious
if ( $date < $this->dtstart || ($this->until && $date > $this->until) ) {
return false;
}
// now the BYXXX rules (expect BYSETPOS)
if ( $this->byhour && ! in_array($date->format('G'), $this->byhour) ) {
return false;
}
if ( $this->byminute && ! in_array((int) $date->format('i'), $this->byminute) ) {
return false;
}
if ( $this->bysecond && ! in_array((int) $date->format('s'), $this->byhour) ) {
return false;
}
// we need some more variables before we continue
list($year, $month, $day, $yearday, $weekday) = explode(' ',$date->format('Y n j z N'));
$masks = array();
$masks['weekday_of_1st_yearday'] = date('N', mktime(0,0,0,1,1,$year));
$masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK, $masks['weekday_of_1st_yearday']-1);
if ( is_leap_year($year) ) {
$masks['year_len'] = 366;
$masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366;
}
else {
$masks['year_len'] = 365;
$masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH;
}
$month_len = $masks['last_day_of_month'][$month] - $masks['last_day_of_month'][$month-1];
if ( $this->bymonth && ! in_array($month, $this->bymonth) ) {
return false;
}
if ( $this->byweekday && ! in_array($weekday, $this->byweekday) ) {
return false;
}
if ( $this->bymonthday || $this->bymonthday_negative ) {
$monthday_negative = -1 * ($month_len - $day - 1);
if ( ! in_array($day, $this->bymonthday) && ! in_array($monthday_negative, $this->bymonthday_negative) ) {
return false;
}
}
if ( $this->byyearday ) {
// caution here, yearday starts from 0 !
$yearday_negative = -1*($masks['year_len'] - $yearday);
if ( ! in_array($yearday+1, $this->byyearday) && ! in_array($yearday_negative, $this->byyearday) ) {
return false;
}
}
if ( $this->byweekday_nth ) {
// we need to summon some magic here
$this->buildNthWeekdayMask($year, $month, $day, $masks);
if ( ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) {
return false;
}
}
if ( $this->byweekno ) {
// more magic
$this->buildWeeknoMask($year, $month, $day, $masks);
if ( ! isset($masks['yearday_is_in_weekno'][$yearday]) ) {
return false;
}
}
// so now we have exhausted all the BYXXX rules (exept bysetpos),
// we still need to consider frequency and interval
list ($start_year, $start_month, $start_day) = explode('-',$this->dtstart->format('Y-m-d'));
switch ( $this->freq ) {
case self::YEARLY:
if ( ($year - $start_year) % $this->interval !== 0 ) {
return false;
}
break;
case self::MONTHLY:
// we need to count the number of months elapsed
$nb_months = (12 - $start_month) + 12*($year - $start_year - 1) + $month;
if ( ($nb_months % $this->interval) !== 0 ) {
return false;
}
break;
case self::WEEKLY:
// count nb of days and divide by 7 to get number of weeks
$nb_days = $date->diff($this->dtstart)->format('%a');
$nb_weeks = (int) ($nb_days/7);
if ( $nb_weeks % $this->interval !== 0 ) {
return false;
}
break;
case self::DAILY:
// count nb of days
$nb_days = $date->diff($this->dtstart)->format('%a');
if ( $nb_days % $this->interval !== 0 ) {
return false;
}
break;
case self::HOURLY:
case self::MINUTELY:
case self::SECONDLY:
throw new \Exception('Unimplemented frequency');
}
// now we are left with 2 rules BYSETPOS and COUNT
//
// - I think BYSETPOS *could* be determined without loooping by considering
// the current set, calculating all the occurrences of the current set
// and determining the position of $date in the result set.
// However I'm not convinced it's worth it.
//
// - I don't see any way to determine COUNT programmatically, because occurrences
// might sometimes be dropped (e.g. a 29 Feb on a normal year, or during
// the switch to DST) and not counted in the final set
if ( ! $this->count && ! $this->bysetpos ) {
return true;
}
// so... as a fallback we have to loop
foreach ( $this as $occurrence ) {
if ( $occurrence == $date ) {
return true; // lucky you!
}
if ( $occurrence > $date ) {
break;
}
}
// we ended the loop without finding
return false;
}
// Iterator interface
@ -610,11 +782,13 @@ class RRule implements \Iterator, \ArrayAccess
return $set;
case self::DAILY:
$n = (int) date('z', mktime(0,0,0,$month,$day,$year));
return [$n];
case self::HOURLY:
case self::MINUTELY:
case self::SECONDLY:
$n = (int) date('z', mktime(0,0,0,$month,$day,$year));
return [$n];
throw new \Exception('Unimplemented frequency');
}
}
@ -623,9 +797,9 @@ class RRule implements \Iterator, \ArrayAccess
*/
protected function buildNthWeekdayMask($year, $month, $day, array & $masks)
{
$masks['yearday_is_in_weekday_relative'] = array();
$masks['yearday_is_nth_weekday'] = array();
if ( $this->byweekday_relative ) {
if ( $this->byweekday_nth ) {
$ranges = array();
if ( $this->freq == self::YEARLY ) {
if ( $this->bymonth ) {
@ -646,7 +820,7 @@ class RRule implements \Iterator, \ArrayAccess
// care about cross-year weekly periods.
foreach ( $ranges as $tmp ) {
list($first, $last) = $tmp;
foreach ( $this->byweekday_relative as $tmp ) {
foreach ( $this->byweekday_nth as $tmp ) {
list($weekday, $nth) = $tmp;
if ( $nth < 0 ) {
$i = $last + ($nth + 1) * 7;
@ -657,7 +831,7 @@ class RRule implements \Iterator, \ArrayAccess
$i = $i + (7 - $masks['yearday_to_weekday'][$i] + $weekday) % 7;
}
if ( $i >= $first && $i <= $last ) {
$masks['yearday_is_in_weekday_relative'][$i] = true;
$masks['yearday_is_nth_weekday'][$i] = true;
}
}
}
@ -870,7 +1044,7 @@ class RRule implements \Iterator, \ArrayAccess
}
}
// everytime month or year changes
if ( $this->byweekday_relative ) {
if ( $this->byweekday_nth ) {
$this->buildNthWeekdayMask($year, $month, $day, $masks);
}
$masks['year'] = $year;
@ -916,7 +1090,7 @@ class RRule implements \Iterator, \ArrayAccess
continue;
}
if ( $this->byweekday_relative && ! isset($masks['yearday_is_in_weekday_relative'][$yearday]) ) {
if ( $this->byweekday_nth && ! isset($masks['yearday_is_nth_weekday'][$yearday]) ) {
continue;
}
@ -964,7 +1138,7 @@ class RRule implements \Iterator, \ArrayAccess
}
next($timeset);
if ( $occurrence >= $this->dtstart ) { // ignore occurences before DTSTART
if ( $occurrence >= $this->dtstart ) { // ignore occurrences before DTSTART
$total += 1;
return $occurrence; // yield
}
@ -1007,7 +1181,7 @@ class RRule implements \Iterator, \ArrayAccess
case self::HOURLY:
case self::MINUTELY:
case self::SECONDLY:
throw new \InvalidArgumentException('Unimplemented frequency');
throw new \Exception('Unimplemented frequency');
}
}
}

View File

@ -115,6 +115,9 @@ class RRuleTest extends PHPUnit_Framework_TestCase
'DTSTART' => '1997-09-02'
], $rule));
$this->assertEquals($occurrences, $rule->getOccurrences());
foreach ( $occurrences as $date ) {
$this->assertTrue($rule->occursAt($date), $date->format('r'));
}
}
@ -160,6 +163,9 @@ class RRuleTest extends PHPUnit_Framework_TestCase
'DTSTART' => '1997-09-02'
], $rule));
$this->assertEquals($occurrences, $rule->getOccurrences());
foreach ( $occurrences as $date ) {
$this->assertTrue($rule->occursAt($date), $date->format('r'));
}
}
public function weeklyRules()
@ -197,6 +203,9 @@ class RRuleTest extends PHPUnit_Framework_TestCase
'DTSTART' => '1997-09-02'
], $rule));
$this->assertEquals($occurrences, $rule->getOccurrences());
foreach ( $occurrences as $date ) {
$this->assertTrue($rule->occursAt($date), $date->format('r'));
}
}
public function dailyRules()
@ -234,6 +243,9 @@ class RRuleTest extends PHPUnit_Framework_TestCase
'DTSTART' => '1997-09-02'
], $rule));
$this->assertEquals($occurrences, $rule->getOccurrences());
foreach ( $occurrences as $date ) {
$this->assertTrue($rule->occursAt($date), $date->format('r'));
}
}
@ -315,4 +327,40 @@ class RRuleTest extends PHPUnit_Framework_TestCase
// [date_create('1997-09-02'),
// date_create('1997-09-03'),
// date_create('1997-09-03')])
public function notOccurrences()
{
return array(
array(
['FREQ' => 'YEARLY', 'DTSTART' => '1999-09-02'],
['1999-09-01','1999-09-03']
),
array(
['FREQ' => 'YEARLY', 'DTSTART' => '1999-09-02', 'UNTIL' => '2000-09-02'],
['2001-09-02']
),
array(
['FREQ' => 'YEARLY', 'DTSTART' => '1999-09-02', 'COUNT' => 3],
['2010-09-02']
),
array(
['FREQ' => 'YEARLY', 'DTSTART' => '1999-09-02', 'INTERVAL' => 2],
['2000-09-02', '2002-09-02']
),
array(
['FREQ' => 'MONTHLY', 'DTSTART' => '1999-09-02', 'INTERVAL' => 2],
['1999-10-02', '1999-12-02']
),
);
}
/**
* @dataProvider notOccurrences
*/
public function testDoesNotOccursAt($rule, $not_occurences)
{
foreach ( $not_occurences as $date ) {
$this->assertFalse((new RRule($rule))->occursAt($date), $date);
}
}
}