calendar-plugin/code/src/Domain/RecurrenceExpander.php

194 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class RecurrenceExpander
{
private const MAX_ITERATIONS = 512;
public static function expand(array $event, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd, array $deletedKeys = []): array
{
$tz = new DateTimeZone('Europe/London');
$start = self::parseDateTime((string) ($event['start_datetime'] ?? ''), $tz);
$end = self::parseDateTime((string) ($event['end_datetime'] ?? ''), $tz);
if (!$start || !$end || $end < $start) {
return [];
}
$deletedMap = [];
foreach ($deletedKeys as $key) {
$deletedMap[(string) $key] = true;
}
$duration = $start->diff($end);
$repeatType = (string) ($event['repeat_type'] ?? 'none');
$interval = max(1, (int) ($event['repeat_interval'] ?? 1));
$rangeMode = (string) ($event['repeat_range_mode'] ?? 'none');
$repeatCount = isset($event['repeat_count']) ? (int) $event['repeat_count'] : null;
$repeatUntil = null;
if ($rangeMode === 'until' && !empty($event['repeat_until'])) {
$repeatUntil = self::parseDateTime((string) $event['repeat_until'] . 'T23:59:59', $tz);
}
if ($repeatType === 'none') {
if (self::overlaps($start, $end, $windowStart, $windowEnd)) {
return [self::occurrence($event, $start, $end)];
}
return [];
}
$occurrences = [];
$current = $start;
$produced = 0;
for ($i = 0; $i < self::MAX_ITERATIONS; $i++) {
if ($rangeMode === 'count' && $repeatCount !== null && $produced >= $repeatCount) {
break;
}
if ($repeatUntil && $current > $repeatUntil) {
break;
}
$currentEnd = $current->add($duration);
if (self::overlaps($current, $currentEnd, $windowStart, $windowEnd)) {
$key = $current->format('c');
if (!isset($deletedMap[$key])) {
$occurrences[] = self::occurrence($event, $current, $currentEnd);
}
}
if ($current > $windowEnd->modify('+400 days')) {
break;
}
$produced++;
$current = self::nextStart($current, $repeatType, $interval, $event);
if (!$current) {
break;
}
}
return $occurrences;
}
private static function nextStart(DateTimeImmutable $current, string $repeatType, int $interval, array $event): ?DateTimeImmutable
{
return match ($repeatType) {
'daily' => $current->add(new DateInterval('P' . $interval . 'D')),
'weekly', 'custom' => $current->add(new DateInterval('P' . $interval . 'W')),
'monthly' => self::nextMonthlyStart($current, $interval, $event),
'yearly' => $current->modify('+' . $interval . ' year') ?: null,
default => null,
};
}
private static function nextMonthlyStart(DateTimeImmutable $current, int $interval, array $event): ?DateTimeImmutable
{
$mode = (string) ($event['repeat_nth_mode'] ?? '');
$next = $current->modify('+' . $interval . ' month');
if (!$next) {
return null;
}
if ($mode === 'day_of_month' && isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null) {
$day = max(1, (int) $event['repeat_nth_day']);
$daysInMonth = (int) $next->format('t');
return $next->setDate((int) $next->format('Y'), (int) $next->format('n'), min($day, $daysInMonth));
}
if ($mode === 'weekday_of_month' && isset($event['repeat_nth_pos'], $event['repeat_nth_weekday']) && $event['repeat_nth_pos'] !== null && $event['repeat_nth_weekday'] !== null) {
$year = (int) $current->format('Y');
$month = (int) $current->format('n');
for ($i = 0; $i < 120; $i++) {
[$year, $month] = self::addMonths($year, $month, max(1, $interval));
$day = self::nthWeekdayOfMonth($year, $month, (int) $event['repeat_nth_weekday'], (int) $event['repeat_nth_pos']);
if ($day !== null) {
return $current->setDate($year, $month, $day);
}
}
return null;
}
return $next;
}
private static function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
{
$weekday = max(0, min(6, $weekday));
$tz = new DateTimeZone('Europe/London');
if ($pos === -1) {
$last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$last = $last->modify('last day of this month');
for ($day = (int) $last->format('j'); $day >= 1; $day--) {
$dt = $last->setDate($year, $month, $day);
if ((int) $dt->format('w') === $weekday) {
return $day;
}
}
return null;
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$daysInMonth = (int) $first->format('t');
$seen = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dt = $first->setDate($year, $month, $day);
if ((int) $dt->format('w') !== $weekday) {
continue;
}
$seen++;
if ($seen === $pos) {
return $day;
}
}
return null;
}
private static function occurrence(array $event, DateTimeImmutable $start, DateTimeImmutable $end): array
{
return [
'event_id' => (int) ($event['id'] ?? 0),
'uid' => (string) ($event['uid'] ?? ''),
'title' => (string) ($event['title'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'location' => (string) ($event['location'] ?? ''),
'category' => (string) ($event['category'] ?? ''),
'all_day_event' => (bool) ($event['all_day_event'] ?? false),
'occurrence_start' => $start->format('c'),
'occurrence_end' => $end->format('c'),
'repeat_type' => (string) ($event['repeat_type'] ?? 'none'),
];
}
private static function overlaps(DateTimeImmutable $start, DateTimeImmutable $end, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd): bool
{
return $start < $windowEnd && $end > $windowStart;
}
private static function parseDateTime(string $value, DateTimeZone $tz): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return new DateTimeImmutable($value . 'T00:00:00', $tz);
}
try {
return new DateTimeImmutable($value, $tz);
} catch (\Throwable) {
return null;
}
}
private static function addMonths(int $year, int $month, int $delta): array
{
$index = ($year * 12) + ($month - 1) + $delta;
$newYear = (int) floor($index / 12);
$newMonth = ($index % 12) + 1;
if ($newMonth <= 0) {
$newMonth += 12;
$newYear -= 1;
}
return [$newYear, $newMonth];
}
}