1: <?php
2: declare(strict_types=1);
3:
4: namespace om;
5:
6: use DateTimeImmutable;
7: use DateTimeZone;
8: use Generator;
9: use InvalidArgumentException;
10: use om\RRule\Expander;
11: use om\RRule\Frequency;
12: use om\RRule\Rule;
13: use RuntimeException;
14:
15: /**
16: * Timestamp based access to a recurrence set (RRULE plus RDATE, minus EXDATE).
17: *
18: * Wall-clock calculations use the process default timezone, as in previous versions.
19: * The recurrence engine itself lives in {@see Expander}.
20: *
21: * Originally based on https://github.com/coopTilleuls/intouch-iCalendar.git (Freq.php)
22: * by Morten Fangel (C) 2008 and Michael Kahn (C) 2013, CC-BY-SA-DK.
23: *
24: * @deprecated 5.0, removed in 5.5 at the latest; use om\RRule\Expander or om\RRule\RecurrenceSet, see UPGRADING.md
25: */
26: class Freq {
27: /** @deprecated has no effect */
28: public static bool $debug = false;
29:
30: protected Rule $rule;
31: protected int $start;
32: protected string $freq;
33:
34: /** @var array<int, true> EXDATE timestamps */
35: protected array $excluded;
36: /** @var list<int> RDATE timestamps, sorted */
37: protected array $added;
38:
39: /** @var list<int>|null null means not calculated; [] is a valid result */
40: protected ?array $cache = null;
41:
42: /**
43: * @param array<string, mixed>|string $rule RRULE parts or an RRULE value like "FREQ=DAILY;COUNT=3"
44: * @param int $start Unix timestamp of DTSTART
45: * @param list<int> $excluded EXDATE timestamps
46: * @param list<int> $added RDATE timestamps
47: * @param int $maxOccurrences expanding more occurrences throws RuntimeException
48: * @throws InvalidArgumentException for an invalid rule
49: */
50: public function __construct(array|string $rule, int $start, array $excluded = [], array $added = [], private readonly int $maxOccurrences = 100000) {
51: if ($maxOccurrences < 1) {
52: throw new InvalidArgumentException('maxOccurrences must be positive.');
53: }
54: $this->rule = is_string($rule) ? Rule::fromString($rule, true) : Rule::fromArray($rule, true);
55: $this->rule->assertGregorian();
56: if (($this->rule->count ?? 0) > $maxOccurrences || count($added) > $maxOccurrences) {
57: throw new RuntimeException('Recurrence occurrence limit exceeded.');
58: }
59: $this->start = $start;
60: $this->freq = strtolower($this->rule->freq->value);
61: $this->excluded = array_fill_keys($excluded, true);
62: $added = array_values(array_unique($added));
63: sort($added);
64: $this->added = $added;
65: if ($this->rule->count !== null) {
66: $this->getAllOccurrences(); // finite series are calculated eagerly, as before
67: }
68: }
69:
70: /**
71: * Next occurrence after the given timestamp, or false when there is none.
72: */
73: public function findNext(int|bool $offset): bool|int {
74: if ($offset === false) {
75: return false;
76: }
77: foreach ($this->occurrences() as $timestamp) {
78: if ($timestamp > $offset) {
79: return $timestamp;
80: }
81: }
82: return false;
83: }
84:
85: /**
86: * Start of the next FREQ period after the given timestamp (e.g. +1 month for MONTHLY).
87: */
88: public function findEndOfPeriod(int $offset = 0): int {
89: $unit = match ($this->rule->freq) {
90: Frequency::Yearly => 'year',
91: Frequency::Monthly => 'month',
92: Frequency::Weekly => 'week',
93: Frequency::Daily => 'day',
94: Frequency::Hourly => 'hour',
95: Frequency::Minutely => 'minute',
96: Frequency::Secondly => 'second',
97: };
98: return $this->localDate($offset)->modify("+1 $unit")->getTimestamp();
99: }
100:
101: /**
102: * Most recent occurrence before the given timestamp, or false when there is none.
103: */
104: public function previousOccurrence(int $offset): bool|int {
105: $previous = false;
106: foreach ($this->occurrences() as $timestamp) {
107: if ($timestamp >= $offset) {
108: break;
109: }
110: $previous = $timestamp;
111: }
112: return $previous;
113: }
114:
115: /**
116: * Next occurrence after the given timestamp, or false when there is none.
117: */
118: public function nextOccurrence(int $offset): bool|int {
119: return $this->findNext($offset);
120: }
121:
122: /**
123: * First occurrence of the recurrence set, or false for an empty set.
124: */
125: public function firstOccurrence(): bool|int {
126: foreach ($this->occurrences() as $timestamp) {
127: return $timestamp;
128: }
129: return false;
130: }
131:
132: /**
133: * Last occurrence of the recurrence set, or false for an empty set.
134: */
135: public function lastOccurrence(): int|false {
136: $all = $this->getAllOccurrences();
137: return $all === [] ? false : $all[array_key_last($all)];
138: }
139:
140: /**
141: * All occurrences, sorted. Unbounded rules throw RuntimeException after maxOccurrences.
142: *
143: * @return list<int>
144: */
145: public function getAllOccurrences(): array {
146: if ($this->cache === null) {
147: $this->cache = iterator_to_array($this->occurrences(), false);
148: }
149: return $this->cache;
150: }
151:
152: /**
153: * Sorted recurrence set: RRULE occurrences merged with RDATE, without EXDATE.
154: *
155: * @return Generator<int, int>
156: */
157: private function occurrences(): Generator {
158: if ($this->cache !== null) {
159: yield from $this->cache;
160: return;
161: }
162: $added = $this->added;
163: $index = 0;
164: $last = PHP_INT_MIN;
165: $count = 0;
166: $expander = new Expander($this->rule, $this->localDate($this->start), limit: $this->maxOccurrences);
167: foreach ($expander as $timestamp) {
168: while (isset($added[$index]) && $added[$index] <= $timestamp) {
169: yield from $this->emit($added[$index++], $last, $count);
170: }
171: yield from $this->emit($timestamp, $last, $count);
172: }
173: while (isset($added[$index])) {
174: yield from $this->emit($added[$index++], $last, $count);
175: }
176: }
177:
178: /**
179: * @return Generator<int, int>
180: */
181: private function emit(int $timestamp, int &$last, int &$count): Generator {
182: if ($timestamp !== $last && !isset($this->excluded[$timestamp])) {
183: if (++$count > $this->maxOccurrences) {
184: throw new RuntimeException('Recurrence occurrence limit exceeded.');
185: }
186: yield $timestamp;
187: }
188: $last = $timestamp;
189: }
190:
191: private function localDate(int $timestamp): DateTimeImmutable {
192: return (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone(date_default_timezone_get()));
193: }
194: }
195: