502be95d7d
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
398 lines
12 KiB
PHP
398 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXF\Chrono\Entity;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeInterface;
|
|
use KTXF\Utile\Timestamp;
|
|
|
|
final class EventRangeMetadata {
|
|
|
|
public static function fromStoreDocument(array $document): array {
|
|
$entity = $document['data'] ?? null;
|
|
if (!is_array($entity) || !isset($entity['startsOn'])) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$startsOn = new DateTimeImmutable($entity['startsOn']);
|
|
$endsOn = isset($entity['endsOn']) ? new DateTimeImmutable($entity['endsOn']) : null;
|
|
} catch (\Exception) {
|
|
return [];
|
|
}
|
|
|
|
$rangeStart = $startsOn;
|
|
$rangeEnd = $endsOn;
|
|
$pattern = $entity['pattern'] ?? null;
|
|
if (is_array($pattern)) {
|
|
$rangeEnd = self::constructRecurringRangeEnd($startsOn, $endsOn, $pattern);
|
|
}
|
|
|
|
foreach (($entity['mutations'] ?? []) as $mutation) {
|
|
if (!is_array($mutation) || ($mutation['mutationExclusion'] ?? false)) {
|
|
continue;
|
|
}
|
|
try {
|
|
if (isset($mutation['startsOn'])) {
|
|
$mutationStart = new DateTimeImmutable($mutation['startsOn']);
|
|
if ($mutationStart < $rangeStart) {
|
|
$rangeStart = $mutationStart;
|
|
}
|
|
}
|
|
if ($rangeEnd !== null && isset($mutation['endsOn'])) {
|
|
$mutationEnd = new DateTimeImmutable($mutation['endsOn']);
|
|
if ($mutationEnd > $rangeEnd) {
|
|
$rangeEnd = $mutationEnd;
|
|
}
|
|
}
|
|
} catch (\Exception) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'startsOn' => Timestamp::toEpoch($rangeStart),
|
|
'endsOn' => $rangeEnd !== null ? Timestamp::toEpoch($rangeEnd) : null,
|
|
];
|
|
}
|
|
|
|
private static function parseRangeDateTime(mixed $value): ?DateTimeImmutable {
|
|
if (!is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new DateTimeImmutable($value);
|
|
} catch (\Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static function parseConclusionBoundary(mixed $value, DateTimeImmutable $reference): ?DateTimeImmutable {
|
|
if (!is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
|
return DateTimeImmutable::createFromFormat(
|
|
'!Y-m-d H:i:s.u',
|
|
sprintf('%s 23:59:59.999000', $value),
|
|
$reference->getTimezone()
|
|
) ?: null;
|
|
}
|
|
|
|
return self::parseRangeDateTime($value);
|
|
}
|
|
|
|
private static function recurringDuration(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn): ?\DateInterval {
|
|
if ($endsOn === null || $endsOn <= $startsOn) {
|
|
return null;
|
|
}
|
|
|
|
return $startsOn->diff($endsOn);
|
|
}
|
|
|
|
private static function positiveIntegerList(mixed $value): array {
|
|
if (!is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$list = [];
|
|
foreach ($value as $entry) {
|
|
if (!is_numeric($entry)) {
|
|
continue;
|
|
}
|
|
$integer = (int)$entry;
|
|
if ($integer > 0) {
|
|
$list[$integer] = $integer;
|
|
}
|
|
}
|
|
|
|
ksort($list);
|
|
return array_values($list);
|
|
}
|
|
|
|
private static function signedIntegerList(mixed $value): array {
|
|
if (!is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$list = [];
|
|
foreach ($value as $entry) {
|
|
if (!is_numeric($entry)) {
|
|
continue;
|
|
}
|
|
$integer = (int)$entry;
|
|
if ($integer !== 0) {
|
|
$list[$integer] = $integer;
|
|
}
|
|
}
|
|
|
|
ksort($list);
|
|
return array_values($list);
|
|
}
|
|
|
|
private static function recurrenceWeekdays(array $pattern, DateTimeImmutable $startsOn): array {
|
|
$weekdays = self::positiveIntegerList($pattern['onDayOfWeek'] ?? null);
|
|
if ($weekdays === []) {
|
|
$weekdays = [(int)$startsOn->format('N')];
|
|
}
|
|
return array_values(array_filter($weekdays, static fn(int $value): bool => $value >= 1 && $value <= 7));
|
|
}
|
|
|
|
private static function recurrenceMonths(array $pattern, DateTimeImmutable $startsOn): array {
|
|
$months = self::positiveIntegerList($pattern['onMonthOfYear'] ?? null);
|
|
if ($months === []) {
|
|
$months = [(int)$startsOn->format('n')];
|
|
}
|
|
return array_values(array_filter($months, static fn(int $value): bool => $value >= 1 && $value <= 12));
|
|
}
|
|
|
|
private static function monthCandidatesAbsolute(DateTimeImmutable $periodStart, array $days, DateTimeImmutable $startsOn): array {
|
|
$days = $days !== [] ? $days : [(int)$startsOn->format('j')];
|
|
$lastDay = (int)$periodStart->format('t');
|
|
$candidates = [];
|
|
|
|
foreach ($days as $day) {
|
|
if ($day < 1 || $day > $lastDay) {
|
|
continue;
|
|
}
|
|
$candidates[] = $periodStart->setDate(
|
|
(int)$periodStart->format('Y'),
|
|
(int)$periodStart->format('n'),
|
|
$day
|
|
);
|
|
}
|
|
|
|
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
|
return $candidates;
|
|
}
|
|
|
|
private static function nthWeekdayOfMonth(DateTimeImmutable $periodStart, int $weekday, int $position): ?DateTimeImmutable {
|
|
if ($weekday < 1 || $weekday > 7 || $position === 0) {
|
|
return null;
|
|
}
|
|
|
|
$year = (int)$periodStart->format('Y');
|
|
$month = (int)$periodStart->format('n');
|
|
$lastDay = (int)$periodStart->format('t');
|
|
|
|
if ($position > 0) {
|
|
$firstDayWeekday = (int)$periodStart->format('N');
|
|
$offset = ($weekday - $firstDayWeekday + 7) % 7;
|
|
$day = 1 + $offset + (($position - 1) * 7);
|
|
if ($day > $lastDay) {
|
|
return null;
|
|
}
|
|
return $periodStart->setDate($year, $month, $day);
|
|
}
|
|
|
|
$lastDayDate = $periodStart->setDate($year, $month, $lastDay);
|
|
$lastWeekday = (int)$lastDayDate->format('N');
|
|
$offset = ($lastWeekday - $weekday + 7) % 7;
|
|
$day = $lastDay - $offset;
|
|
$steps = abs($position) - 1;
|
|
$day -= $steps * 7;
|
|
if ($day < 1) {
|
|
return null;
|
|
}
|
|
|
|
return $periodStart->setDate($year, $month, $day);
|
|
}
|
|
|
|
private static function monthCandidatesRelative(DateTimeImmutable $periodStart, array $weekdays, array $positions, DateTimeImmutable $startsOn): array {
|
|
$weekdays = $weekdays !== [] ? $weekdays : [(int)$startsOn->format('N')];
|
|
$positions = $positions !== [] ? $positions : [self::positionInMonth($startsOn)];
|
|
$candidates = [];
|
|
|
|
foreach ($positions as $position) {
|
|
foreach ($weekdays as $weekday) {
|
|
$candidate = self::nthWeekdayOfMonth($periodStart, $weekday, $position);
|
|
if ($candidate !== null) {
|
|
$candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
$candidates = array_values($candidates);
|
|
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
|
return $candidates;
|
|
}
|
|
|
|
private static function positionInMonth(DateTimeImmutable $date): int {
|
|
$day = (int)$date->format('j');
|
|
$lastDay = (int)$date->format('t');
|
|
return ($day + 7 > $lastDay) ? -1 : (int)ceil($day / 7);
|
|
}
|
|
|
|
private static function recurringCandidateStarts(DateTimeImmutable $startsOn, array $pattern, ?DateTimeImmutable $concludes, ?int $iterations): array {
|
|
$precision = is_string($pattern['precision'] ?? null) ? strtolower((string)$pattern['precision']) : '';
|
|
$interval = max(1, (int)($pattern['interval'] ?? 1));
|
|
$occurrences = [];
|
|
$limit = $iterations ?? 512;
|
|
|
|
$append = function (DateTimeImmutable $candidate) use (&$occurrences, $startsOn, $concludes, $iterations): bool {
|
|
if ($candidate < $startsOn) {
|
|
return true;
|
|
}
|
|
if ($concludes !== null && $candidate > $concludes) {
|
|
return false;
|
|
}
|
|
$occurrences[] = $candidate;
|
|
return $iterations === null || count($occurrences) < $iterations;
|
|
};
|
|
|
|
switch ($precision) {
|
|
case 'secondly':
|
|
case 'minutely':
|
|
case 'hourly':
|
|
case 'daily':
|
|
$step = 0;
|
|
$weekdays = $precision === 'daily' && !empty($pattern['onDayOfWeek'])
|
|
? self::recurrenceWeekdays($pattern, $startsOn)
|
|
: [];
|
|
while (count($occurrences) < $limit) {
|
|
$candidate = match ($precision) {
|
|
'secondly' => $startsOn->modify(sprintf('+%d seconds', $step * $interval)),
|
|
'minutely' => $startsOn->modify(sprintf('+%d minutes', $step * $interval)),
|
|
'hourly' => $startsOn->modify(sprintf('+%d hours', $step * $interval)),
|
|
default => $startsOn->modify(sprintf('+%d days', $step * $interval)),
|
|
};
|
|
if ($candidate === false) {
|
|
break;
|
|
}
|
|
if ($precision === 'daily' && $weekdays !== [] && !in_array((int)$candidate->format('N'), $weekdays, true)) {
|
|
$step++;
|
|
if ($concludes !== null && $candidate > $concludes) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if (!$append($candidate)) {
|
|
break;
|
|
}
|
|
$step++;
|
|
}
|
|
break;
|
|
|
|
case 'weekly':
|
|
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
|
$weekStart = $startsOn->modify(sprintf('-%d days', ((int)$startsOn->format('N')) - 1));
|
|
if ($weekStart === false) {
|
|
break;
|
|
}
|
|
for ($week = 0; count($occurrences) < $limit; $week++) {
|
|
$periodStart = $weekStart->modify(sprintf('+%d days', $week * $interval * 7));
|
|
if ($periodStart === false) {
|
|
break;
|
|
}
|
|
foreach ($weekdays as $weekday) {
|
|
$candidate = $periodStart->modify(sprintf('+%d days', $weekday - 1));
|
|
if ($candidate === false) {
|
|
continue;
|
|
}
|
|
if (!$append($candidate)) {
|
|
break 2;
|
|
}
|
|
}
|
|
if ($concludes !== null && $periodStart > $concludes) {
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'monthly':
|
|
$isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative';
|
|
$days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null);
|
|
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
|
$positions = self::signedIntegerList($pattern['onPosition'] ?? null);
|
|
$monthAnchor = $startsOn->setDate((int)$startsOn->format('Y'), (int)$startsOn->format('n'), 1);
|
|
for ($month = 0; count($occurrences) < $limit; $month++) {
|
|
$periodStart = $monthAnchor->modify(sprintf('+%d months', $month * $interval));
|
|
if ($periodStart === false) {
|
|
break;
|
|
}
|
|
$candidates = $isRelative
|
|
? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn)
|
|
: self::monthCandidatesAbsolute($periodStart, $days, $startsOn);
|
|
foreach ($candidates as $candidate) {
|
|
if (!$append($candidate)) {
|
|
break 2;
|
|
}
|
|
}
|
|
if ($concludes !== null && $periodStart > $concludes) {
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'yearly':
|
|
$isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative';
|
|
$months = self::recurrenceMonths($pattern, $startsOn);
|
|
$days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null);
|
|
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
|
$positions = self::signedIntegerList($pattern['onPosition'] ?? null);
|
|
$yearAnchor = $startsOn->setDate((int)$startsOn->format('Y'), 1, 1);
|
|
for ($year = 0; count($occurrences) < $limit; $year++) {
|
|
$periodYear = $yearAnchor->modify(sprintf('+%d years', $year * $interval));
|
|
if ($periodYear === false) {
|
|
break;
|
|
}
|
|
$candidates = [];
|
|
foreach ($months as $month) {
|
|
$periodStart = $periodYear->setDate((int)$periodYear->format('Y'), $month, 1);
|
|
foreach (
|
|
$isRelative
|
|
? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn)
|
|
: self::monthCandidatesAbsolute($periodStart, $days, $startsOn)
|
|
as $candidate
|
|
) {
|
|
$candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate;
|
|
}
|
|
}
|
|
$candidates = array_values($candidates);
|
|
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
|
foreach ($candidates as $candidate) {
|
|
if (!$append($candidate)) {
|
|
break 2;
|
|
}
|
|
}
|
|
if ($concludes !== null && $periodYear > $concludes) {
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
return $occurrences;
|
|
}
|
|
|
|
private static function constructRecurringRangeEnd(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn, array $pattern): ?DateTimeImmutable {
|
|
$iterations = isset($pattern['iterations']) && is_numeric($pattern['iterations']) && (int)$pattern['iterations'] > 0
|
|
? (int)$pattern['iterations']
|
|
: null;
|
|
$concludes = self::parseConclusionBoundary($pattern['concludes'] ?? null, $startsOn);
|
|
|
|
if ($iterations === null && $concludes === null) {
|
|
return null;
|
|
}
|
|
|
|
$occurrences = self::recurringCandidateStarts($startsOn, $pattern, $concludes, $iterations);
|
|
$lastStart = $occurrences !== [] ? end($occurrences) : $startsOn;
|
|
if (!$lastStart instanceof DateTimeImmutable) {
|
|
return null;
|
|
}
|
|
|
|
$duration = self::recurringDuration($startsOn, $endsOn);
|
|
return $duration !== null ? $lastStart->add($duration) : $lastStart;
|
|
}
|
|
|
|
}
|