215 lines
6.8 KiB
PHP
215 lines
6.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App;
|
|
|
|
use DateInterval;
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
|
|
final class CronExpression
|
|
{
|
|
/** @var array<int, true> */
|
|
private array $minutes;
|
|
/** @var array<int, true> */
|
|
private array $hours;
|
|
/** @var array<int, true> */
|
|
private array $daysOfMonth;
|
|
/** @var array<int, true> */
|
|
private array $months;
|
|
/** @var array<int, true> */
|
|
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<int, true> */
|
|
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
|
|
);
|
|
}
|
|
}
|