Files
nextcloud-pantry/lib/Service/RecurrenceService.php

129 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Pantry\Service;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Recur\RRuleIterator;
/**
* Thin wrapper around sabre/vobject's RRuleIterator.
*/
class RecurrenceService {
/**
* Validate an RRULE string (RFC 5545). Accepts either a bare rule ("FREQ=WEEKLY;INTERVAL=1")
* or a full "RRULE:..." line.
*
* @throws \InvalidArgumentException if the rule is malformed.
*/
public function validate(string $rrule): void {
$normalized = $this->normalize($rrule);
$this->preflight($normalized);
try {
new RRuleIterator($normalized, new \DateTimeImmutable('2000-01-01T00:00:00Z'));
} catch (InvalidDataException $e) {
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
}
}
/**
* Compute the next occurrence strictly after $from.
*
* The iterator's semantics are: the first item equals DTSTART; subsequent items are
* successive occurrences per the rule. We seed with DTSTART = $from and advance once.
*/
public function computeNextOccurrence(string $rrule, \DateTimeImmutable $from): ?\DateTimeImmutable {
$normalized = $this->normalize($rrule);
$this->preflight($normalized);
try {
$iter = new RRuleIterator($normalized, $from);
} catch (InvalidDataException $e) {
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
}
// First call yields DTSTART itself. Advance to the next one.
$iter->next();
if (!$iter->valid()) {
return null;
}
$current = $iter->current();
if (!$current instanceof \DateTimeInterface) {
return null;
}
return \DateTimeImmutable::createFromInterface($current);
}
/**
* Compute the next occurrence of a rule strictly after $after, using $dtStart as the
* schedule anchor.
*
* This is the "fixed schedule" semantics: the series of occurrences is determined by
* $dtStart (e.g. the item creation time), and we skip forward until we find the first
* one that is still in the future. Caps iteration to avoid runaway loops on malformed
* rules.
*/
public function nextOccurrenceAfter(
string $rrule,
\DateTimeImmutable $dtStart,
\DateTimeImmutable $after,
): ?\DateTimeImmutable {
$normalized = $this->normalize($rrule);
$this->preflight($normalized);
try {
$iter = new RRuleIterator($normalized, $dtStart);
} catch (InvalidDataException $e) {
throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
}
$guard = 0;
while ($iter->valid() && $guard < 10_000) {
$current = $iter->current();
if ($current instanceof \DateTimeInterface) {
if ($current->getTimestamp() > $after->getTimestamp()) {
return \DateTimeImmutable::createFromInterface($current);
}
}
$iter->next();
$guard++;
}
return null;
}
private function normalize(string $rrule): string {
$trim = trim($rrule);
if (stripos($trim, 'RRULE:') === 0) {
return substr($trim, 6);
}
return $trim;
}
/**
* Shallow structural check before the string ever reaches sabre/vobject.
*
* Why this exists: sabre/vobject 4.5.x on some PHP 8.2 / doctrine combinations raises a
* PHP deprecation while parsing malformed input, which bubbles up to PHPUnit as a
* "deprecation" and fails CI under `--fail-on-warning`. Rejecting obvious garbage here
* keeps the error path within our own code.
*
* @throws \InvalidArgumentException if the rule is not a well-formed list of KEY=VALUE
* parts or lacks a supported FREQ= clause.
*/
private function preflight(string $rrule): void {
if ($rrule === '') {
throw new \InvalidArgumentException('Invalid RRULE: empty rule');
}
// Whole rule must be KEY=VALUE(;KEY=VALUE)*
if (!preg_match('/^[A-Z][A-Z0-9-]*=[^;]*(?:;[A-Z][A-Z0-9-]*=[^;]*)*$/i', $rrule)) {
throw new \InvalidArgumentException('Invalid RRULE: expected KEY=VALUE parts separated by ";"');
}
// Must contain a supported FREQ clause.
if (!preg_match('/(?:^|;)FREQ=(SECONDLY|MINUTELY|HOURLY|DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)/i', $rrule)) {
throw new \InvalidArgumentException('Invalid RRULE: missing or unsupported FREQ clause');
}
}
}