| 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: | |
| 14: | |
| 15: | |
| 16: | |
| 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: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | |
| 31: | |
| 32: | |
| 33: | |
| 34: | |
| 35: | |
| 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: | |
| 72: | |
| 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: | |
| 91: | |
| 92: | |
| 93: | |
| 94: | |
| 95: | |
| 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: | |
| 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: | |
| 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: | |
| 199: | |
| 200: | |
| 201: | public function isGregorian(): bool { |
| 202: | return ($this->rscale === null || $this->rscale === 'GREGORIAN') && $this->byLeapMonth === []; |
| 203: | } |
| 204: | |
| 205: | |
| 206: | |
| 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: | |
| 220: | |
| 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: | |
| 237: | |
| 238: | |
| 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: | |
| 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: | |
| 293: | |
| 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; |
| 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: | |
| 320: | |
| 321: | |
| 322: | |
| 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: | |
| 354: | |
| 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]; |
| 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: | |