| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om\RRule; |
| 5: | |
| 6: | use DateTime; |
| 7: | use DateTimeImmutable; |
| 8: | use DateTimeInterface; |
| 9: | use DateTimeZone; |
| 10: | use Generator; |
| 11: | use IteratorAggregate; |
| 12: | use om\ICal\Exception\InvalidRecurrenceRuleException; |
| 13: | use om\ICal\Exception\ResourceLimitException; |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | |
| 20: | |
| 21: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | |
| 31: | |
| 32: | |
| 33: | final class Expander implements IteratorAggregate { |
| 34: | |
| 35: | public const int MAX_EMPTY_PERIODS = 100000; |
| 36: | |
| 37: | |
| 38: | |
| 39: | |
| 40: | |
| 41: | private const array CYCLE_PERIODS = ['YEARLY' => 400, 'MONTHLY' => 4800, 'WEEKLY' => 20871, 'DAILY' => 146097]; |
| 42: | private const int MAX_DAYS = 2932897; |
| 43: | |
| 44: | private readonly DateTimeZone $timezone; |
| 45: | private readonly ?int $fixedOffset; |
| 46: | private readonly DateTime $probe; |
| 47: | |
| 48: | private int $offset = 0; |
| 49: | private int $offsetFrom = PHP_INT_MAX; |
| 50: | private int $offsetUntil = PHP_INT_MIN; |
| 51: | |
| 52: | |
| 53: | private array $months = []; |
| 54: | |
| 55: | private array $monthDays = []; |
| 56: | |
| 57: | private array $yearDays = []; |
| 58: | |
| 59: | private array $weekNumbers = []; |
| 60: | |
| 61: | private array $weekdays = []; |
| 62: | |
| 63: | private array $weekdayOrdinals = []; |
| 64: | private bool $hasDayFilter; |
| 65: | |
| 66: | private ?Skip $skip = null; |
| 67: | |
| 68: | private array $skipDays = []; |
| 69: | private string $ordinalScope; |
| 70: | |
| 71: | private array $hours; |
| 72: | |
| 73: | private array $minutes; |
| 74: | |
| 75: | private array $seconds; |
| 76: | |
| 77: | private array $timeOfDay = []; |
| 78: | |
| 79: | private array $week1Cache = []; |
| 80: | |
| 81: | |
| 82: | |
| 83: | |
| 84: | |
| 85: | |
| 86: | |
| 87: | public function __construct( |
| 88: | private readonly Rule $rule, |
| 89: | private readonly DateTimeInterface $start, |
| 90: | private readonly ?int $horizon = null, |
| 91: | private readonly int $limit = 100000, |
| 92: | private readonly int $maxIterations = PHP_INT_MAX, |
| 93: | ) { |
| 94: | $rule->assertGregorian(); |
| 95: | $timezone = $start->getTimezone(); |
| 96: | $name = $timezone->getName(); |
| 97: | if ($name === 'Z' || $name === 'UTC' || $name === 'GMT' || preg_match('/^[+-]\d{2}:\d{2}$/D', $name)) { |
| 98: | $this->fixedOffset = $timezone->getOffset(new DateTimeImmutable('@0')); |
| 99: | $timezone = $name === 'Z' ? new DateTimeZone('UTC') : $timezone; |
| 100: | } else { |
| 101: | $this->fixedOffset = null; |
| 102: | } |
| 103: | $this->timezone = $timezone; |
| 104: | $this->probe = (new DateTime('@0'))->setTimezone($timezone); |
| 105: | } |
| 106: | |
| 107: | |
| 108: | |
| 109: | |
| 110: | public function getIterator(): Generator { |
| 111: | $rule = $this->rule; |
| 112: | $local = DateTimeImmutable::createFromInterface($this->start)->setTimezone($this->timezone); |
| 113: | [$year, $month, $day, $hour, $minute, $second] = array_map('intval', explode(' ', $local->format('Y n j G i s'))); |
| 114: | $startDays = self::daysFromCivil($year, $month, $day); |
| 115: | $startTs = $this->start->getTimestamp(); |
| 116: | |
| 117: | $until = $rule->untilTimestamp($this->timezone); |
| 118: | if ($this->horizon !== null) { |
| 119: | $until = $until === null ? $this->horizon : min($until, $this->horizon); |
| 120: | } |
| 121: | $until ??= PHP_INT_MAX; |
| 122: | |
| 123: | |
| 124: | yield $startTs; |
| 125: | $emitted = 1; |
| 126: | if ($startTs > $until || ($rule->count !== null && $emitted >= $rule->count)) { |
| 127: | return; |
| 128: | } |
| 129: | |
| 130: | if (!$this->prepare($month, $day, self::weekday($startDays), $hour, $minute, $second)) { |
| 131: | return; |
| 132: | } |
| 133: | |
| 134: | $freq = $rule->freq; |
| 135: | $interval = $rule->interval; |
| 136: | $periodDays = match ($freq) { |
| 137: | Frequency::Weekly => $startDays - ((self::weekday($startDays) - $rule->wkst + 7) % 7), |
| 138: | default => $startDays, |
| 139: | }; |
| 140: | $periodSeconds = match ($freq) { |
| 141: | Frequency::Hourly => $hour * 3600, |
| 142: | Frequency::Minutely => $hour * 3600 + $minute * 60, |
| 143: | Frequency::Secondly => $hour * 3600 + $minute * 60 + $second, |
| 144: | default => 0, |
| 145: | }; |
| 146: | $step = $interval * match ($freq) { |
| 147: | Frequency::Hourly => 3600, |
| 148: | Frequency::Minutely => 60, |
| 149: | default => 1, |
| 150: | }; |
| 151: | $subDaily = !$freq->isCoarserThan(Frequency::Hourly); |
| 152: | $last = $startTs; |
| 153: | $emptyPeriods = 0; |
| 154: | $maxEmptyPeriods = self::CYCLE_PERIODS[$freq->value] ?? self::MAX_EMPTY_PERIODS; |
| 155: | $iterations = 0; |
| 156: | |
| 157: | $carry = []; |
| 158: | $carryFrom = $this->skip === Skip::Forward && $freq === Frequency::Monthly ? 0 : PHP_INT_MAX; |
| 159: | |
| 160: | while (true) { |
| 161: | if (++$iterations > $this->maxIterations) { |
| 162: | throw ResourceLimitException::create('recurrence.iterations', "The rule needs more than {$this->maxIterations} iterations."); |
| 163: | } |
| 164: | |
| 165: | $skip = $subDaily ? $this->secondsToSkip($freq, $periodDays, $periodSeconds) : 0; |
| 166: | $candidates = $skip === 0 ? $this->candidates($freq, $year, $month, $periodDays, $periodSeconds) : []; |
| 167: | if ($candidates !== [] && $rule->bySetPos !== []) { |
| 168: | $candidates = $this->applySetPos($candidates); |
| 169: | } |
| 170: | if ($carry !== []) { |
| 171: | $candidates = array_values(array_unique([...$carry, ...$candidates], SORT_REGULAR)); |
| 172: | sort($candidates); |
| 173: | $carry = []; |
| 174: | } |
| 175: | if ($carryFrom !== PHP_INT_MAX) { |
| 176: | $carryFrom = self::daysFromCivil($year + intdiv($month, 12), $month % 12 + 1, 1); |
| 177: | } |
| 178: | |
| 179: | if ($candidates === []) { |
| 180: | if (++$emptyPeriods > $maxEmptyPeriods || $this->periodStart($freq, $year, $month, $periodDays, $periodSeconds) > $until) { |
| 181: | return; |
| 182: | } |
| 183: | } else { |
| 184: | $emptyPeriods = 0; |
| 185: | foreach ($candidates as [$days, $time]) { |
| 186: | if ($days >= $carryFrom) { |
| 187: | |
| 188: | $carry[] = [$days, $time]; |
| 189: | continue; |
| 190: | } |
| 191: | if ($days >= self::MAX_DAYS) { |
| 192: | return; |
| 193: | } |
| 194: | $ts = $this->toTimestamp($days, $time); |
| 195: | if ($ts > $until) { |
| 196: | return; |
| 197: | } |
| 198: | |
| 199: | if ($ts <= $last) { |
| 200: | continue; |
| 201: | } |
| 202: | yield $ts; |
| 203: | $last = $ts; |
| 204: | if (++$emitted > $this->limit) { |
| 205: | throw ResourceLimitException::create('recurrence.limit', 'Recurrence occurrence limit exceeded.'); |
| 206: | } |
| 207: | if ($rule->count !== null && $emitted >= $rule->count) { |
| 208: | return; |
| 209: | } |
| 210: | } |
| 211: | } |
| 212: | |
| 213: | switch ($freq) { |
| 214: | case Frequency::Yearly: |
| 215: | $year += $interval; |
| 216: | break; |
| 217: | case Frequency::Monthly: |
| 218: | $month += $interval; |
| 219: | $year += intdiv($month - 1, 12); |
| 220: | $month = ($month - 1) % 12 + 1; |
| 221: | break; |
| 222: | case Frequency::Weekly: |
| 223: | $periodDays += 7 * $interval; |
| 224: | break; |
| 225: | case Frequency::Daily: |
| 226: | $periodDays += $interval; |
| 227: | break; |
| 228: | default: |
| 229: | $periodSeconds += $skip > 0 ? intdiv($skip + $step - 1, $step) * $step : $step; |
| 230: | $periodDays += intdiv($periodSeconds, 86400); |
| 231: | $periodSeconds %= 86400; |
| 232: | } |
| 233: | if ($year > 9999 || $periodDays >= self::MAX_DAYS) { |
| 234: | return; |
| 235: | } |
| 236: | } |
| 237: | } |
| 238: | |
| 239: | |
| 240: | |
| 241: | |
| 242: | |
| 243: | private function secondsToSkip(Frequency $freq, int $days, int $seconds): int { |
| 244: | if (!$this->matchesDayNumber($days)) { |
| 245: | return 86400 - $seconds; |
| 246: | } |
| 247: | if ($freq !== Frequency::Hourly && $this->hours !== [] && !in_array(intdiv($seconds, 3600), $this->hours, true)) { |
| 248: | return 3600 - $seconds % 3600; |
| 249: | } |
| 250: | if ($freq === Frequency::Secondly && $this->minutes !== [] && !in_array(intdiv($seconds % 3600, 60), $this->minutes, true)) { |
| 251: | return 60 - $seconds % 60; |
| 252: | } |
| 253: | return 0; |
| 254: | } |
| 255: | |
| 256: | |
| 257: | |
| 258: | |
| 259: | private function periodStart(Frequency $freq, int $year, int $month, int $days, int $seconds): int { |
| 260: | $first = match ($freq) { |
| 261: | Frequency::Yearly => self::daysFromCivil($year, 1, 1), |
| 262: | Frequency::Monthly => self::daysFromCivil($year, $month, 1), |
| 263: | default => $days, |
| 264: | }; |
| 265: | return ($first - 1) * 86400 + $seconds; |
| 266: | } |
| 267: | |
| 268: | |
| 269: | |
| 270: | |
| 271: | |
| 272: | |
| 273: | private function prepare(int $month, int $day, int $weekday, int $hour, int $minute, int $second): bool { |
| 274: | $rule = $this->rule; |
| 275: | $byMonth = $rule->byMonth; |
| 276: | $byMonthDay = $rule->byMonthDay; |
| 277: | $byDay = $rule->byDay; |
| 278: | |
| 279: | if ($rule->byWeekNo === [] && $rule->byYearDay === [] && $byMonthDay === [] && $byDay === []) { |
| 280: | switch ($rule->freq) { |
| 281: | case Frequency::Yearly: |
| 282: | $byMonth = $byMonth ?: [$month]; |
| 283: | $byMonthDay = [$day]; |
| 284: | break; |
| 285: | case Frequency::Monthly: |
| 286: | $byMonthDay = [$day]; |
| 287: | break; |
| 288: | case Frequency::Weekly: |
| 289: | $byDay = [[0, $weekday]]; |
| 290: | break; |
| 291: | default: |
| 292: | } |
| 293: | } |
| 294: | |
| 295: | $this->months = array_fill_keys($byMonth, true); |
| 296: | $this->monthDays = array_fill_keys($byMonthDay, true); |
| 297: | $this->yearDays = array_fill_keys($rule->byYearDay, true); |
| 298: | $this->weekNumbers = array_fill_keys($rule->byWeekNo, true); |
| 299: | |
| 300: | |
| 301: | |
| 302: | |
| 303: | $this->ordinalScope = match (true) { |
| 304: | $rule->freq === Frequency::Monthly, $rule->freq === Frequency::Yearly && $byMonth !== [] => 'month', |
| 305: | $rule->freq === Frequency::Yearly => 'year', |
| 306: | default => 'none', |
| 307: | }; |
| 308: | foreach ($byDay as [$ordinal, $weekdayNumber]) { |
| 309: | if ($ordinal === 0 || $this->ordinalScope === 'none') { |
| 310: | $this->weekdays[$weekdayNumber] = true; |
| 311: | } else { |
| 312: | $this->weekdayOrdinals[$weekdayNumber][] = $ordinal; |
| 313: | } |
| 314: | } |
| 315: | $this->hasDayFilter = $this->months || $this->monthDays || $this->yearDays || $this->weekNumbers || $byDay; |
| 316: | |
| 317: | |
| 318: | |
| 319: | $skip = $rule->skip ?? Skip::Omit; |
| 320: | if ($skip !== Skip::Omit && ($rule->freq === Frequency::Monthly || $rule->freq === Frequency::Yearly) |
| 321: | && $this->yearDays === [] && $this->weekNumbers === []) { |
| 322: | $this->skipDays = array_values(array_filter($byMonthDay, static fn(int $value): bool => $value > 28)); |
| 323: | $this->skip = $this->skipDays === [] ? null : $skip; |
| 324: | } |
| 325: | |
| 326: | $freq = $rule->freq; |
| 327: | $this->hours = $rule->byHour ?: ($freq->isCoarserThan(Frequency::Hourly) ? [$hour] : []); |
| 328: | $this->minutes = $rule->byMinute ?: ($freq->isCoarserThan(Frequency::Minutely) ? [$minute] : []); |
| 329: | $this->seconds = array_values(array_filter( |
| 330: | $rule->bySecond ?: ($freq->isCoarserThan(Frequency::Secondly) ? [$second] : []), |
| 331: | static fn(int $value): bool => $value < 60, |
| 332: | )); |
| 333: | if ($rule->bySecond !== [] && $this->seconds === []) { |
| 334: | return false; |
| 335: | } |
| 336: | |
| 337: | if ($freq->isCoarserThan(Frequency::Hourly)) { |
| 338: | foreach ($this->hours as $h) { |
| 339: | foreach ($this->minutes as $m) { |
| 340: | foreach ($this->seconds as $s) { |
| 341: | $this->timeOfDay[] = $h * 3600 + $m * 60 + $s; |
| 342: | } |
| 343: | } |
| 344: | } |
| 345: | sort($this->timeOfDay); |
| 346: | } |
| 347: | return true; |
| 348: | } |
| 349: | |
| 350: | |
| 351: | |
| 352: | |
| 353: | |
| 354: | |
| 355: | private function candidates(Frequency $freq, int $year, int $month, int $periodDays, int $periodSeconds): array { |
| 356: | $days = match ($freq) { |
| 357: | Frequency::Yearly => $this->yearDays($year), |
| 358: | Frequency::Monthly => $this->monthDaysOf($year, $month), |
| 359: | Frequency::Weekly => array_values(array_filter(range($periodDays, $periodDays + 6), $this->matchesDayNumber(...))), |
| 360: | default => $this->matchesDayNumber($periodDays) ? [$periodDays] : [], |
| 361: | }; |
| 362: | if ($days === []) { |
| 363: | return []; |
| 364: | } |
| 365: | |
| 366: | if ($freq->isCoarserThan(Frequency::Hourly)) { |
| 367: | $times = $this->timeOfDay; |
| 368: | } else { |
| 369: | $times = $this->subDailyTimes($freq, $periodSeconds); |
| 370: | } |
| 371: | if ($times === []) { |
| 372: | return []; |
| 373: | } |
| 374: | |
| 375: | $candidates = []; |
| 376: | foreach ($days as $dayNumber) { |
| 377: | foreach ($times as $time) { |
| 378: | $candidates[] = [$dayNumber, $time]; |
| 379: | } |
| 380: | } |
| 381: | return $candidates; |
| 382: | } |
| 383: | |
| 384: | |
| 385: | |
| 386: | |
| 387: | private function subDailyTimes(Frequency $freq, int $periodSeconds): array { |
| 388: | $hour = intdiv($periodSeconds, 3600); |
| 389: | $minute = intdiv($periodSeconds % 3600, 60); |
| 390: | $second = $periodSeconds % 60; |
| 391: | if ($this->hours !== [] && !in_array($hour, $this->hours, true)) { |
| 392: | return []; |
| 393: | } |
| 394: | if ($freq === Frequency::Hourly) { |
| 395: | $times = []; |
| 396: | foreach ($this->minutes as $m) { |
| 397: | foreach ($this->seconds as $s) { |
| 398: | $times[] = $hour * 3600 + $m * 60 + $s; |
| 399: | } |
| 400: | } |
| 401: | return $times; |
| 402: | } |
| 403: | if ($this->minutes !== [] && !in_array($minute, $this->minutes, true)) { |
| 404: | return []; |
| 405: | } |
| 406: | if ($freq === Frequency::Minutely) { |
| 407: | return array_map(static fn(int $s): int => $hour * 3600 + $minute * 60 + $s, $this->seconds); |
| 408: | } |
| 409: | if ($this->seconds !== [] && !in_array($second, $this->seconds, true)) { |
| 410: | return []; |
| 411: | } |
| 412: | return [$periodSeconds]; |
| 413: | } |
| 414: | |
| 415: | |
| 416: | |
| 417: | |
| 418: | private function yearDays(int $year): array { |
| 419: | $result = []; |
| 420: | foreach ($this->months !== [] && $this->yearDays === [] && $this->weekNumbers === [] ? array_keys($this->months) : range(1, 12) as $month) { |
| 421: | foreach ($this->monthDaysOf($year, $month) as $dayNumber) { |
| 422: | $result[] = $dayNumber; |
| 423: | } |
| 424: | } |
| 425: | if ($this->skip !== null) { |
| 426: | $result = array_unique($result); |
| 427: | } |
| 428: | sort($result); |
| 429: | return $result; |
| 430: | } |
| 431: | |
| 432: | |
| 433: | |
| 434: | |
| 435: | |
| 436: | |
| 437: | private function monthDaysOf(int $year, int $month): array { |
| 438: | $first = self::daysFromCivil($year, $month, 1); |
| 439: | $length = self::monthLength($year, $month); |
| 440: | $result = []; |
| 441: | for ($d = 0; $d < $length; $d++) { |
| 442: | if ($this->matchesDay($first + $d, $year, $month, $d + 1)) { |
| 443: | $result[] = $first + $d; |
| 444: | } |
| 445: | } |
| 446: | if ($this->skip === null || ($this->months !== [] && !isset($this->months[$month]))) { |
| 447: | return $result; |
| 448: | } |
| 449: | foreach ($this->skipDays as $day) { |
| 450: | if ($day > $length) { |
| 451: | |
| 452: | $moved = $this->skip === Skip::Backward ? $first + $length - 1 : $first + $length; |
| 453: | if (!in_array($moved, $result, true) && $this->matchesWeekday($moved)) { |
| 454: | $result[] = $moved; |
| 455: | } |
| 456: | } |
| 457: | } |
| 458: | sort($result); |
| 459: | return $result; |
| 460: | } |
| 461: | |
| 462: | private function matchesWeekday(int $days): bool { |
| 463: | if ($this->weekdays === [] && $this->weekdayOrdinals === []) { |
| 464: | return true; |
| 465: | } |
| 466: | [$year, $month, $day] = self::civilFromDays($days); |
| 467: | $weekday = self::weekday($days); |
| 468: | return isset($this->weekdays[$weekday]) || $this->matchesWeekdayOrdinal($weekday, $days, $year, $month, $day); |
| 469: | } |
| 470: | |
| 471: | private function matchesDayNumber(int $days): bool { |
| 472: | if (!$this->hasDayFilter) { |
| 473: | return true; |
| 474: | } |
| 475: | [$year, $month, $day] = self::civilFromDays($days); |
| 476: | return $this->matchesDay($days, $year, $month, $day); |
| 477: | } |
| 478: | |
| 479: | private function matchesDay(int $days, int $year, int $month, int $day): bool { |
| 480: | if ($this->months !== [] && !isset($this->months[$month])) { |
| 481: | return false; |
| 482: | } |
| 483: | if ($this->monthDays !== []) { |
| 484: | $length = self::monthLength($year, $month); |
| 485: | if (!isset($this->monthDays[$day]) && !isset($this->monthDays[$day - $length - 1])) { |
| 486: | return false; |
| 487: | } |
| 488: | } |
| 489: | if ($this->yearDays !== []) { |
| 490: | $yearDay = $days - self::daysFromCivil($year, 1, 1) + 1; |
| 491: | if (!isset($this->yearDays[$yearDay]) && !isset($this->yearDays[$yearDay - self::yearLength($year) - 1])) { |
| 492: | return false; |
| 493: | } |
| 494: | } |
| 495: | if ($this->weekNumbers !== [] && !$this->matchesWeekNumber($days, $year)) { |
| 496: | return false; |
| 497: | } |
| 498: | if ($this->weekdays !== [] || $this->weekdayOrdinals !== []) { |
| 499: | $weekday = self::weekday($days); |
| 500: | if (!isset($this->weekdays[$weekday]) && !$this->matchesWeekdayOrdinal($weekday, $days, $year, $month, $day)) { |
| 501: | return false; |
| 502: | } |
| 503: | } |
| 504: | return true; |
| 505: | } |
| 506: | |
| 507: | private function matchesWeekdayOrdinal(int $weekday, int $days, int $year, int $month, int $day): bool { |
| 508: | if (!isset($this->weekdayOrdinals[$weekday])) { |
| 509: | return false; |
| 510: | } |
| 511: | if ($this->ordinalScope === 'month') { |
| 512: | $position = $day; |
| 513: | $length = self::monthLength($year, $month); |
| 514: | } else { |
| 515: | $position = $days - self::daysFromCivil($year, 1, 1) + 1; |
| 516: | $length = self::yearLength($year); |
| 517: | } |
| 518: | $fromStart = intdiv($position - 1, 7) + 1; |
| 519: | $fromEnd = -(intdiv($length - $position, 7) + 1); |
| 520: | foreach ($this->weekdayOrdinals[$weekday] as $ordinal) { |
| 521: | if ($ordinal === $fromStart || $ordinal === $fromEnd) { |
| 522: | return true; |
| 523: | } |
| 524: | } |
| 525: | return false; |
| 526: | } |
| 527: | |
| 528: | |
| 529: | |
| 530: | |
| 531: | |
| 532: | private function matchesWeekNumber(int $days, int $year): bool { |
| 533: | $weekYear = $year; |
| 534: | if ($days < $this->week1Start($year)) { |
| 535: | $weekYear--; |
| 536: | } elseif ($days >= $this->week1Start($year + 1)) { |
| 537: | $weekYear++; |
| 538: | } |
| 539: | $week1 = $this->week1Start($weekYear); |
| 540: | $weeks = intdiv($this->week1Start($weekYear + 1) - $week1, 7); |
| 541: | $number = intdiv($days - $week1, 7) + 1; |
| 542: | return isset($this->weekNumbers[$number]) || isset($this->weekNumbers[$number - $weeks - 1]); |
| 543: | } |
| 544: | |
| 545: | private function week1Start(int $year): int { |
| 546: | if (!isset($this->week1Cache[$year])) { |
| 547: | $january1 = self::daysFromCivil($year, 1, 1); |
| 548: | $weekStart = $january1 - ((self::weekday($january1) - $this->rule->wkst + 7) % 7); |
| 549: | $this->week1Cache[$year] = $january1 - $weekStart <= 3 ? $weekStart : $weekStart + 7; |
| 550: | } |
| 551: | return $this->week1Cache[$year]; |
| 552: | } |
| 553: | |
| 554: | |
| 555: | |
| 556: | |
| 557: | |
| 558: | private function applySetPos(array $candidates): array { |
| 559: | $count = count($candidates); |
| 560: | $selected = []; |
| 561: | foreach ($this->rule->bySetPos as $position) { |
| 562: | $index = $position > 0 ? $position - 1 : $count + $position; |
| 563: | if ($index >= 0 && $index < $count) { |
| 564: | $selected[$index] = $candidates[$index]; |
| 565: | } |
| 566: | } |
| 567: | ksort($selected); |
| 568: | return array_values($selected); |
| 569: | } |
| 570: | |
| 571: | |
| 572: | |
| 573: | |
| 574: | |
| 575: | |
| 576: | |
| 577: | |
| 578: | private function toTimestamp(int $days, int $secondsOfDay): int { |
| 579: | $local = $days * 86400 + $secondsOfDay; |
| 580: | if ($this->fixedOffset !== null) { |
| 581: | return $local - $this->fixedOffset; |
| 582: | } |
| 583: | $timestamp = $local - $this->offset; |
| 584: | if ($timestamp >= $this->offsetFrom && $timestamp < $this->offsetUntil) { |
| 585: | return $timestamp; |
| 586: | } |
| 587: | |
| 588: | $timestamp = LocalTime::timestamp($this->timezone, $local); |
| 589: | $this->rememberOffset($timestamp); |
| 590: | return $timestamp; |
| 591: | } |
| 592: | |
| 593: | private function rememberOffset(int $timestamp): void { |
| 594: | $window = 200 * 86400; |
| 595: | $previous = $timestamp - $window; |
| 596: | $next = $timestamp + $window; |
| 597: | foreach ($this->timezone->getTransitions($timestamp - $window, $timestamp + $window) ?: [] as $index => $transition) { |
| 598: | if ($index === 0) { |
| 599: | continue; |
| 600: | } |
| 601: | if ($transition['ts'] <= $timestamp) { |
| 602: | $previous = $transition['ts']; |
| 603: | } elseif ($transition['ts'] < $next) { |
| 604: | $next = $transition['ts']; |
| 605: | break; |
| 606: | } |
| 607: | } |
| 608: | $this->offset = $this->timezone->getOffset($this->probe->setTimestamp($timestamp)); |
| 609: | $this->offsetFrom = $previous + 86400; |
| 610: | $this->offsetUntil = $next - 86400; |
| 611: | } |
| 612: | |
| 613: | |
| 614: | |
| 615: | |
| 616: | public static function daysFromCivil(int $year, int $month, int $day): int { |
| 617: | $year -= $month <= 2 ? 1 : 0; |
| 618: | $era = intdiv($year >= 0 ? $year : $year - 399, 400); |
| 619: | $yearOfEra = $year - $era * 400; |
| 620: | $dayOfYear = intdiv(153 * ($month + ($month > 2 ? -3 : 9)) + 2, 5) + $day - 1; |
| 621: | $dayOfEra = $yearOfEra * 365 + intdiv($yearOfEra, 4) - intdiv($yearOfEra, 100) + $dayOfYear; |
| 622: | return $era * 146097 + $dayOfEra - 719468; |
| 623: | } |
| 624: | |
| 625: | |
| 626: | |
| 627: | |
| 628: | public static function civilFromDays(int $days): array { |
| 629: | $days += 719468; |
| 630: | $era = intdiv($days >= 0 ? $days : $days - 146096, 146097); |
| 631: | $dayOfEra = $days - $era * 146097; |
| 632: | $yearOfEra = intdiv($dayOfEra - intdiv($dayOfEra, 1460) + intdiv($dayOfEra, 36524) - intdiv($dayOfEra, 146096), 365); |
| 633: | $dayOfYear = $dayOfEra - (365 * $yearOfEra + intdiv($yearOfEra, 4) - intdiv($yearOfEra, 100)); |
| 634: | $monthPart = intdiv(5 * $dayOfYear + 2, 153); |
| 635: | $day = $dayOfYear - intdiv(153 * $monthPart + 2, 5) + 1; |
| 636: | $month = $monthPart < 10 ? $monthPart + 3 : $monthPart - 9; |
| 637: | return [$yearOfEra + $era * 400 + ($month <= 2 ? 1 : 0), $month, $day]; |
| 638: | } |
| 639: | |
| 640: | |
| 641: | |
| 642: | |
| 643: | public static function weekday(int $days): int { |
| 644: | return (($days % 7) + 10) % 7 + 1; |
| 645: | } |
| 646: | |
| 647: | private static function monthLength(int $year, int $month): int { |
| 648: | return $month === 2 ? (self::isLeapYear($year) ? 29 : 28) : (in_array($month, [4, 6, 9, 11], true) ? 30 : 31); |
| 649: | } |
| 650: | |
| 651: | private static function yearLength(int $year): int { |
| 652: | return self::isLeapYear($year) ? 366 : 365; |
| 653: | } |
| 654: | |
| 655: | private static function isLeapYear(int $year): bool { |
| 656: | return ($year % 4 === 0 && $year % 100 !== 0) || $year % 400 === 0; |
| 657: | } |
| 658: | } |
| 659: | |