diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 6f1f28ae..0e7df92e 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -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; @@ -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. */ diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 0d14cbae..74367fab 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -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));