PKiSZozzcron-expression/LICENSEnuW+ACopyright (c) 2011 Michael Dowling , 2016 Chris Tankersley , and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PKiSZ9ZEcron-expression/CHANGELOG.mdnuW+A# Change Log ## [3.3.3] - 2024-08-10 ### Added - N/A ### Changed - N/A ### Fixed - Added fixes for making sure `?` is not passed for both DOM and DOW (#148, thank you https://github.com/LeoVie) - Fixed bug in Next Execution Time by sorting minutes properly (#160, thank you https://github.com/imyip) ## [3.3.2] - 2022-09-19 ### Added - N/A ### Changed - Skip some daylight savings time tests for PHP 8.1 daylight savings time weirdness (#146) ### Fixed - Changed string interpolations to work better with PHP 8.2 (#142) ## [3.3.1] - 2022-01-18 ### Added - N/A ### Changed - N/A ### Fixed - Fixed issue when timezones had no transition, which can occur over very short timespans (#134) ## [3.3.0] - 2022-01-13 ### Added - Added ability to register your own expression aliases (#132) ### Changed - Changed how Day of Week and Day of Month resolve when one or the other is `*` or `?` ### Fixed - PHPStan should no longer error out ## [3.2.4] - 2022-01-12 ### Added - N/A ### Changed - Changed how Day of Week increment/decrement to help with DST changes (#131) ### Fixed - N/A ## [3.2.3] - 2022-01-05 ### Added - N/A ### Changed - Changed how minutes and hours increment/decrement to help with DST changes (#131) ### Fixed - N/A ## [3.2.2] - 2022-01-05 ### Added - N/A ### Changed - Marked some methods `@internal` (#124) ### Fixed - Fixed issue with small ranges and large steps that caused an error with `range()` (#88) - Fixed issue where wraparound logic incorrectly considered high bound on range (#89) ## [3.2.1] - 2022-01-04 ### Added - N/A ### Changed - Added PHP 8.1 to testing (#125) ### Fixed - Allow better mixture of ranges, steps, and lists (#122) - Fixed return order when multiple dates are requested and inverted (#121) - Better handling over DST (#115) - Fixed PHPStan tests (#130) ## [3.2.0] - 2022-01-04 ### Added - Added alias for `@midnight` (#117) ### Changed - Improved testing for instance of field in tests (#105) - Optimization for determining multiple run dates (#75) - `CronExpression` properties changed from private to protected (#106) ### Fixed - N/A ## [3.1.0] - 2020-11-24 ### Added - Added `CronExpression::getParts()` method to get parts of the expression as an array (#83) ### Changed - Changed to Interfaces for some type hints (#97, #86) - Dropped minimum PHP version to 7.2 - Few syntax changes for phpstan compatibility (#93) ### Fixed - N/A ### Deprecated - Deprecated `CronExpression::factory` in favor of the constructor (#56) - Deprecated `CronExpression::YEAR` as a formality, the functionality is already removed (#87) ## [3.0.1] - 2020-10-12 ### Added - Added support for PHP 8 (#92) ### Changed - N/A ### Fixed - N/A ## [3.0.0] - 2020-03-25 **MAJOR CHANGE** - In previous versions of this library, setting both a "Day of Month" and a "Day of Week" would be interpreted as an `AND` statement, not an `OR` statement. For example: `30 0 1 * 1` would evaluate to "Run 30 minutes after the 0 hour when the Day Of Month is 1 AND a Monday" instead of "Run 30 minutes after the 0 hour on Day Of Month 1 OR a Monday", where the latter is more inline with most cron systems. This means that if your cron expression has both of these fields set, you may see your expression fire more often starting with v3.0.0. ### Added - Additional docblocks for IDE and documentation - Added phpstan as a development dependency - Added a `Cron\FieldFactoryInterface` to make migrations easier (#38) ### Changed - Changed some DI testing during TravisCI runs - `\Cron\CronExpression::determineTimezone()` now checks for `\DateTimeInterface` instead of just `\DateTime` - Errors with fields now report a more human-understandable error and are 1-based instead of 0-based - Better support for `\DateTimeImmutable` across the library by typehinting for `\DateTimeInterface` now - Literals should now be less case-sensative across the board - Changed logic for when both a Day of Week and a Day of Month are supplied to now be an OR statement, not an AND ### Fixed - Fixed infinite loop when determining last day of week from literals - Fixed bug where single number ranges were allowed (ex: `1/10`) - Fixed nullable FieldFactory in CronExpression where no factory could be supplied - Fixed issue where logic for dropping seconds to 0 could lead to a timezone change ## [2.3.1] - 2020-10-12 ### Added - Added support for PHP 8 (#92) ### Changed - N/A ### Fixed - N/A ## [2.3.0] - 2019-03-30 ### Added - Added support for DateTimeImmutable via DateTimeInterface - Added support for PHP 7.3 - Started listing projects that use the library ### Changed - Errors should now report a human readable position in the cron expression, instead of starting at 0 ### Fixed - N/A ## [2.2.0] - 2018-06-05 ### Added - Added support for steps larger than field ranges (#6) ## Changed - N/A ### Fixed - Fixed validation for numbers with leading 0s (#12) ## [2.1.0] - 2018-04-06 ### Added - N/A ### Changed - Upgraded to PHPUnit 6 (#2) ### Fixed - Refactored timezones to deal with some inconsistent behavior (#3) - Allow ranges and lists in same expression (#5) - Fixed regression where literals were not converted to their numerical counterpart (#) ## [2.0.0] - 2017-10-12 ### Added - N/A ### Changed - Dropped support for PHP 5.x - Dropped support for the YEAR field, as it was not part of the cron standard ### Fixed - Reworked validation for all the field types - Stepping should now work for 1-indexed fields like Month (#153) ## [1.2.0] - 2017-01-22 ### Added - Added IDE, CodeSniffer, and StyleCI.IO support ### Changed - Switched to PSR-4 Autoloading ### Fixed - 0 step expressions are handled better - Fixed `DayOfMonth` validation to be more strict - Typos ## [1.1.0] - 2016-01-26 ### Added - Support for non-hourly offset timezones - Checks for valid expressions ### Changed - Max Iterations no longer hardcoded for `getRunDate()` - Supports DateTimeImmutable for newer PHP verions ### Fixed - Fixed looping bug for PHP 7 when determining the last specified weekday of a month ## [1.0.3] - 2013-11-23 ### Added - Now supports expressions with any number of extra spaces, tabs, or newlines ### Changed - Using static instead of self in `CronExpression::factory` ### Fixed - Fixes issue [#28](https://github.com/mtdowling/cron-expression/issues/28) where PHP increments of ranges were failing due to PHP casting hyphens to 0 - Only set default timezone if the given $currentTime is not a DateTime instance ([#34](https://github.com/mtdowling/cron-expression/issues/34)) PKiSZ|7Ώ )cron-expression/src/Cron/MinutesField.phpnuW+AisSatisfied((int)$date->format('i'), $value); } /** * {@inheritdoc} * {@inheritDoc} * * @param string|null $parts */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (is_null($parts)) { $date = $this->timezoneSafeModify($date, ($invert ? "-" : "+") ."1 minute"); return $this; } $current_minute = (int) $date->format('i'); $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; sort($parts); $minutes = []; foreach ($parts as $part) { $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59)); } $position = $invert ? \count($minutes) - 1 : 0; if (\count($minutes) > 1) { for ($i = 0; $i < \count($minutes) - 1; ++$i) { if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) || ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) { $position = $invert ? $i : $i + 1; break; } } } $target = (int) $minutes[$position]; $originalMinute = (int) $date->format("i"); if (! $invert) { if ($originalMinute >= $target) { $distance = 60 - $originalMinute; $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); $originalMinute = (int) $date->format("i"); } $distance = $target - $originalMinute; $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); } else { if ($originalMinute <= $target) { $distance = ($originalMinute + 1); $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); $originalMinute = (int) $date->format("i"); } $distance = $originalMinute - $target; $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); } return $this; } } PKiSZX)88'cron-expression/src/Cron/MonthField.phpnuW+A 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL', 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC', ]; /** * {@inheritdoc} */ public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { if ($value === '?') { return true; } $value = $this->convertLiterals($value); return $this->isSatisfied((int) $date->format('m'), $value); } /** * @inheritDoc * * @param \DateTime|\DateTimeImmutable $date */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (! $invert) { $date = $date->modify('first day of next month'); $date = $date->setTime(0, 0); } else { $date = $date->modify('last day of previous month'); $date = $date->setTime(23, 59); } return $this; } } PKiSZr~U׾'cron-expression/src/Cron/HoursField.phpnuW+Aformat('H'); $retval = $this->isSatisfied($checkValue, $value); if ($retval) { return $retval; } // Are we on the edge of a transition $lastTransition = $this->getPastTransition($date); if (($lastTransition !== null) && ($lastTransition["ts"] > ((int) $date->format('U') - 3600))) { $dtLastOffset = clone $date; $this->timezoneSafeModify($dtLastOffset, "-1 hour"); $lastOffset = $dtLastOffset->getOffset(); $dtNextOffset = clone $date; $this->timezoneSafeModify($dtNextOffset, "+1 hour"); $nextOffset = $dtNextOffset->getOffset(); $offsetChange = $nextOffset - $lastOffset; if ($offsetChange >= 3600) { $checkValue -= 1; return $this->isSatisfied($checkValue, $value); } if ((! $invert) && ($offsetChange <= -3600)) { $checkValue += 1; return $this->isSatisfied($checkValue, $value); } } return $retval; } public function getPastTransition(DateTimeInterface $date): ?array { $currentTimestamp = (int) $date->format('U'); if ( ($this->transitions === null) || ($this->transitionsStart < ($currentTimestamp + 86400)) || ($this->transitionsEnd > ($currentTimestamp - 86400)) ) { // We start a day before current time so we can differentiate between the first transition entry // and a change that happens now $dtLimitStart = clone $date; $dtLimitStart = $dtLimitStart->modify("-12 months"); $dtLimitEnd = clone $date; $dtLimitEnd = $dtLimitEnd->modify('+12 months'); $this->transitions = $date->getTimezone()->getTransitions( $dtLimitStart->getTimestamp(), $dtLimitEnd->getTimestamp() ); if (empty($this->transitions)) { return null; } $this->transitionsStart = $dtLimitStart->getTimestamp(); $this->transitionsEnd = $dtLimitEnd->getTimestamp(); } $nextTransition = null; foreach ($this->transitions as $transition) { if ($transition["ts"] > $currentTimestamp) { continue; } if (($nextTransition !== null) && ($transition["ts"] < $nextTransition["ts"])) { continue; } $nextTransition = $transition; } return ($nextTransition ?? null); } /** * {@inheritdoc} * * @param string|null $parts */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { $originalTimestamp = (int) $date->format('U'); // Change timezone to UTC temporarily. This will // allow us to go back or forwards and hour even // if DST will be changed between the hours. if (null === $parts || '*' === $parts) { if ($invert) { $date = $date->sub(new \DateInterval('PT1H')); } else { $date = $date->add(new \DateInterval('PT1H')); } $date = $this->setTimeHour($date, $invert, $originalTimestamp); return $this; } $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; $hours = []; foreach ($parts as $part) { $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); } $current_hour = (int) $date->format('H'); $position = $invert ? \count($hours) - 1 : 0; $countHours = \count($hours); if ($countHours > 1) { for ($i = 0; $i < $countHours - 1; ++$i) { if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) || ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) { $position = $invert ? $i : $i + 1; break; } } } $target = (int) $hours[$position]; $originalHour = (int)$date->format('H'); $originalDay = (int)$date->format('d'); $previousOffset = $date->getOffset(); if (! $invert) { if ($originalHour >= $target) { $distance = 24 - $originalHour; $date = $this->timezoneSafeModify($date, "+{$distance} hours"); $actualDay = (int)$date->format('d'); $actualHour = (int)$date->format('H'); if (($actualDay !== ($originalDay + 1)) && ($actualHour !== 0)) { $offsetChange = ($previousOffset - $date->getOffset()); $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); } $originalHour = (int)$date->format('H'); } $distance = $target - $originalHour; $date = $this->timezoneSafeModify($date, "+{$distance} hours"); } else { if ($originalHour <= $target) { $distance = ($originalHour + 1); $date = $this->timezoneSafeModify($date, "-" . $distance . " hours"); $actualDay = (int)$date->format('d'); $actualHour = (int)$date->format('H'); if (($actualDay !== ($originalDay - 1)) && ($actualHour !== 23)) { $offsetChange = ($previousOffset - $date->getOffset()); $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); } $originalHour = (int)$date->format('H'); } $distance = $originalHour - $target; $date = $this->timezoneSafeModify($date, "-{$distance} hours"); } $date = $this->setTimeHour($date, $invert, $originalTimestamp); $actualHour = (int)$date->format('H'); if ($invert && ($actualHour === ($target - 1) || (($actualHour === 23) && ($target === 0)))) { $date = $this->timezoneSafeModify($date, "+1 hour"); } return $this; } } PKiSZөi+cron-expression/src/Cron/DayOfWeekField.phpnuW+A 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN']; /** * Constructor */ public function __construct() { $this->nthRange = range(1, 5); parent::__construct(); } /** * @inheritDoc */ public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { if ('?' === $value) { return true; } // Convert text day of the week values to integers $value = $this->convertLiterals($value); $currentYear = (int) $date->format('Y'); $currentMonth = (int) $date->format('m'); $lastDayOfMonth = (int) $date->format('t'); // Find out if this is the last specific weekday of the month if ($lPosition = strpos($value, 'L')) { $weekday = $this->convertLiterals(substr($value, 0, $lPosition)); $weekday %= 7; $daysInMonth = (int) $date->format('t'); $remainingDaysInMonth = $daysInMonth - (int) $date->format('d'); return (($weekday === (int) $date->format('w')) && ($remainingDaysInMonth < 7)); } // Handle # hash tokens if (strpos($value, '#')) { [$weekday, $nth] = explode('#', $value); if (!is_numeric($nth)) { throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given"); } else { $nth = (int) $nth; } // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 if ('0' === $weekday) { $weekday = 7; } $weekday = (int) $this->convertLiterals((string) $weekday); // Validate the hash fields if ($weekday < 0 || $weekday > 7) { throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); } if (!\in_array($nth, $this->nthRange, true)) { throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given"); } // The current weekday must match the targeted weekday to proceed if ((int) $date->format('N') !== $weekday) { return false; } $tdate = clone $date; $tdate = $tdate->setDate($currentYear, $currentMonth, 1); $dayCount = 0; $currentDay = 1; while ($currentDay < $lastDayOfMonth + 1) { if ((int) $tdate->format('N') === $weekday) { if (++$dayCount >= $nth) { break; } } $tdate = $tdate->setDate($currentYear, $currentMonth, ++$currentDay); } return (int) $date->format('j') === $currentDay; } // Handle day of the week values if (false !== strpos($value, '-')) { $parts = explode('-', $value); if ('7' === $parts[0]) { $parts[0] = 0; } elseif ('0' === $parts[1]) { $parts[1] = 7; } $value = implode('-', $parts); } // Test to see which Sunday to use -- 0 == 7 == Sunday $format = \in_array(7, array_map(function ($value) { return (int) $value; }, str_split($value)), true) ? 'N' : 'w'; $fieldValue = (int) $date->format($format); return $this->isSatisfied($fieldValue, $value); } /** * @inheritDoc */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (! $invert) { $date = $date->add(new \DateInterval('P1D')); $date = $date->setTime(0, 0); } else { $date = $date->sub(new \DateInterval('P1D')); $date = $date->setTime(23, 59); } return $this; } /** * {@inheritdoc} */ public function validate(string $value): bool { $basicChecks = parent::validate($value); if (!$basicChecks) { if ('?' === $value) { return true; } // Handle the # value if (false !== strpos($value, '#')) { $chunks = explode('#', $value); $chunks[0] = $this->convertLiterals($chunks[0]); if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && \in_array((int) $chunks[1], $this->nthRange, true)) { return true; } } if (preg_match('/^(.*)L$/', $value, $matches)) { return $this->validate($matches[1]); } return false; } return $basicChecks; } } PKiSZL,cron-expression/src/Cron/DayOfMonthField.phpnuW+A */ class DayOfMonthField extends AbstractField { /** * {@inheritdoc} */ protected $rangeStart = 1; /** * {@inheritdoc} */ protected $rangeEnd = 31; /** * Get the nearest day of the week for a given day in a month. * * @param int $currentYear Current year * @param int $currentMonth Current month * @param int $targetDay Target day of the month * * @return \DateTime|null Returns the nearest date */ private static function getNearestWeekday(int $currentYear, int $currentMonth, int $targetDay): ?DateTime { $tday = str_pad((string) $targetDay, 2, '0', STR_PAD_LEFT); $target = DateTime::createFromFormat('Y-m-d', "{$currentYear}-{$currentMonth}-{$tday}"); if ($target === false) { return null; } $currentWeekday = (int) $target->format('N'); if ($currentWeekday < 6) { return $target; } $lastDayOfMonth = $target->format('t'); foreach ([-1, 1, -2, 2] as $i) { $adjusted = $targetDay + $i; if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { $target->setDate($currentYear, $currentMonth, $adjusted); if ((int) $target->format('N') < 6 && (int) $target->format('m') === $currentMonth) { return $target; } } } return null; } /** * {@inheritdoc} */ public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { // ? states that the field value is to be skipped if ('?' === $value) { return true; } $fieldValue = $date->format('d'); // Check to see if this is the last day of the month if ('L' === $value) { return $fieldValue === $date->format('t'); } // Check to see if this is the nearest weekday to a particular value if ($wPosition = strpos($value, 'W')) { // Parse the target day $targetDay = (int) substr($value, 0, $wPosition); // Find out if the current day is the nearest day of the week $nearest = self::getNearestWeekday( (int) $date->format('Y'), (int) $date->format('m'), $targetDay ); if ($nearest) { return $date->format('j') === $nearest->format('j'); } throw new \RuntimeException('Unable to return nearest weekday'); } return $this->isSatisfied((int) $date->format('d'), $value); } /** * @inheritDoc * * @param \DateTime|\DateTimeImmutable $date */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (! $invert) { $date = $date->add(new \DateInterval('P1D')); $date = $date->setTime(0, 0); } else { $date = $date->sub(new \DateInterval('P1D')); $date = $date->setTime(23, 59); } return $this; } /** * {@inheritdoc} */ public function validate(string $value): bool { $basicChecks = parent::validate($value); // Validate that a list don't have W or L if (false !== strpos($value, ',') && (false !== strpos($value, 'W') || false !== strpos($value, 'L'))) { return false; } if (!$basicChecks) { if ('?' === $value) { return true; } if ('L' === $value) { return true; } if (preg_match('/^(.*)W$/', $value, $matches)) { return $this->validate($matches[1]); } return false; } return $basicChecks; } } PKiSZZQQ+cron-expression/src/Cron/CronExpression.phpnuW+A '0 0 1 1 *', '@annually' => '0 0 1 1 *', '@monthly' => '0 0 1 * *', '@weekly' => '0 0 * * 0', '@daily' => '0 0 * * *', '@midnight' => '0 0 * * *', '@hourly' => '0 * * * *', ]; /** * @var array CRON expression parts */ protected $cronParts; /** * @var FieldFactoryInterface CRON field factory */ protected $fieldFactory; /** * @var int Max iteration count when searching for next run date */ protected $maxIterationCount = 1000; /** * @var array Order in which to test of cron parts */ protected static $order = [ self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE, ]; /** * @var array */ private static $registeredAliases = self::MAPPINGS; /** * Registered a user defined CRON Expression Alias. * * @throws LogicException If the expression or the alias name are invalid * or if the alias is already registered. */ public static function registerAlias(string $alias, string $expression): void { try { new self($expression); } catch (InvalidArgumentException $exception) { throw new LogicException("The expression `$expression` is invalid", 0, $exception); } $shortcut = strtolower($alias); if (1 !== preg_match('/^@\w+$/', $shortcut)) { throw new LogicException("The alias `$alias` is invalid. It must start with an `@` character and contain alphanumeric (letters, numbers, regardless of case) plus underscore (_)."); } if (isset(self::$registeredAliases[$shortcut])) { throw new LogicException("The alias `$alias` is already registered."); } self::$registeredAliases[$shortcut] = $expression; } /** * Unregistered a user defined CRON Expression Alias. * * @throws LogicException If the user tries to unregister a built-in alias */ public static function unregisterAlias(string $alias): bool { $shortcut = strtolower($alias); if (isset(self::MAPPINGS[$shortcut])) { throw new LogicException("The alias `$alias` is a built-in alias; it can not be unregistered."); } if (!isset(self::$registeredAliases[$shortcut])) { return false; } unset(self::$registeredAliases[$shortcut]); return true; } /** * Tells whether a CRON Expression alias is registered. */ public static function supportsAlias(string $alias): bool { return isset(self::$registeredAliases[strtolower($alias)]); } /** * Returns all registered aliases as an associated array where the aliases are the key * and their associated expressions are the values. * * @return array */ public static function getAliases(): array { return self::$registeredAliases; } /** * @deprecated since version 3.0.2, use __construct instead. */ public static function factory(string $expression, ?FieldFactoryInterface $fieldFactory = null): CronExpression { /** @phpstan-ignore-next-line */ return new static($expression, $fieldFactory); } /** * Validate a CronExpression. * * @param string $expression the CRON expression to validate * * @return bool True if a valid CRON expression was passed. False if not. */ public static function isValidExpression(string $expression): bool { try { new CronExpression($expression); } catch (InvalidArgumentException $e) { return false; } return true; } /** * Parse a CRON expression. * * @param string $expression CRON expression (e.g. '8 * * * *') * @param null|FieldFactoryInterface $fieldFactory Factory to create cron fields * @throws InvalidArgumentException */ public function __construct(string $expression, ?FieldFactoryInterface $fieldFactory = null) { $shortcut = strtolower($expression); $expression = self::$registeredAliases[$shortcut] ?? $expression; $this->fieldFactory = $fieldFactory ?: new FieldFactory(); $this->setExpression($expression); } /** * Set or change the CRON expression. * * @param string $value CRON expression (e.g. 8 * * * *) * * @throws \InvalidArgumentException if not a valid CRON expression * * @return CronExpression */ public function setExpression(string $value): CronExpression { $split = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); if (!\is_array($split)) { throw new InvalidArgumentException( $value . ' is not a valid CRON expression' ); } $notEnoughParts = \count($split) < 5; $questionMarkInInvalidPart = array_key_exists(0, $split) && $split[0] === '?' || array_key_exists(1, $split) && $split[1] === '?' || array_key_exists(3, $split) && $split[3] === '?'; $tooManyQuestionMarks = array_key_exists(2, $split) && $split[2] === '?' && array_key_exists(4, $split) && $split[4] === '?'; if ($notEnoughParts || $questionMarkInInvalidPart || $tooManyQuestionMarks) { throw new InvalidArgumentException( $value . ' is not a valid CRON expression' ); } $this->cronParts = $split; foreach ($this->cronParts as $position => $part) { $this->setPart($position, $part); } return $this; } /** * Set part of the CRON expression. * * @param int $position The position of the CRON expression to set * @param string $value The value to set * * @throws \InvalidArgumentException if the value is not valid for the part * * @return CronExpression */ public function setPart(int $position, string $value): CronExpression { if (!$this->fieldFactory->getField($position)->validate($value)) { throw new InvalidArgumentException( 'Invalid CRON field value ' . $value . ' at position ' . $position ); } $this->cronParts[$position] = $value; return $this; } /** * Set max iteration count for searching next run dates. * * @param int $maxIterationCount Max iteration count when searching for next run date * * @return CronExpression */ public function setMaxIterationCount(int $maxIterationCount): CronExpression { $this->maxIterationCount = $maxIterationCount; return $this; } /** * Get a next run date relative to the current date or a specific date * * @param string|\DateTimeInterface $currentTime Relative calculation date * @param int $nth Number of matches to skip before returning a * matching next run date. 0, the default, will return the * current date and time if the next run date falls on the * current date and time. Setting this value to 1 will * skip the first match and go to the second match. * Setting this value to 2 will skip the first 2 * matches and so on. * @param bool $allowCurrentDate Set to TRUE to return the current date if * it matches the cron expression. * @param null|string $timeZone TimeZone to use instead of the system default * * @throws \RuntimeException on too many iterations * @throws \Exception * * @return \DateTime */ public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime { return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone); } /** * Get a previous run date relative to the current date or a specific date. * * @param string|\DateTimeInterface $currentTime Relative calculation date * @param int $nth Number of matches to skip before returning * @param bool $allowCurrentDate Set to TRUE to return the * current date if it matches the cron expression * @param null|string $timeZone TimeZone to use instead of the system default * * @throws \RuntimeException on too many iterations * @throws \Exception * * @return \DateTime * * @see \Cron\CronExpression::getNextRunDate */ public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime { return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone); } /** * Get multiple run dates starting at the current date or a specific date. * * @param int $total Set the total number of dates to calculate * @param string|\DateTimeInterface|null $currentTime Relative calculation date * @param bool $invert Set to TRUE to retrieve previous dates * @param bool $allowCurrentDate Set to TRUE to return the * current date if it matches the cron expression * @param null|string $timeZone TimeZone to use instead of the system default * * @return \DateTime[] Returns an array of run dates */ public function getMultipleRunDates(int $total, $currentTime = 'now', bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): array { $timeZone = $this->determineTimeZone($currentTime, $timeZone); if ('now' === $currentTime) { $currentTime = new DateTime(); } elseif ($currentTime instanceof DateTime) { $currentTime = clone $currentTime; } elseif ($currentTime instanceof DateTimeImmutable) { $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); } elseif (\is_string($currentTime)) { $currentTime = new DateTime($currentTime); } if (!$currentTime instanceof DateTime) { throw new InvalidArgumentException('invalid current time'); } $currentTime->setTimezone(new DateTimeZone($timeZone)); $matches = []; for ($i = 0; $i < $total; ++$i) { try { $result = $this->getRunDate($currentTime, 0, $invert, $allowCurrentDate, $timeZone); } catch (RuntimeException $e) { break; } $allowCurrentDate = false; $currentTime = clone $result; $matches[] = $result; } return $matches; } /** * Get all or part of the CRON expression. * * @param int|string|null $part specify the part to retrieve or NULL to get the full * cron schedule string * * @return null|string Returns the CRON expression, a part of the * CRON expression, or NULL if the part was specified but not found */ public function getExpression($part = null): ?string { if (null === $part) { return implode(' ', $this->cronParts); } if (array_key_exists($part, $this->cronParts)) { return $this->cronParts[$part]; } return null; } /** * Gets the parts of the cron expression as an array. * * @return string[] * The array of parts that make up this expression. */ public function getParts() { return $this->cronParts; } /** * Helper method to output the full expression. * * @return string Full CRON expression */ public function __toString(): string { return (string) $this->getExpression(); } /** * Determine if the cron is due to run based on the current date or a * specific date. This method assumes that the current number of * seconds are irrelevant, and should be called once per minute. * * @param string|\DateTimeInterface $currentTime Relative calculation date * @param null|string $timeZone TimeZone to use instead of the system default * * @return bool Returns TRUE if the cron is due to run or FALSE if not */ public function isDue($currentTime = 'now', $timeZone = null): bool { $timeZone = $this->determineTimeZone($currentTime, $timeZone); if ('now' === $currentTime) { $currentTime = new DateTime(); } elseif ($currentTime instanceof DateTime) { $currentTime = clone $currentTime; } elseif ($currentTime instanceof DateTimeImmutable) { $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); } elseif (\is_string($currentTime)) { $currentTime = new DateTime($currentTime); } if (!$currentTime instanceof DateTime) { throw new InvalidArgumentException('invalid current time'); } $currentTime->setTimezone(new DateTimeZone($timeZone)); // drop the seconds to 0 $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0); try { return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp(); } catch (Exception $e) { return false; } } /** * Get the next or previous run date of the expression relative to a date. * * @param string|\DateTimeInterface|null $currentTime Relative calculation date * @param int $nth Number of matches to skip before returning * @param bool $invert Set to TRUE to go backwards in time * @param bool $allowCurrentDate Set to TRUE to return the * current date if it matches the cron expression * @param string|null $timeZone TimeZone to use instead of the system default * * @throws \RuntimeException on too many iterations * @throws Exception * * @return \DateTime */ protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): DateTime { $timeZone = $this->determineTimeZone($currentTime, $timeZone); if ($currentTime instanceof DateTime) { $currentDate = clone $currentTime; } elseif ($currentTime instanceof DateTimeImmutable) { $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); } elseif (\is_string($currentTime)) { $currentDate = new DateTime($currentTime); } else { $currentDate = new DateTime('now'); } if (!$currentDate instanceof DateTime) { throw new InvalidArgumentException('invalid current date'); } $currentDate->setTimezone(new DateTimeZone($timeZone)); // Workaround for setTime causing an offset change: https://bugs.php.net/bug.php?id=81074 $currentDate = DateTime::createFromFormat("!Y-m-d H:iO", $currentDate->format("Y-m-d H:iP"), $currentDate->getTimezone()); if ($currentDate === false) { throw new \RuntimeException('Unable to create date from format'); } $currentDate->setTimezone(new DateTimeZone($timeZone)); $nextRun = clone $currentDate; // We don't have to satisfy * or null fields $parts = []; $fields = []; foreach (self::$order as $position) { $part = $this->getExpression($position); if (null === $part || '*' === $part) { continue; } $parts[$position] = $part; $fields[$position] = $this->fieldFactory->getField($position); } if (isset($parts[self::DAY]) && isset($parts[self::WEEKDAY])) { $domExpression = sprintf('%s %s %s %s *', $this->getExpression(0), $this->getExpression(1), $this->getExpression(2), $this->getExpression(3)); $dowExpression = sprintf('%s %s * %s %s', $this->getExpression(0), $this->getExpression(1), $this->getExpression(3), $this->getExpression(4)); $domExpression = new self($domExpression); $dowExpression = new self($dowExpression); $domRunDates = $domExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); $dowRunDates = $dowExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); if ($parts[self::DAY] === '?' || $parts[self::DAY] === '*') { $domRunDates = []; } if ($parts[self::WEEKDAY] === '?' || $parts[self::WEEKDAY] === '*') { $dowRunDates = []; } $combined = array_merge($domRunDates, $dowRunDates); usort($combined, function ($a, $b) { return $a->format('Y-m-d H:i:s') <=> $b->format('Y-m-d H:i:s'); }); if ($invert) { $combined = array_reverse($combined); } return $combined[$nth]; } // Set a hard limit to bail on an impossible date for ($i = 0; $i < $this->maxIterationCount; ++$i) { foreach ($parts as $position => $part) { $satisfied = false; // Get the field object used to validate this part $field = $fields[$position]; // Check if this is singular or a list if (false === strpos($part, ',')) { $satisfied = $field->isSatisfiedBy($nextRun, $part, $invert); } else { foreach (array_map('trim', explode(',', $part)) as $listPart) { if ($field->isSatisfiedBy($nextRun, $listPart, $invert)) { $satisfied = true; break; } } } // If the field is not satisfied, then start over if (!$satisfied) { $field->increment($nextRun, $invert, $part); continue 2; } } // Skip this match if needed if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { $this->fieldFactory->getField(self::MINUTE)->increment($nextRun, $invert, $parts[self::MINUTE] ?? null); continue; } return $nextRun; } // @codeCoverageIgnoreStart throw new RuntimeException('Impossible CRON expression'); // @codeCoverageIgnoreEnd } /** * Workout what timeZone should be used. * * @param string|\DateTimeInterface|null $currentTime Relative calculation date * @param string|null $timeZone TimeZone to use instead of the system default * * @return string */ protected function determineTimeZone($currentTime, ?string $timeZone): string { if (null !== $timeZone) { return $timeZone; } if ($currentTime instanceof DateTimeInterface) { return $currentTime->getTimezone()->getName(); } return date_default_timezone_get(); } } PKiSZ؄yy2cron-expression/src/Cron/FieldFactoryInterface.phpnuW+AfullRange = range($this->rangeStart, $this->rangeEnd); } /** * Check to see if a field is satisfied by a value. * * @internal * @param int $dateValue Date value to check * @param string $value Value to test * * @return bool */ public function isSatisfied(int $dateValue, string $value): bool { if ($this->isIncrementsOfRanges($value)) { return $this->isInIncrementsOfRanges($dateValue, $value); } if ($this->isRange($value)) { return $this->isInRange($dateValue, $value); } return '*' === $value || $dateValue === (int) $value; } /** * Check if a value is a range. * * @internal * @param string $value Value to test * * @return bool */ public function isRange(string $value): bool { return false !== strpos($value, '-'); } /** * Check if a value is an increments of ranges. * * @internal * @param string $value Value to test * * @return bool */ public function isIncrementsOfRanges(string $value): bool { return false !== strpos($value, '/'); } /** * Test if a value is within a range. * * @internal * @param int $dateValue Set date value * @param string $value Value to test * * @return bool */ public function isInRange(int $dateValue, $value): bool { $parts = array_map( function ($value) { $value = trim($value); return $this->convertLiterals($value); }, explode('-', $value, 2) ); return $dateValue >= $parts[0] && $dateValue <= $parts[1]; } /** * Test if a value is within an increments of ranges (offset[-to]/step size). * * @internal * @param int $dateValue Set date value * @param string $value Value to test * * @return bool */ public function isInIncrementsOfRanges(int $dateValue, string $value): bool { $chunks = array_map('trim', explode('/', $value, 2)); $range = $chunks[0]; $step = $chunks[1] ?? 0; // No step or 0 steps aren't cool /** @phpstan-ignore-next-line */ if (null === $step || '0' === $step || 0 === $step) { return false; } // Expand the * to a full range if ('*' === $range) { $range = $this->rangeStart . '-' . $this->rangeEnd; } // Generate the requested small range $rangeChunks = explode('-', $range, 2); $rangeStart = (int) $rangeChunks[0]; $rangeEnd = $rangeChunks[1] ?? $rangeStart; $rangeEnd = (int) $rangeEnd; if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) { throw new \OutOfRangeException('Invalid range start requested'); } if ($rangeEnd < $this->rangeStart || $rangeEnd > $this->rangeEnd || $rangeEnd < $rangeStart) { throw new \OutOfRangeException('Invalid range end requested'); } // Steps larger than the range need to wrap around and be handled // slightly differently than smaller steps // UPDATE - This is actually false. The C implementation will allow a // larger step as valid syntax, it never wraps around. It will stop // once it hits the end. Unfortunately this means in future versions // we will not wrap around. However, because the logic exists today // per the above documentation, fixing the bug from #89 if ($step > $this->rangeEnd) { $thisRange = [$this->fullRange[$step % \count($this->fullRange)]]; } else { if ($step > ($rangeEnd - $rangeStart)) { $thisRange[$rangeStart] = (int) $rangeStart; } else { $thisRange = range($rangeStart, $rangeEnd, (int) $step); } } return \in_array($dateValue, $thisRange, true); } /** * Returns a range of values for the given cron expression. * * @param string $expression The expression to evaluate * @param int $max Maximum offset for range * * @return array */ public function getRangeForExpression(string $expression, int $max): array { $values = []; $expression = $this->convertLiterals($expression); if (false !== strpos($expression, ',')) { $ranges = explode(',', $expression); $values = []; foreach ($ranges as $range) { $expanded = $this->getRangeForExpression($range, $this->rangeEnd); $values = array_merge($values, $expanded); } return $values; } if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) { if (!$this->isIncrementsOfRanges($expression)) { [$offset, $to] = explode('-', $expression); $offset = $this->convertLiterals($offset); $to = $this->convertLiterals($to); $stepSize = 1; } else { $range = array_map('trim', explode('/', $expression, 2)); $stepSize = $range[1] ?? 0; $range = $range[0]; $range = explode('-', $range, 2); $offset = $range[0]; $to = $range[1] ?? $max; } $offset = '*' === $offset ? $this->rangeStart : $offset; if ($stepSize >= $this->rangeEnd) { $values = [$this->fullRange[$stepSize % \count($this->fullRange)]]; } else { for ($i = $offset; $i <= $to; $i += $stepSize) { $values[] = (int) $i; } } sort($values); } else { $values = [$expression]; } return $values; } /** * Convert literal. * * @param string $value * * @return string */ protected function convertLiterals(string $value): string { if (\count($this->literals)) { $key = array_search(strtoupper($value), $this->literals, true); if (false !== $key) { return (string) $key; } } return $value; } /** * Checks to see if a value is valid for the field. * * @param string $value * * @return bool */ public function validate(string $value): bool { $value = $this->convertLiterals($value); // All fields allow * as a valid value if ('*' === $value) { return true; } // Validate each chunk of a list individually if (false !== strpos($value, ',')) { foreach (explode(',', $value) as $listItem) { if (!$this->validate($listItem)) { return false; } } return true; } if (false !== strpos($value, '/')) { [$range, $step] = explode('/', $value); // Don't allow numeric ranges if (is_numeric($range)) { return false; } return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT); } if (false !== strpos($value, '-')) { if (substr_count($value, '-') > 1) { return false; } $chunks = explode('-', $value); $chunks[0] = $this->convertLiterals($chunks[0]); $chunks[1] = $this->convertLiterals($chunks[1]); if ('*' === $chunks[0] || '*' === $chunks[1]) { return false; } return $this->validate($chunks[0]) && $this->validate($chunks[1]); } if (!is_numeric($value)) { return false; } if (false !== strpos($value, '.')) { return false; } // We should have a numeric by now, so coerce this into an integer $value = (int) $value; return \in_array($value, $this->fullRange, true); } protected function timezoneSafeModify(DateTimeInterface $dt, string $modification): DateTimeInterface { $timezone = $dt->getTimezone(); $dt = $dt->setTimezone(new \DateTimeZone("UTC")); $dt = $dt->modify($modification); $dt = $dt->setTimezone($timezone); return $dt; } protected function setTimeHour(DateTimeInterface $date, bool $invert, int $originalTimestamp): DateTimeInterface { $date = $date->setTime((int)$date->format('H'), ($invert ? 59 : 0)); // setTime caused the offset to change, moving time in the wrong direction $actualTimestamp = $date->format('U'); if ((! $invert) && ($actualTimestamp <= $originalTimestamp)) { $date = $this->timezoneSafeModify($date, "+1 hour"); } elseif ($invert && ($actualTimestamp >= $originalTimestamp)) { $date = $this->timezoneSafeModify($date, "-1 hour"); } return $date; } } PKiSZZI..+cron-expression/src/Cron/FieldInterface.phpnuW+Afields[$position] ?? $this->fields[$position] = $this->instantiateField($position); } private function instantiateField(int $position): FieldInterface { switch ($position) { case CronExpression::MINUTE: return new MinutesField(); case CronExpression::HOUR: return new HoursField(); case CronExpression::DAY: return new DayOfMonthField(); case CronExpression::MONTH: return new MonthField(); case CronExpression::WEEKDAY: return new DayOfWeekField(); } throw new InvalidArgumentException( ($position + 1) . ' is not a valid position' ); } } PKiSZ\_/cron-expression/README.mdnuW+APHP Cron Expression Parser ========================== [![Latest Stable Version](https://poser.pugx.org/dragonmantank/cron-expression/v/stable.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Total Downloads](https://poser.pugx.org/dragonmantank/cron-expression/downloads.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Tests](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml/badge.svg)](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml) [![StyleCI](https://github.styleci.io/repos/103715337/shield?branch=master)](https://github.styleci.io/repos/103715337) The PHP cron expression parser can parse a CRON expression, determine if it is due to run, calculate the next run date of the expression, and calculate the previous run date of the expression. You can calculate dates far into the future or past by skipping **n** number of matching dates. The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9), lists (e.g. 1,2,3), **W** to find the nearest weekday for a given day of the month, **L** to find the last day of the month, **L** to find the last given weekday of a month, and hash (#) to find the nth weekday of a given month. More information about this fork can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. Installing ========== Add the dependency to your project: ```bash composer require dragonmantank/cron-expression ``` Usage ===== ```php isDue(); echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s'); // Works with complex expressions $cron = new Cron\CronExpression('3-59/15 6-12 */15 1 2-5'); echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); // Calculate a run date two iterations into the future $cron = new Cron\CronExpression('@daily'); echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); // Calculate a run date relative to a specific time $cron = new Cron\CronExpression('@monthly'); echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s'); ``` CRON Expressions ================ A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows: ``` * * * * * - - - - - | | | | | | | | | | | | | | +----- day of week (0-7) (Sunday = 0 or 7) (or SUN-SAT) | | | +--------- month (1-12) (or JAN-DEC) | | +------------- day of month (1-31) | +----------------- hour (0-23) +--------------------- minute (0-59) ``` Each part of expression can also use wildcard, lists, ranges and steps: - wildcard - match always - `* * * * *` - At every minute. - day of week and day of month also support `?`, an alias to `*` - lists - match list of values, ranges and steps - e.g. `15,30 * * * *` - At minute 15 and 30. - ranges - match values in range - e.g. `1-9 * * * *` - At every minute from 1 through 9. - steps - match every nth value in range - e.g. `*/5 * * * *` - At every 5th minute. - e.g. `0-30/5 * * * *` - At every 5th minute from 0 through 30. - combinations - e.g. `0-14,30-44 * * * *` - At every minute from 0 through 14 and every minute from 30 through 44. You can also use macro instead of an expression: - `@yearly`, `@annually` - At 00:00 on 1st of January. (same as `0 0 1 1 *`) - `@monthly` - At 00:00 on day-of-month 1. (same as `0 0 1 * *`) - `@weekly` - At 00:00 on Sunday. (same as `0 0 * * 0`) - `@daily`, `@midnight` - At 00:00. (same as `0 0 * * *`) - `@hourly` - At minute 0. (same as `0 * * * *`) Day of month extra features: - nearest weekday - weekday (Monday-Friday) nearest to the given day - e.g. `* * 15W * *` - At every minute on a weekday nearest to the 15th. - If you were to specify `15W` as the value, the meaning is: "the nearest weekday to the 15th of the month" So if the 15th is a Saturday, the trigger will fire on Friday the 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If the 15th is a Tuesday, then it will fire on Tuesday the 15th. - However, if you specify `1W` as the value for day-of-month, and the 1st is a Saturday, the trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary of a month's days. - last day of the month - e.g. `* * L * *` - At every minute on a last day-of-month. - last weekday of the month - e.g. `* * LW * *` - At every minute on a last weekday. Day of week extra features: - nth day - e.g. `* * * * 7#4` - At every minute on 4th Sunday. - 1-5 - Every day of week repeats 4-5 times a month. To target the last one, use "last day" feature instead. - last day - e.g. `* * * * 7L` - At every minute on the last Sunday. Requirements ============ - PHP 7.2+ - PHPUnit is required to run the unit tests - Composer is required to run the unit tests Projects that Use cron-expression ================================= * Part of the [Laravel Framework](https://github.com/laravel/framework/) * Available as a [Symfony Bundle - setono/cron-expression-bundle](https://github.com/Setono/CronExpressionBundle) * Framework agnostic, PHP-based job scheduler - [Crunz](https://github.com/crunzphp/crunz) * Framework agnostic job scheduler - with locks, parallelism, per-second scheduling and more - [orisai/scheduler](https://github.com/orisai/scheduler) * Explain expression in English (and other languages) with [orisai/cron-expression-explainer](https://github.com/orisai/cron-expression-explainer) PKiSZ6LKcron-expression/composer.jsonnuW+A{ "name": "dragonmantank/cron-expression", "type": "library", "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", "keywords": ["cron", "schedule"], "license": "MIT", "authors": [ { "name": "Chris Tankersley", "email": "chris@ctankersley.com", "homepage": "https://github.com/dragonmantank" } ], "require": { "php": "^7.2|^8.0", "webmozart/assert": "^1.0" }, "require-dev": { "phpstan/phpstan": "^1.0", "phpunit/phpunit": "^7.0|^8.0|^9.0", "phpstan/extension-installer": "^1.0" }, "autoload": { "psr-4": { "Cron\\": "src/Cron/" } }, "autoload-dev": { "psr-4": { "Cron\\Tests\\": "tests/Cron/" } }, "replace": { "mtdowling/cron-expression": "^1.0" }, "scripts": { "phpstan": "./vendor/bin/phpstan analyze", "test": "phpunit" }, "extra": { "branch-alias": { "dev-master": "3.x-dev" } }, "config": { "allow-plugins": { "ocramius/package-versions": true, "phpstan/extension-installer": true } } } PKiSZozzcron-expression/LICENSEnuW+APKiSZ9ZEcron-expression/CHANGELOG.mdnuW+APKiSZ|7Ώ )cron-expression/src/Cron/MinutesField.phpnuW+APKiSZX)88')cron-expression/src/Cron/MonthField.phpnuW+APKiSZr~U׾'/cron-expression/src/Cron/HoursField.phpnuW+APKiSZөi+)Kcron-expression/src/Cron/DayOfWeekField.phpnuW+APKiSZL,bcron-expression/src/Cron/DayOfMonthField.phpnuW+APKiSZZQQ+uucron-expression/src/Cron/CronExpression.phpnuW+APKiSZ؄yy2cron-expression/src/Cron/FieldFactoryInterface.phpnuW+APKiSZ?&&*cron-expression/src/Cron/AbstractField.phpnuW+APKiSZZI..+cron-expression/src/Cron/FieldInterface.phpnuW+APKiSZa}}) cron-expression/src/Cron/FieldFactory.phpnuW+APKiSZ\_/cron-expression/README.mdnuW+APKiSZ6LKcron-expression/composer.jsonnuW+APK8