Files
desktop/docs/Umsetzungsanweisung/Old-Nexus/src/App/CronExpression.php
Lars Gebhardt-Kusche 2cdd14c400
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped
deploy
2026-06-20 01:50:07 +02:00

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
);
}
}