194 lines
7.4 KiB
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];
|
|
}
|
|
}
|