-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
2 changed files
with
272 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,270 @@ | ||
<?php | ||
|
||
namespace ipl\Scheduler; | ||
|
||
use BadMethodCallException; | ||
use DateTime; | ||
use DateTimeInterface; | ||
use Generator; | ||
use InvalidArgumentException; | ||
use ipl\Scheduler\Contract\Frequency; | ||
use JsonSerializable; | ||
use Recurr\Rule as RecurrRule; | ||
use Recurr\Transformer\ArrayTransformer; | ||
use Recurr\Transformer\ArrayTransformerConfig; | ||
use Recurr\Transformer\Constraint\AfterConstraint; | ||
|
||
use function ipl\Stdlib\get_php_type; | ||
|
||
/** | ||
* Support scheduling a task based on expressions in an iCalendar format | ||
*/ | ||
class RRule implements Frequency, JsonSerializable | ||
{ | ||
/** @var string Run once a year */ | ||
public const YEARLY = 'YEARLY'; | ||
|
||
/** @var string Run every 3 month starting from the given start time */ | ||
public const QUARTERLY = 'QUARTERLY'; | ||
|
||
/** @var string Run once a month */ | ||
public const MONTHLY = 'MONTHLY'; | ||
|
||
/** @var string Run once a week based on the specified start time */ | ||
public const WEEKLY = 'WEEKLY'; | ||
|
||
/** @var string Run once a day at the specified start time */ | ||
public const DAILY = 'DAILY'; | ||
|
||
/** @var string Run once an hour */ | ||
public const HOURLY = 'HOURLY'; | ||
|
||
/** @var string Run once a minute */ | ||
public const MINUTELY = 'MINUTELY'; | ||
|
||
/** @var int Default limit of the recurrences to be generated by the transformer */ | ||
protected const DEFAULT_LIMIT = 1; | ||
|
||
/** @var RecurrRule */ | ||
protected $rrule; | ||
|
||
/** @var ArrayTransformer */ | ||
protected $transformer; | ||
|
||
/** @var ArrayTransformerConfig */ | ||
protected $transformerConfig; | ||
|
||
/** @var string */ | ||
protected $frequency; | ||
|
||
public function __construct(string $rule) | ||
{ | ||
$this->rrule = new RecurrRule($rule); | ||
$this->frequency = $this->rrule->getFreqAsText(); | ||
$this->transformerConfig = new ArrayTransformerConfig(); | ||
$this->transformerConfig->setVirtualLimit(static::DEFAULT_LIMIT); | ||
|
||
// If the run day isn't set explicitly, we can enable the last day of month | ||
// fix, so that it doesn't skip some months which doesn't have e.g. 29,30,31 days. | ||
if ( | ||
$this->getFrequency() === static::MONTHLY | ||
&& ! $this->rrule->getByDay() | ||
&& ! $this->rrule->getByMonthDay() | ||
) { | ||
$this->transformerConfig->enableLastDayOfMonthFix(); | ||
} | ||
|
||
$this->transformer = new ArrayTransformer($this->transformerConfig); | ||
} | ||
|
||
/** | ||
* Get an RRule instance from the provided frequency | ||
* | ||
* @param string $frequency | ||
* | ||
* @return $this | ||
*/ | ||
public static function fromFrequency(string $frequency): self | ||
{ | ||
$frequencies = array_flip([ | ||
static::MINUTELY, | ||
static::HOURLY, | ||
static::DAILY, | ||
static::WEEKLY, | ||
static::MONTHLY, | ||
static::QUARTERLY, | ||
static::YEARLY | ||
]); | ||
|
||
if (! isset($frequencies[$frequency])) { | ||
throw new InvalidArgumentException(sprintf('Unknown frequency provided: %s', $frequency)); | ||
} | ||
|
||
$rule = "FREQ=$frequency"; | ||
if ($frequency === RRule::QUARTERLY) { | ||
$repeat = RRule::MONTHLY; | ||
$rule = "FREQ=$repeat;INTERVAL=3"; | ||
} | ||
|
||
$self = new static($rule); | ||
$self->frequency = $frequency; | ||
|
||
return $self; | ||
} | ||
|
||
public function isDue(DateTimeInterface $dateTime): bool | ||
{ | ||
$start = $this->rrule->getStartDate(); | ||
if (($start && $dateTime < $start) || $this->isExpired($dateTime)) { | ||
return false; | ||
} | ||
|
||
$nextDue = $this->getNextRecurrences($dateTime); | ||
if (! $nextDue->valid()) { | ||
return false; | ||
} | ||
|
||
return $nextDue->current() == $dateTime; | ||
} | ||
|
||
public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface | ||
{ | ||
if ($this->isExpired($dateTime)) { | ||
return ($this->rrule->getEndDate() ?? $this->rrule->getUntil()); | ||
} | ||
|
||
$nextDue = $this->getNextRecurrences($dateTime, 1, false); | ||
if (! $nextDue->valid()) { | ||
return $dateTime; | ||
} | ||
|
||
return $nextDue->current(); | ||
} | ||
|
||
public function isExpired(DateTimeInterface $dateTime): bool | ||
{ | ||
if ($this->rrule->repeatsIndefinitely()) { | ||
return false; | ||
} | ||
|
||
$end = $this->rrule->getEndDate() ?? $this->rrule->getUntil(); | ||
|
||
return $end !== null && $end < $dateTime; | ||
} | ||
|
||
/** | ||
* Set the start time of this frequency | ||
* | ||
* @param DateTimeInterface $start | ||
* | ||
* @return $this | ||
*/ | ||
public function startAt(DateTimeInterface $start): self | ||
{ | ||
$startDate = clone $start; | ||
$now = new DateTime(); | ||
if ($startDate > $now) { | ||
// When the start time contains microseconds, the first recurrence will always be skipped, as | ||
// the transformer operates only up to seconds level. See also the upstream issue #155 | ||
$startDate->setTime($start->format('H'), $start->format('i'), $start->format('s')); | ||
|
||
$this->rrule->setStartDate($startDate); | ||
} | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Set the end time of this frequency | ||
* | ||
* @param DateTimeInterface $end | ||
* | ||
* @return $this | ||
*/ | ||
public function endAt(DateTimeInterface $end): self | ||
{ | ||
$this->rrule->setEndDate($end); | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Get the frequency of this rule | ||
* | ||
* @return string | ||
*/ | ||
public function getFrequency(): string | ||
{ | ||
return $this->frequency; | ||
} | ||
|
||
/** | ||
* Get a set of recurrences relative to the given time and bounded to the configured generator's limit | ||
* | ||
* @param DateTimeInterface $dateTime | ||
* @param int $limit Limit the recurrences to be generated to the given value | ||
* @param bool $include Whether to include the passed time in the result set | ||
* | ||
* @return Generator | ||
*/ | ||
public function getNextRecurrences( | ||
DateTimeInterface $dateTime, | ||
int $limit = self::DEFAULT_LIMIT, | ||
bool $include = true | ||
): Generator { | ||
$resetTransformerConfig = function (int $limit = self::DEFAULT_LIMIT): void { | ||
$this->transformerConfig->setVirtualLimit($limit); | ||
$this->transformer->setConfig($this->transformerConfig); | ||
}; | ||
|
||
if ($limit > static::DEFAULT_LIMIT) { | ||
$resetTransformerConfig($limit); | ||
} | ||
|
||
// Setting the start date to a date time smaller than now causes the underlying library | ||
// not to generate any recurrences when using the regular frequencies such as `MINUTELY` etc. | ||
// and the `$countConstraintFailures` is set to true. We need also to tell the transformer | ||
// not to count the recurrences that fail the constraint's test! | ||
$recurrences = $this->transformer->transform($this->rrule, new AfterConstraint($dateTime, $include), false); | ||
foreach ($recurrences as $recurrence) { | ||
yield $recurrence->getStart(); | ||
} | ||
|
||
if ($limit > static::DEFAULT_LIMIT) { | ||
$resetTransformerConfig(); | ||
} | ||
} | ||
|
||
#[\ReturnTypeWillChange] | ||
public function jsonSerialize() | ||
{ | ||
return $this->rrule->getString(); | ||
} | ||
|
||
/** | ||
* Redirect all public method calls to the underlying rrule object | ||
* | ||
* @param string $methodName | ||
* @param array $args | ||
* | ||
* @return mixed | ||
* | ||
* @throws BadMethodCallException If the given method doesn't exist or when setter method is called | ||
*/ | ||
public function __call(string $methodName, array $args) | ||
{ | ||
if (! method_exists($this->rrule, $methodName)) { | ||
throw new BadMethodCallException( | ||
sprintf('Call to undefined method %s::%s()', get_php_type($this->rrule), $methodName) | ||
); | ||
} | ||
|
||
if (substr($methodName, 0, 3) === 'set') { | ||
throw new BadMethodCallException( | ||
sprintf('Dynamic method %s is not supported. Only getters (get*) are', $methodName) | ||
); | ||
} | ||
|
||
return call_user_func_array([$this->rrule, $methodName], $args); | ||
} | ||
} |