diff --git a/shared/lib/Chrono/Entity/EventRangeMetadata.php b/shared/lib/Chrono/Entity/EventRangeMetadata.php new file mode 100644 index 0000000..33ba4b0 --- /dev/null +++ b/shared/lib/Chrono/Entity/EventRangeMetadata.php @@ -0,0 +1,397 @@ + + * 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; + } + +}