*/ private array $minutes; /** @var array */ private array $hours; /** @var array */ private array $daysOfMonth; /** @var array */ private array $months; /** @var array */ private array $daysOfWeek; private function __construct( private string $expression, array $minutes, array $hours, array $daysOfMonth, array $months, array $daysOfWeek, private bool $daysOfMonthWildcard, private bool $daysOfWeekWildcard ) { $this->minutes = $minutes; $this->hours = $hours; $this->daysOfMonth = $daysOfMonth; $this->months = $months; $this->daysOfWeek = $daysOfWeek; } public static function parse(string $expression): self { $normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? ''; if ($normalized === '') { throw new \InvalidArgumentException('Cron-Ausdruck fehlt.'); } $parts = explode(' ', $normalized); if (count($parts) !== 5) { throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.'); } return new self( $normalized, self::parseField($parts[0], 0, 59), self::parseField($parts[1], 0, 23), self::parseField($parts[2], 1, 31), self::parseField($parts[3], 1, 12, [ 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12, ]), self::parseField($parts[4], 0, 6, [ 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3, 'THU' => 4, 'FRI' => 5, 'SAT' => 6, '7' => 0, ]), trim($parts[2]) === '*', trim($parts[4]) === '*' ); } public function expression(): string { return $this->expression; } public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool { $local = $utcDateTime->setTimezone($timezone); $minute = (int) $local->format('i'); $hour = (int) $local->format('G'); $dayOfMonth = (int) $local->format('j'); $month = (int) $local->format('n'); $dayOfWeek = (int) $local->format('w'); if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) { return false; } $dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]); $dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]); if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) { $dayMatches = true; } elseif ($this->daysOfMonthWildcard) { $dayMatches = $dayOfWeekMatch; } elseif ($this->daysOfWeekWildcard) { $dayMatches = $dayOfMonthMatch; } else { $dayMatches = $dayOfMonthMatch || $dayOfWeekMatch; } return $dayMatches; } public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable { $cursor = $this->floorToMinute($utcDateTime); for ($i = 0; $i <= $lookbackMinutes; $i++) { if ($this->matches($cursor, $timezone)) { return $cursor; } $cursor = $cursor->sub(new DateInterval('PT1M')); } return null; } public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable { $cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M')); for ($i = 0; $i <= $lookaheadMinutes; $i++) { if ($this->matches($cursor, $timezone)) { return $cursor; } $cursor = $cursor->add(new DateInterval('PT1M')); } return null; } /** @return array */ private static function parseField(string $field, int $min, int $max, array $aliases = []): array { $field = strtoupper(trim($field)); if ($field === '*') { $all = []; for ($value = $min; $value <= $max; $value++) { $all[$value] = true; } return $all; } $values = []; foreach (explode(',', $field) as $segment) { $segment = strtoupper(trim($segment)); if ($segment === '') { continue; } $step = 1; if (str_contains($segment, '/')) { [$segment, $stepPart] = explode('/', $segment, 2); if (!is_numeric($stepPart) || (int) $stepPart <= 0) { throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".'); } $step = (int) $stepPart; } if ($segment === '*') { $start = $min; $end = $max; } elseif (str_contains($segment, '-')) { [$startPart, $endPart] = explode('-', $segment, 2); $start = self::normalizePart($startPart, $aliases); $end = self::normalizePart($endPart, $aliases); } else { $start = self::normalizePart($segment, $aliases); $end = $start; } if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) { throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.'); } for ($value = $start; $value <= $end; $value += $step) { $values[$value] = true; } } if ($values === []) { throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.'); } ksort($values); return $values; } private static function normalizePart(string $part, array $aliases): int { $part = strtoupper(trim($part)); if ($part === '') { throw new \InvalidArgumentException('Leerer Cron-Wert.'); } if (array_key_exists($part, $aliases)) { return (int) $aliases[$part]; } if (!is_numeric($part)) { throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".'); } return (int) $part; } private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable { return $utcDateTime->setTime( (int) $utcDateTime->format('H'), (int) $utcDateTime->format('i'), 0 ); } }