1: <?php
2: declare(strict_types=1);
3:
4: namespace om\RRule;
5:
6: use DateTimeImmutable;
7: use DateTimeInterface;
8: use DateTimeZone;
9: use Exception;
10: use om\ICal\Exception\InvalidRecurrenceRuleException;
11:
12: /**
13: * Validated recurrence rule (RFC 5545, section 3.3.10) with the RSCALE and SKIP
14: * rule parts and leap months of RFC 7529.
15: *
16: * Weekdays are ISO-8601 numbers (1 = Monday ... 7 = Sunday).
17: */
18: final readonly class Rule {
19: public const array WEEKDAYS = ['MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 7];
20:
21: /**
22: * @param list<int> $bySecond
23: * @param list<int> $byMinute
24: * @param list<int> $byHour
25: * @param list<array{int, int}> $byDay pairs of [ordinal (0 = every), weekday]
26: * @param list<int> $byMonthDay
27: * @param list<int> $byYearDay
28: * @param list<int> $byWeekNo
29: * @param list<int> $byMonth
30: * @param list<int> $bySetPos
31: * @param DateTimeImmutable|string|null $until parsed date, or a floating value resolved in the DTSTART timezone
32: * @param int<1, 7> $wkst
33: * @param ?string $rscale calendar system in upper case (RFC 7529), null for a plain RFC 5545 rule
34: * @param ?Skip $skip handling of invalid dates, null when not given (the default is OMIT); requires RSCALE
35: * @param list<int> $byLeapMonth leap months of BYMONTH, e.g. 5 for "5L"; requires RSCALE
36: */
37: public function __construct(
38: public Frequency $freq,
39: public int $interval = 1,
40: public ?int $count = null,
41: public DateTimeImmutable|string|null $until = null,
42: public array $bySecond = [],
43: public array $byMinute = [],
44: public array $byHour = [],
45: public array $byDay = [],
46: public array $byMonthDay = [],
47: public array $byYearDay = [],
48: public array $byWeekNo = [],
49: public array $byMonth = [],
50: public array $bySetPos = [],
51: public int $wkst = 1,
52: public ?string $rscale = null,
53: public ?Skip $skip = null,
54: public array $byLeapMonth = [],
55: ) {
56: if ($interval < 1) {
57: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'INTERVAL must be a positive integer.');
58: }
59: if ($count !== null && $count < 1) {
60: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'COUNT must be a positive integer.');
61: }
62: if ($rscale === null && $skip !== null) {
63: throw InvalidRecurrenceRuleException::create('recurrence.skip-without-rscale', 'SKIP must not be present without RSCALE (RFC 7529, section 4).');
64: }
65: if ($rscale === null && $byLeapMonth !== []) {
66: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'Leap months in BYMONTH require RSCALE.');
67: }
68: }
69:
70: /**
71: * Parse a rule like "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE".
72: * With $ignoreSkipWithoutRscale, SKIP without RSCALE is ignored instead of rejected.
73: */
74: public static function fromString(string $rule, bool $ignoreSkipWithoutRscale = false): self {
75: $parts = [];
76: foreach (explode(';', trim($rule)) as $part) {
77: if ($part === '') {
78: continue;
79: }
80: $pair = explode('=', $part, 2);
81: if (count($pair) !== 2 || $pair[0] === '' || $pair[1] === '') {
82: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'Invalid recurrence rule.');
83: }
84: $parts[$pair[0]] = $pair[1];
85: }
86: return self::fromArray($parts, $ignoreSkipWithoutRscale);
87: }
88:
89: /**
90: * Build a rule from rule parts; keys are case-insensitive and unknown parts are ignored.
91: * UNTIL may be a string, a DateTimeInterface or a Unix timestamp.
92: * With $ignoreSkipWithoutRscale, SKIP without RSCALE is ignored instead of rejected,
93: * which is how rules were read before RFC 7529.
94: *
95: * @param array<string, mixed> $parts
96: */
97: public static function fromArray(array $parts, bool $ignoreSkipWithoutRscale = false): self {
98: $parts = array_change_key_case($parts, CASE_UPPER);
99: if ($ignoreSkipWithoutRscale && !isset($parts['RSCALE'])) {
100: unset($parts['SKIP']);
101: }
102: $freq = Frequency::tryFrom(strtoupper(self::scalar($parts['FREQ'] ?? '', 'FREQ')));
103: if ($freq === null) {
104: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'Unsupported recurrence frequency: ' . self::scalar($parts['FREQ'] ?? '', 'FREQ'));
105: }
106:
107: $interval = isset($parts['INTERVAL']) ? self::positiveInt($parts['INTERVAL'], 'INTERVAL') : 1;
108: $count = isset($parts['COUNT']) ? self::positiveInt($parts['COUNT'], 'COUNT') : null;
109:
110: $until = null;
111: if (isset($parts['UNTIL'])) {
112: $until = self::until($parts['UNTIL']);
113: }
114:
115: $wkst = 1;
116: if (isset($parts['WKST'])) {
117: $wkst = self::WEEKDAYS[strtoupper(self::scalar($parts['WKST'], 'WKST'))]
118: ?? throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'Invalid WKST value.');
119: }
120:
121: $rscale = null;
122: if (isset($parts['RSCALE'])) {
123: $rscale = strtoupper(self::scalar($parts['RSCALE'], 'RSCALE'));
124: if (!preg_match('/^[A-Z0-9-]{1,64}$/D', $rscale)) {
125: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "Invalid RSCALE value: $rscale");
126: }
127: }
128: $skip = null;
129: if (isset($parts['SKIP'])) {
130: $skip = Skip::tryFrom(strtoupper(self::scalar($parts['SKIP'], 'SKIP')))
131: ?? throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'Invalid SKIP value: ' . self::scalar($parts['SKIP'], 'SKIP'));
132: }
133: // other calendar systems have other limits (RFC 7529, section 4), e.g. 13 months or 385 days
134: $gregorian = $rscale === null || $rscale === 'GREGORIAN';
135: $limit = static fn(int $max): int => $gregorian ? $max : 999;
136: [$byMonth, $byLeapMonth] = self::monthList($parts, $rscale !== null, $limit(12));
137:
138: return new self(
139: freq: $freq,
140: interval: $interval,
141: count: $count,
142: until: $until,
143: bySecond: self::intList($parts, 'BYSECOND', 0, 60),
144: byMinute: self::intList($parts, 'BYMINUTE', 0, 59),
145: byHour: self::intList($parts, 'BYHOUR', 0, 23),
146: byDay: self::weekdayList($parts),
147: byMonthDay: self::intList($parts, 'BYMONTHDAY', -$limit(31), $limit(31), false),
148: byYearDay: self::intList($parts, 'BYYEARDAY', -$limit(366), $limit(366), false),
149: byWeekNo: self::intList($parts, 'BYWEEKNO', -$limit(53), $limit(53), false),
150: byMonth: $byMonth,
151: bySetPos: self::intList($parts, 'BYSETPOS', -$limit(366), $limit(366), false),
152: wkst: $wkst,
153: rscale: $rscale,
154: skip: $skip,
155: byLeapMonth: $byLeapMonth,
156: );
157: }
158:
159: /**
160: * Serialize the rule, e.g. "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE".
161: */
162: public function toString(): string {
163: $days = array_flip(self::WEEKDAYS);
164: $parts = ['FREQ' => $this->freq->value];
165: if ($this->rscale !== null) {
166: $parts['RSCALE'] = $this->rscale;
167: }
168: if ($this->until !== null) {
169: $parts['UNTIL'] = is_string($this->until) ? $this->until : $this->until->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z');
170: }
171: if ($this->count !== null) {
172: $parts['COUNT'] = $this->count;
173: }
174: if ($this->interval !== 1) {
175: $parts['INTERVAL'] = $this->interval;
176: }
177: $lists = [
178: 'BYSECOND' => $this->bySecond, 'BYMINUTE' => $this->byMinute, 'BYHOUR' => $this->byHour,
179: 'BYDAY' => array_map(static fn(array $day): string => ($day[0] ?: '') . $days[$day[1]], $this->byDay),
180: 'BYMONTHDAY' => $this->byMonthDay, 'BYYEARDAY' => $this->byYearDay, 'BYWEEKNO' => $this->byWeekNo,
181: 'BYMONTH' => $this->months(), 'BYSETPOS' => $this->bySetPos,
182: ];
183: foreach ($lists as $name => $values) {
184: if ($values !== []) {
185: $parts[$name] = implode(',', $values);
186: }
187: }
188: if ($this->wkst !== 1) {
189: $parts['WKST'] = $days[$this->wkst];
190: }
191: if ($this->skip !== null) {
192: $parts['SKIP'] = $this->skip->value;
193: }
194: return implode(';', array_map(static fn(string $name, string|int $value): string => "$name=$value", array_keys($parts), $parts));
195: }
196:
197: /**
198: * Whether the rule uses the Gregorian calendar, the only calendar system the Expander supports:
199: * no RSCALE or RSCALE=GREGORIAN, and no leap months.
200: */
201: public function isGregorian(): bool {
202: return ($this->rscale === null || $this->rscale === 'GREGORIAN') && $this->byLeapMonth === [];
203: }
204:
205: /**
206: * @throws InvalidRecurrenceRuleException recurrence.unsupported-rscale for a rule of another calendar system
207: */
208: public function assertGregorian(): void {
209: if ($this->rscale !== null && $this->rscale !== 'GREGORIAN') {
210: throw InvalidRecurrenceRuleException::create('recurrence.unsupported-rscale', "Unsupported calendar system RSCALE=$this->rscale, the rule is not expanded.");
211: }
212: if ($this->byLeapMonth !== []) {
213: $months = implode(',', array_map(static fn(int $month): string => $month . 'L', $this->byLeapMonth));
214: throw InvalidRecurrenceRuleException::create('recurrence.unsupported-rscale', "Leap months (BYMONTH=$months) are not supported in RSCALE=$this->rscale, the rule is not expanded.");
215: }
216: }
217:
218: /**
219: * Resolve UNTIL to an instant; floating values use the given timezone.
220: * A date-only UNTIL includes the whole day.
221: */
222: public function untilTimestamp(DateTimeZone $timezone): ?int {
223: if ($this->until === null) {
224: return null;
225: }
226: if ($this->until instanceof DateTimeImmutable) {
227: return $this->until->getTimestamp();
228: }
229: if (strlen($this->until) === 8) {
230: return (new DateTimeImmutable($this->until . 'T235959', $timezone))->getTimestamp();
231: }
232: return (new DateTimeImmutable($this->until, $timezone))->getTimestamp();
233: }
234:
235: /**
236: * BYMONTH values with leap months after their regular month, e.g. ["5", "5L", "6"].
237: *
238: * @return list<string>
239: */
240: private function months(): array {
241: $months = [];
242: foreach ($this->byMonth as $month) {
243: $months[$month * 2] = (string) $month;
244: }
245: foreach ($this->byLeapMonth as $month) {
246: $months[$month * 2 + 1] = $month . 'L';
247: }
248: ksort($months);
249: return array_values($months);
250: }
251:
252: private static function until(mixed $value): DateTimeImmutable|string {
253: if ($value instanceof DateTimeInterface) {
254: return DateTimeImmutable::createFromInterface($value);
255: }
256: if (is_int($value)) {
257: return new DateTimeImmutable('@' . $value);
258: }
259: $value = strtoupper(self::scalar($value, 'UNTIL'));
260: if (preg_match('/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z?))?$/D', $value, $match, PREG_UNMATCHED_AS_NULL)) {
261: [, $year, $month, $day, $hour, $minute, $second, $utc] = $match;
262: $validTime = $hour === null || ((int) $hour < 24 && (int) $minute < 60 && (int) $second <= 60);
263: if (!$validTime || !checkdate((int) $month, (int) $day, (int) $year)) {
264: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "Invalid UNTIL value: $value");
265: }
266: // a date or a floating date-time is resolved later in the timezone of DTSTART
267: return $utc === 'Z' ? new DateTimeImmutable($value) : $value;
268: }
269: try {
270: return new DateTimeImmutable($value);
271: } catch (Exception) {
272: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', 'UNTIL must be a valid date or timestamp.');
273: }
274: }
275:
276: private static function scalar(mixed $value, string $name): string {
277: if (is_string($value) || is_int($value)) {
278: return trim((string) $value);
279: }
280: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', $name . ' must be a string.');
281: }
282:
283: private static function positiveInt(mixed $value, string $name): int {
284: $value = self::scalar($value, $name);
285: if (!preg_match('/^\+?\d{1,9}$/D', $value) || (int) $value < 1) {
286: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', $name . ' must be a positive integer.');
287: }
288: return (int) $value;
289: }
290:
291: /**
292: * @param array<string, mixed> $parts
293: * @return list<int>
294: */
295: private static function intList(array $parts, string $name, int $min, int $max, bool $allowZero = true): array {
296: if (!isset($parts[$name])) {
297: return [];
298: }
299: $values = [];
300: foreach (explode(',', self::scalar($parts[$name], $name)) as $item) {
301: $item = trim($item);
302: if ($item === '') {
303: continue; // tolerate "BYHOUR=9," produced by some generators
304: }
305: if (!preg_match('/^[+-]?\d{1,3}$/D', $item)) {
306: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "Invalid $name value: $item");
307: }
308: $value = (int) $item;
309: if ($value < $min || $value > $max || (!$allowZero && $value === 0)) {
310: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "$name value out of range: $item");
311: }
312: $values[$value] = $value;
313: }
314: sort($values);
315: return $values;
316: }
317:
318: /**
319: * BYMONTH as regular and leap months; leap months ("5L") are allowed with RSCALE only.
320: *
321: * @param array<string, mixed> $parts
322: * @return array{list<int>, list<int>}
323: */
324: private static function monthList(array $parts, bool $leapMonths, int $max): array {
325: if (!isset($parts['BYMONTH'])) {
326: return [[], []];
327: }
328: $regular = $leap = [];
329: foreach (explode(',', strtoupper(self::scalar($parts['BYMONTH'], 'BYMONTH'))) as $item) {
330: $item = trim($item);
331: if ($item === '') {
332: continue;
333: }
334: if (!preg_match($leapMonths ? '/^([+-]?\d{1,3})(L?)$/D' : '/^([+-]?\d{1,3})()$/D', $item, $match)) {
335: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "Invalid BYMONTH value: $item");
336: }
337: $value = (int) $match[1];
338: if ($value < 1 || $value > $max) {
339: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "BYMONTH value out of range: $item");
340: }
341: if ($match[2] === 'L') {
342: $leap[$value] = $value;
343: } else {
344: $regular[$value] = $value;
345: }
346: }
347: sort($regular);
348: sort($leap);
349: return [$regular, $leap];
350: }
351:
352: /**
353: * @param array<string, mixed> $parts
354: * @return list<array{int, int}>
355: */
356: private static function weekdayList(array $parts): array {
357: if (!isset($parts['BYDAY'])) {
358: return [];
359: }
360: $values = [];
361: foreach (explode(',', strtoupper(self::scalar($parts['BYDAY'], 'BYDAY'))) as $item) {
362: $item = trim($item);
363: if ($item === '') {
364: continue;
365: }
366: if (!preg_match('/^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/D', $item, $match)) {
367: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "Invalid BYDAY value: $item");
368: }
369: $ordinal = (int) $match[1]; // an empty ordinal means every weekday
370: if ($ordinal < -53 || $ordinal > 53) {
371: throw InvalidRecurrenceRuleException::create('recurrence.invalid-rule', "BYDAY value out of range: $item");
372: }
373: $values[] = [$ordinal, self::WEEKDAYS[$match[2]]];
374: }
375: return $values;
376: }
377: }
378: