*/ public function parse(string $rule): array { $parts = []; foreach (explode(';', preg_replace('/^RRULE:/i', '', trim($rule)) ?? '') as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); $key = strtoupper($key); if ($key === '' || $value === '' || isset($parts[$key])) { throw new \InvalidArgumentException('A recurrence rule must contain unique, complete rule parts.'); } $parts[$key] = strtoupper($value); } if (! in_array($parts['FREQ'] ?? '', ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], true)) { throw new \InvalidArgumentException('Choose daily, weekly, monthly or yearly recurrence.'); } if (array_diff(array_keys($parts), ['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYSETPOS', 'WKST']) !== []) { throw new \InvalidArgumentException('This recurrence rule includes an unsupported calendar rule part.'); } if (isset($parts['COUNT'], $parts['UNTIL'])) { throw new \InvalidArgumentException('Choose an end date or occurrence count, not both.'); } return $parts; } public function expand(string $rule, DateTimeImmutable $startsAt, DateInterval $duration, DateTimeImmutable $from, DateTimeImmutable $until, array $exclusions = [], array $additions = [], int $previewLimit = 0): iterable { $parts = $this->parse($rule); $count = isset($parts['COUNT']) ? filter_var($parts['COUNT'], FILTER_VALIDATE_INT) : null; if ($count !== null && ($count === false || $count < 1)) { throw new \InvalidArgumentException('The occurrence count must be positive.'); } $zone = $startsAt->getTimezone(); $floating = new DateTimeZone('UTC'); $first = new DateTimeImmutable($startsAt->format('Y-m-d H:i:s'), $floating); $bound = new DateTimeImmutable($until->setTimezone($zone)->format('Y-m-d H:i:s'), $floating); $ruleEnd = isset($parts['UNTIL']) ? $this->date($parts['UNTIL'], $startsAt, true) : $until; unset($parts['COUNT'], $parts['UNTIL']); $rrule = new RRule($parts + ['DTSTART' => $first, 'UNTIL' => $bound]); $excluded = []; foreach ($exclusions as $value) { $excluded[$this->date($value, $startsAt)->format('Y-m-d H:i:sP')] = true; } $result = []; $accepted = 0; $scanned = 0; foreach ($rrule as $candidate) { if (++$scanned > 200000) { throw new \InvalidArgumentException('This schedule exceeds the bounded recurrence work limit.'); } $local = $candidate->format('Y-m-d\TH:i:s'); try { $time = EventTime::fromLocal($local, $local, $zone->getName()); } catch (\InvalidArgumentException) { // RFC 5545: nonexistent local times do not consume COUNT. continue; } $start = $time->start; $transitions = $zone->getTransitions($start->getTimestamp() - 86400, $start->getTimestamp() + 86400); $previousOffset = null; foreach ($transitions ?: [] as $transition) { $wall = $candidate->getTimestamp(); if ($previousOffset !== null && $transition['offset'] < $previousOffset && $wall >= $transition['ts'] + $transition['offset'] && $wall < $transition['ts'] + $previousOffset) { // An ambiguous local time means its first occurrence in RFC 5545. $start = (new DateTimeImmutable('@' . ($wall - $previousOffset)))->setTimezone($zone); } $previousOffset = $transition['offset']; } if ($start > $ruleEnd || $start > $until || ($count !== null && $accepted >= $count)) { break; } ++$accepted; if ($start >= $from && ! isset($excluded[$start->format('Y-m-d H:i:sP')])) { $result[$start->format('Y-m-d H:i:sP')] = $start; if ($previewLimit > 0 && count($result) >= $previewLimit) { break; } } } foreach ($additions as $value) { $date = $this->date($value, $startsAt); if ($date >= $from && $date <= $until && ! isset($excluded[$date->format('Y-m-d H:i:sP')])) { $result[$date->format('Y-m-d H:i:sP')] = $date; } } uasort($result, static fn (DateTimeImmutable $a, DateTimeImmutable $b): int => $a->getTimestamp() <=> $b->getTimestamp()); foreach ($result as $start) { yield new EventTime($start, $start->add($duration), $zone->getName()); } } public function exceptionDate(string $value, DateTimeImmutable $context): DateTimeImmutable { return $this->date($value, $context); } private function date(string $value, DateTimeImmutable $context, bool $ending = false): DateTimeImmutable { if (preg_match('/^\d{8}T\d{6}Z?$/D', $value)) { $compact = DateTimeImmutable::createFromFormat('!Ymd\THis' . (str_ends_with($value, 'Z') ? '\Z' : ''), $value, str_ends_with($value, 'Z') ? new DateTimeZone('UTC') : $context->getTimezone()); if (! $compact || $compact->format('Ymd\THis' . (str_ends_with($value, 'Z') ? '\Z' : '')) !== $value) { throw new \InvalidArgumentException('Invalid recurrence date.'); } return str_ends_with($value, 'Z') ? $compact->setTimezone($context->getTimezone()) : EventTime::fromLocal($compact->format('Y-m-d\TH:i:s'), $compact->format('Y-m-d\TH:i:s'), $context->getTimezone()->getName(), true)->start; } if (preg_match('/^\d{8}$/D', $value)) { $value = substr($value, 0, 4) . '-' . substr($value, 4, 2) . '-' . substr($value, 6, 2); } if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) { $value .= 'T' . ($ending ? '23:59:59' : $context->format('H:i:s')); } return EventTime::fromLocal(str_replace(' ', 'T', $value), str_replace(' ', 'T', $value), $context->getTimezone()->getName(), true)->start; } }