1: <?php
2: declare(strict_types=1);
3:
4: namespace om\RRule;
5:
6: use DateTime;
7: use DateTimeInterface;
8: use Generator;
9: use IteratorAggregate;
10: use om\ICal\Exception\InvalidRecurrenceRuleException;
11: use om\ICal\Exception\ResourceLimitException;
12:
13: /**
14: * Recurrence set of RFC 5545, section 3.8.5: RRULE occurrences and RDATE values,
15: * minus EXDATE values, as sorted unique timestamps.
16: *
17: * EXDATE values may also be whole days (date-only EXDATE of a DATE-TIME event),
18: * which remove every occurrence on that local day.
19: *
20: * @implements IteratorAggregate<int, int>
21: */
22: final class RecurrenceSet implements IteratorAggregate {
23: /** @var list<int> */
24: private readonly array $rdates;
25: /** @var array<int, true> */
26: private readonly array $exdates;
27: /** @var array<string, true> */
28: private readonly array $exdays;
29:
30: /** @var list<Rule> */
31: private readonly array $rules;
32:
33: /**
34: * @param Rule|list<Rule>|null $rule RRULE (several rules are combined, as in RFC 2445); without it the set starts with DTSTART only
35: * @param list<int> $rdates RDATE timestamps
36: * @param list<int> $exdates EXDATE timestamps
37: * @param list<string> $exdays EXDATE days as "Ymd" in the timezone of DTSTART
38: * @param ?int $until inclusive end of the RRULE expansion (RDATE values are not limited)
39: * @param ?int $from RRULE occurrences before this timestamp are skipped
40: * @param int $limit maximal number of returned occurrences
41: * @param bool $strict throw ResourceLimitException instead of stopping at the limit
42: * @param int $maxIterations see Expander
43: * @throws InvalidRecurrenceRuleException recurrence.unsupported-rscale for a rule of another calendar system than GREGORIAN
44: */
45: public function __construct(
46: private readonly DateTimeInterface $start,
47: Rule|array|null $rule = null,
48: array $rdates = [],
49: array $exdates = [],
50: array $exdays = [],
51: private readonly ?int $until = null,
52: private readonly ?int $from = null,
53: private readonly int $limit = PHP_INT_MAX,
54: private readonly bool $strict = false,
55: private readonly int $maxIterations = PHP_INT_MAX,
56: ) {
57: $this->rules = $rule === null ? [] : ($rule instanceof Rule ? [$rule] : $rule);
58: foreach ($this->rules as $item) {
59: $item->assertGregorian();
60: }
61: $rdates = array_values(array_unique($rdates));
62: sort($rdates);
63: $this->rdates = $rdates;
64: $this->exdates = array_fill_keys($exdates, true);
65: $this->exdays = array_fill_keys($exdays, true);
66: }
67:
68: /**
69: * @return Generator<int, int>
70: */
71: public function getIterator(): Generator {
72: $last = null;
73: $count = 0;
74: foreach ($this->merged() as $timestamp) {
75: if ($timestamp === $last || isset($this->exdates[$timestamp])
76: || ($this->exdays !== [] && isset($this->exdays[$this->day($timestamp)]))) {
77: continue;
78: }
79: if ($count >= $this->limit) {
80: if ($this->strict) {
81: throw ResourceLimitException::create('recurrence.limit', "Recurrence occurrence limit of {$this->limit} exceeded.");
82: }
83: return;
84: }
85: $count++;
86: $last = $timestamp;
87: yield $timestamp;
88: }
89: }
90:
91: /**
92: * RRULE occurrences merged with RDATE values, sorted, possibly with duplicates.
93: *
94: * @return Generator<int, int>
95: */
96: private function merged(): Generator {
97: $rdates = $this->rdates;
98: $index = 0;
99: foreach ($this->ruleOccurrences() as $timestamp) {
100: while (isset($rdates[$index]) && $rdates[$index] < $timestamp) {
101: yield $rdates[$index++];
102: }
103: yield $timestamp;
104: }
105: while (isset($rdates[$index])) {
106: yield $rdates[$index++];
107: }
108: }
109:
110: /**
111: * Occurrences of all rules merged in order (duplicates are removed later).
112: *
113: * @return iterable<int>
114: */
115: private function ruleOccurrences(): iterable {
116: if ($this->rules === []) {
117: return [$this->start->getTimestamp()];
118: }
119: return (function (): Generator {
120: $streams = [];
121: foreach ($this->rules as $rule) {
122: $stream = (new Expander($rule, $this->start, $this->until, PHP_INT_MAX, $this->maxIterations))->getIterator();
123: if ($stream->valid()) {
124: $streams[] = $stream;
125: }
126: }
127: while ($streams !== []) {
128: $next = 0;
129: foreach ($streams as $index => $stream) {
130: if ($stream->current() < $streams[$next]->current()) {
131: $next = $index;
132: }
133: }
134: $timestamp = $streams[$next]->current();
135: $streams[$next]->next();
136: if (!$streams[$next]->valid()) {
137: array_splice($streams, $next, 1);
138: }
139: if ($this->from === null || $timestamp >= $this->from) {
140: yield $timestamp;
141: }
142: }
143: })();
144: }
145:
146: private function day(int $timestamp): string {
147: return DateTime::createFromInterface($this->start)->setTimestamp($timestamp)->format('Ymd');
148: }
149: }
150: