parse($rule); $limit = isset($parts['COUNT']) ? max(1, (int) $parts['COUNT']) : self::MAX_CANDIDATES; $ruleUntil = isset($parts['UNTIL']) ? $this->parseDate($parts['UNTIL'], $startsAt) : $until; $last = $ruleUntil < $until ? $ruleUntil : $until; $excluded = array_fill_keys(array_map(fn (string $value): string => $this->key($this->parseDate($value, $startsAt)), $exclusions), true); $results = []; $matched = 0; $cursor = $startsAt; $scanned = 0; while ($cursor <= $last && $matched < $limit && $scanned < self::MAX_CANDIDATES) { ++$scanned; if ($this->matches($cursor, $startsAt, $parts)) { ++$matched; if ($cursor >= $from && ! isset($excluded[$this->key($cursor)])) { $results[$this->key($cursor)] = $cursor; } } $cursor = $cursor->add(new DateInterval('P1D')); } foreach ($additions as $addition) { $date = $this->parseDate($addition, $startsAt); if ($date >= $from && $date <= $until && ! isset($excluded[$this->key($date)])) { $results[$this->key($date)] = $date; } } ksort($results); foreach ($results as $start) { yield EventTime::fromLocal( $start->format('Y-m-d\TH:i:s'), $start->add($duration)->format('Y-m-d\TH:i:s'), $start->getTimezone()->getName(), ); } } /** @return array */ private function parse(string $rule): array { $rule = strtoupper(trim(str_replace('RRULE:', '', $rule))); $parts = []; foreach (explode(';', $rule) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); if ($key !== '' && $value !== '') { $parts[$key] = $value; } } if (! in_array($parts['FREQ'] ?? '', ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], true)) { throw new \InvalidArgumentException('Unsupported or missing recurrence frequency.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $unsupported = array_diff(array_keys($parts), ['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY']); if ($unsupported !== []) { throw new \InvalidArgumentException('Unsupported recurrence rule part: ' . implode(', ', $unsupported)); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } if (isset($parts['BYDAY']) && preg_match('/^(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*$/', $parts['BYDAY']) !== 1) { throw new \InvalidArgumentException('Only non-ordinal BYDAY values are supported.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } if (isset($parts['BYMONTHDAY']) && preg_match('/^(?:[1-9]|[12][0-9]|3[01])(?:,(?:[1-9]|[12][0-9]|3[01]))*$/', $parts['BYMONTHDAY']) !== 1) { throw new \InvalidArgumentException('BYMONTHDAY must contain calendar day numbers from 1 to 31.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } return $parts; } /** @param array $rule */ private function matches(DateTimeImmutable $date, DateTimeImmutable $start, array $rule): bool { if ($date < $start) { return false; } $interval = max(1, (int) ($rule['INTERVAL'] ?? 1)); $days = (int) $start->setTime(0, 0)->diff($date->setTime(0, 0))->format('%a'); $months = (((int) $date->format('Y') - (int) $start->format('Y')) * 12) + (int) $date->format('n') - (int) $start->format('n'); $frequencyMatches = match ($rule['FREQ']) { 'DAILY' => $days % $interval === 0, 'WEEKLY' => intdiv($days, 7) % $interval === 0, 'MONTHLY' => $months % $interval === 0, 'YEARLY' => ((int) $date->format('Y') - (int) $start->format('Y')) % $interval === 0 && $date->format('m') === $start->format('m'), default => false, }; if (! $frequencyMatches) { return false; } $byDays = isset($rule['BYDAY']) ? explode(',', $rule['BYDAY']) : []; $dayCodes = [1 => 'MO', 2 => 'TU', 3 => 'WE', 4 => 'TH', 5 => 'FR', 6 => 'SA', 7 => 'SU']; if ($byDays !== [] && ! in_array($dayCodes[(int) $date->format('N')], $byDays, true)) { return false; } if ($byDays === [] && $rule['FREQ'] === 'WEEKLY' && $date->format('N') !== $start->format('N')) { return false; } $monthDays = isset($rule['BYMONTHDAY']) ? array_map('intval', explode(',', $rule['BYMONTHDAY'])) : []; if ($monthDays !== [] && ! in_array((int) $date->format('j'), $monthDays, true)) { return false; } if ($monthDays === [] && in_array($rule['FREQ'], ['MONTHLY', 'YEARLY'], true) && $date->format('j') !== $start->format('j')) { return false; } return $date->format('H:i:s') === $start->format('H:i:s'); } private function parseDate(string $value, DateTimeImmutable $context): DateTimeImmutable { $value = trim($value); if (preg_match('/^\d{8}T\d{6}Z$/', $value) === 1) { return (new DateTimeImmutable($value))->setTimezone($context->getTimezone()); } if (preg_match('/^\d{8}$/', $value) === 1) { $date = DateTimeImmutable::createFromFormat('!Ymd', $value, $context->getTimezone()); if ($date instanceof DateTimeImmutable) { return $date->setTime((int) $context->format('H'), (int) $context->format('i'), (int) $context->format('s')); } } try { return new DateTimeImmutable($value, $context->getTimezone()); } catch (\Exception $exception) { throw new \InvalidArgumentException('Invalid recurrence date.', 0, $exception); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } } private function key(DateTimeImmutable $date): string { return $date->format('Y-m-d H:i:sP'); } }