214 lines
7.8 KiB
PHP
214 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Scheduler\CronCommand;
|
|
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* This class provides calculations for the cron command format.
|
|
*
|
|
* @internal not part of TYPO3 Public API
|
|
*/
|
|
class CronCommand
|
|
{
|
|
/**
|
|
* Normalized sections of the cron command.
|
|
* Comma separated lists of integers and the character '*' are allowed.
|
|
*
|
|
* field lower and upper bound
|
|
* ----- --------------
|
|
* minute 0-59
|
|
* hour 0-23
|
|
* day of month 1-31
|
|
* month 1-12
|
|
* day of week 1-7
|
|
*/
|
|
protected array $cronCommandSections;
|
|
|
|
/**
|
|
* Timestamp of next execution date.
|
|
* This value starts with 'now + 1 minute' if not set externally
|
|
* by unit tests. After a call to calculateNextValue() it holds the timestamp of
|
|
* the next execution date which matches the cron command restrictions.
|
|
*/
|
|
protected int $timestamp;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $cronCommand The cron command can hold any combination documented as valid
|
|
* @param bool|int $timestamp Optional start time, used in unit tests
|
|
*/
|
|
public function __construct(string $cronCommand, bool|int $timestamp = false)
|
|
{
|
|
$cronCommand = NormalizeCommand::normalize($cronCommand);
|
|
// Explode cron command to sections
|
|
$this->cronCommandSections = GeneralUtility::trimExplode(' ', $cronCommand);
|
|
// Initialize the values with the starting time
|
|
// This takes care that the calculated time is always in the future
|
|
if ($timestamp === false) {
|
|
$timestamp = strtotime('+1 minute');
|
|
} else {
|
|
$timestamp += 60;
|
|
}
|
|
$this->timestamp = $this->roundTimestamp($timestamp);
|
|
}
|
|
|
|
/**
|
|
* Calculates the date of the next execution.
|
|
*
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function calculateNextValue(): void
|
|
{
|
|
$newTimestamp = $this->getTimestamp();
|
|
// Calculate next minute and hour field
|
|
$loopCount = 0;
|
|
while (true) {
|
|
$loopCount++;
|
|
// If there was no match within two days, cron command is invalid.
|
|
// The second day is needed to catch the summertime leap in some countries.
|
|
if ($loopCount > 2880) {
|
|
throw new \RuntimeException('Unable to determine next execution timestamp: Hour and minute combination is invalid.', 1291494126);
|
|
}
|
|
if ($this->minuteAndHourMatchesCronCommand($newTimestamp)) {
|
|
break;
|
|
}
|
|
$newTimestamp += 60;
|
|
}
|
|
$loopCount = 0;
|
|
while (true) {
|
|
$loopCount++;
|
|
// A date must match within the next 4 years, this high number makes
|
|
// sure leap year cron command configuration are caught.
|
|
// If the loop runs longer than that, the cron command is invalid.
|
|
if ($loopCount > 1464) {
|
|
throw new \RuntimeException('Unable to determine next execution timestamp: Day of month, month and day of week combination is invalid.', 1291501280);
|
|
}
|
|
if ($this->dayMatchesCronCommand($newTimestamp)) {
|
|
break;
|
|
}
|
|
$newTimestamp += $this->numberOfSecondsInDay($newTimestamp);
|
|
}
|
|
$this->timestamp = $newTimestamp;
|
|
}
|
|
|
|
/**
|
|
* Get next timestamp
|
|
*/
|
|
public function getTimestamp(): int
|
|
{
|
|
return $this->timestamp;
|
|
}
|
|
|
|
/**
|
|
* Get cron command sections. Array of strings, each containing either
|
|
* a list of comma separated integers or *
|
|
*/
|
|
public function getCronCommandSections(): array
|
|
{
|
|
return $this->cronCommandSections;
|
|
}
|
|
|
|
/**
|
|
* Determine if current timestamp matches minute and hour cron command restriction.
|
|
*/
|
|
protected function minuteAndHourMatchesCronCommand(int $timestamp): bool
|
|
{
|
|
$minute = (int)date('i', $timestamp);
|
|
$hour = (int)date('G', $timestamp);
|
|
$commandMatch = false;
|
|
if ($this->isInCommandList($this->cronCommandSections[0], $minute) && $this->isInCommandList($this->cronCommandSections[1], $hour)) {
|
|
$commandMatch = true;
|
|
}
|
|
return $commandMatch;
|
|
}
|
|
|
|
/**
|
|
* Determine if current timestamp matches day of month, month and day of week
|
|
* cron command restriction
|
|
*/
|
|
protected function dayMatchesCronCommand(int $timestamp): bool
|
|
{
|
|
$dayOfMonth = (int)date('j', $timestamp);
|
|
$month = (int)date('n', $timestamp);
|
|
$dayOfWeek = (int)date('N', $timestamp);
|
|
$isInDayOfMonth = $this->isInCommandList($this->cronCommandSections[2], $dayOfMonth);
|
|
$isInMonth = $this->isInCommandList($this->cronCommandSections[3], $month);
|
|
$isInDayOfWeek = $this->isInCommandList($this->cronCommandSections[4], $dayOfWeek);
|
|
// Quote from vixiecron:
|
|
// Note: The day of a command's execution can be specified by two fields — day of month, and day of week.
|
|
// If both fields are restricted (i.e., aren't *), the command will be run when either field
|
|
// matches the current time. For example, `30 4 1,15 * 5' would cause
|
|
// a command to be run at 4:30 am on the 1st and 15th of each month, plus every Friday.
|
|
$isDayOfMonthRestricted = (string)$this->cronCommandSections[2] !== '*';
|
|
$isDayOfWeekRestricted = (string)$this->cronCommandSections[4] !== '*';
|
|
if (!$isInMonth) {
|
|
return false;
|
|
}
|
|
|
|
// If both day-of-month and day-of-week are unrestricted, month match is enough.
|
|
if (!$isDayOfMonthRestricted && !$isDayOfWeekRestricted) {
|
|
return true;
|
|
}
|
|
|
|
// Otherwise, at least one restriction must match.
|
|
return ($isInDayOfMonth && $isDayOfMonthRestricted) || ($isInDayOfWeek && $isDayOfWeekRestricted);
|
|
}
|
|
|
|
/**
|
|
* Determine if a given number validates a cron command section. The given cron
|
|
* command must be a 'normalized' list with only comma separated integers or '*'
|
|
*/
|
|
protected function isInCommandList(string $commandExpression, int $numberToMatch): bool
|
|
{
|
|
if ($commandExpression === '*') {
|
|
$inList = true;
|
|
} else {
|
|
$inList = GeneralUtility::inList($commandExpression, (string)$numberToMatch);
|
|
}
|
|
return $inList;
|
|
}
|
|
|
|
/**
|
|
* Helper method to calculate number of seconds in a day.
|
|
*
|
|
* This is not always 86400 (60*60*24) and depends on the timezone:
|
|
* Some countries like Germany have a summertime / wintertime switch,
|
|
* on every last sunday in march clocks are forwarded by one hour (set from 2:00 to 3:00),
|
|
* and on last sunday of october they are set back one hour (from 3:00 to 2:00).
|
|
* This shortens and lengthens the length of a day by one hour.
|
|
*/
|
|
protected function numberOfSecondsInDay(int $timestamp): int
|
|
{
|
|
$now = mktime(0, 0, 0, (int)date('n', $timestamp), (int)date('j', $timestamp), (int)date('Y', $timestamp));
|
|
// Make sure to be in next day, even if day has 25 hours
|
|
$nextDay = $now + 60 * 60 * 25;
|
|
$nextDay = mktime(0, 0, 0, (int)date('n', $nextDay), (int)date('j', $nextDay), (int)date('Y', $nextDay));
|
|
return $nextDay - $now;
|
|
}
|
|
|
|
/**
|
|
* Round a timestamp down to full minute.
|
|
*/
|
|
protected function roundTimestamp(int $timestamp): int
|
|
{
|
|
return (int)(floor($timestamp / 60) * 60);
|
|
}
|
|
}
|