Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions lib/Recur/RRuleIterator.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ public function next(): void
// Otherwise, we find the next event in the normal RRULE
// sequence.
switch ($this->frequency) {
case 'secondly':
$this->nextSecondly();
break;

case 'minutely':
$this->nextMinutely();
break;

case 'hourly':
$this->nextHourly();
break;
Expand Down Expand Up @@ -332,6 +340,62 @@ protected function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousE
}
}

/**
* Does the processing for advancing the iterator for secondly frequency.
*/
protected function nextSecondly(): void
{
$this->advanceSubHourly('+'.$this->interval.' seconds', true);
}

/**
* Does the processing for advancing the iterator for minutely frequency.
*/
protected function nextMinutely(): void
{
$this->advanceSubHourly('+'.$this->interval.' minutes', false);
}

/**
* Advances currentDate by a sub-hourly interval, applying the BYHOUR,
* BYMINUTE and (secondly only) BYSECOND limit rules of RFC 5545 §3.3.10.
*/
protected function advanceSubHourly(string $interval, bool $limitBySecond): void
{
if (!$this->byHour && !$this->byMinute && !($limitBySecond && $this->bySecond)) {
$this->currentDate = $this->currentDate->modify($interval);

return;
}

do {
$this->currentDate = $this->currentDate->modify($interval);
if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
$this->currentDate = null;

return;
}
} while (!$this->matchesSubHourlyByRules($limitBySecond));
}

/**
* Whether currentDate satisfies the applicable BYHOUR/BYMINUTE/BYSECOND limits.
*/
protected function matchesSubHourlyByRules(bool $limitBySecond): bool
{
if ($this->byHour && !in_array((int) $this->currentDate->format('G'), array_map('intval', $this->byHour), true)) {
return false;
}
if ($this->byMinute && !in_array((int) $this->currentDate->format('i'), array_map('intval', $this->byMinute), true)) {
return false;
}
if ($limitBySecond && $this->bySecond && !in_array((int) $this->currentDate->format('s'), array_map('intval', $this->bySecond), true)) {
return false;
}

return true;
}

/**
* Does the processing for advancing the iterator for hourly frequency.
*/
Expand Down
126 changes: 126 additions & 0 deletions tests/VObject/Recur/RRuleIteratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,132 @@ public function testIteratorFunctions(): void
);
}

public function testMinutely(): void
{
$this->parse(
'FREQ=MINUTELY;COUNT=4',
'2024-01-01 09:00:00',
[
'2024-01-01 09:00:00',
'2024-01-01 09:01:00',
'2024-01-01 09:02:00',
'2024-01-01 09:03:00',
]
);
}

public function testMinutelyInterval(): void
{
$this->parse(
'FREQ=MINUTELY;INTERVAL=15;COUNT=3',
'2024-01-01 09:50:00',
[
'2024-01-01 09:50:00',
'2024-01-01 10:05:00',
'2024-01-01 10:20:00',
]
);
}

public function testMinutelyByHour(): void
{
$this->parse(
'FREQ=MINUTELY;BYHOUR=10;COUNT=3',
'2024-01-01 09:58:00',
[
'2024-01-01 09:58:00',
'2024-01-01 10:00:00',
'2024-01-01 10:01:00',
]
);
}

public function testMinutelyByMinute(): void
{
$this->parse(
'FREQ=MINUTELY;BYMINUTE=0,30;COUNT=3',
'2024-01-01 09:58:00',
[
'2024-01-01 09:58:00',
'2024-01-01 10:00:00',
'2024-01-01 10:30:00',
]
);
}

public function testSecondly(): void
{
$this->parse(
'FREQ=SECONDLY;COUNT=4',
'2024-01-01 09:00:00',
[
'2024-01-01 09:00:00',
'2024-01-01 09:00:01',
'2024-01-01 09:00:02',
'2024-01-01 09:00:03',
]
);
}

public function testSecondlyByMinute(): void
{
$this->parse(
'FREQ=SECONDLY;BYMINUTE=0;COUNT=3',
'2024-01-01 08:59:58',
[
'2024-01-01 08:59:58',
'2024-01-01 09:00:00',
'2024-01-01 09:00:01',
]
);
}

public function testSecondlyBySecond(): void
{
$this->parse(
'FREQ=SECONDLY;BYSECOND=0,30;COUNT=3',
'2024-01-01 08:59:58',
[
'2024-01-01 08:59:58',
'2024-01-01 09:00:00',
'2024-01-01 09:00:30',
]
);
}

/**
* A bounded (UNTIL) sub-hourly rule must terminate. Before SECONDLY/MINUTELY
* were iterated, currentDate never advanced past the start, so valid() stayed
* true forever. The hard cap keeps a regression from hanging the suite.
*/
public function testMinutelyUntilTerminates(): void
{
$parser = new RRuleIterator('FREQ=MINUTELY;UNTIL=20240101T091000Z', new \DateTime('2024-01-01 09:00:00', new \DateTimeZone('UTC')));
$result = [];
while ($parser->valid()) {
$result[] = $parser->current()->format('H:i:s');
if (count($result) > 100) {
self::fail('MINUTELY;UNTIL did not terminate');
}
$parser->next();
}
self::assertSame(['09:00:00', '09:01:00', '09:02:00', '09:03:00', '09:04:00', '09:05:00', '09:06:00', '09:07:00', '09:08:00', '09:09:00', '09:10:00'], $result);
}

public function testSecondlyUntilTerminates(): void
{
$parser = new RRuleIterator('FREQ=SECONDLY;UNTIL=20240101T090005Z', new \DateTime('2024-01-01 09:00:00', new \DateTimeZone('UTC')));
$result = [];
while ($parser->valid()) {
$result[] = $parser->current()->format('H:i:s');
if (count($result) > 100) {
self::fail('SECONDLY;UNTIL did not terminate');
}
$parser->next();
}
self::assertSame(['09:00:00', '09:00:01', '09:00:02', '09:00:03', '09:00:04', '09:00:05'], $result);
}

public function parse($rule, string $start, array $expected, ?string $fastForward = null, string $tz = 'UTC', bool $runTillTheEnd = false): void
{
$dt = new \DateTime($start, new \DateTimeZone($tz));
Expand Down