diff --git a/composer.json b/composer.json index 8c52cf4..920348c 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,13 @@ }, "require": { "php": ">=7.2", + "ext-json": "*", "dragonmantank/cron-expression": "^3", "psr/log": "^1", "ramsey/uuid": "^3", "react/event-loop": "^1", "react/promise": "^2", + "simshaun/recurr": "^5", "ipl/stdlib": ">=0.12.0" }, "autoload": { diff --git a/src/RRule.php b/src/RRule.php new file mode 100644 index 0000000..ae2ee1a --- /dev/null +++ b/src/RRule.php @@ -0,0 +1,270 @@ +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); + } +}