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: * Expands a recurrence rule into occurrence timestamps (RFC 5545, section 3.3.10).
17: *
18: * Each FREQ period (year, month, week, day, hour, ...) is turned into a set of
19: * candidate days and times, which the BYxxx rule parts then filter. Filtering
20: * a whole period covers both the "expand" and the "limit" behaviour described
21: * in the RFC table, and BYSETPOS then picks from the sorted period set.
22: *
23: * Calculations use wall-clock dates in the DTSTART timezone, so daylight saving
24: * transitions keep the local time, and the process default timezone is never changed.
25: * DTSTART always counts as the first occurrence, even when it does not match the rule.
26: *
27: * SKIP of RFC 7529 moves an instance on an invalid day of the month (e.g. February 30)
28: * to the last day of the month or to the first day of the next month; this happens before
29: * BYDAY, BYSETPOS and COUNT, and duplicate instances are removed.
30: *
31: * @implements IteratorAggregate<int, int>
32: */
33: final class Expander implements IteratorAggregate {
34: /** Stop searching sub-daily rules after this many consecutive periods without any candidate. */
35: public const int MAX_EMPTY_PERIODS = 100000;
36:
37: /**
38: * The Gregorian calendar repeats every 400 years (146 097 days, a multiple of 7). When all
39: * periods of a whole cycle are empty, no later period can match, so the search ends.
40: */
41: private const array CYCLE_PERIODS = ['YEARLY' => 400, 'MONTHLY' => 4800, 'WEEKLY' => 20871, 'DAILY' => 146097];
42: private const int MAX_DAYS = 2932897; // 10000-01-01, the first day that is not expanded
43:
44: private readonly DateTimeZone $timezone;
45: private readonly ?int $fixedOffset;
46: private readonly DateTime $probe;
47: /** UTC offset valid for instants in [$offsetFrom, $offsetUntil), see toTimestamp() */
48: private int $offset = 0;
49: private int $offsetFrom = PHP_INT_MAX;
50: private int $offsetUntil = PHP_INT_MIN;
51:
52: /** @var array<int, true> */
53: private array $months = [];
54: /** @var array<int, true> */
55: private array $monthDays = [];
56: /** @var array<int, true> */
57: private array $yearDays = [];
58: /** @var array<int, true> */
59: private array $weekNumbers = [];
60: /** @var array<int, true> weekdays without an ordinal */
61: private array $weekdays = [];
62: /** @var array<int, list<int>> ordinals by weekday */
63: private array $weekdayOrdinals = [];
64: private bool $hasDayFilter;
65: /** SKIP=BACKWARD or FORWARD of a MONTHLY or YEARLY rule, null when invalid days are omitted */
66: private ?Skip $skip = null;
67: /** @var list<int> BYMONTHDAY values that are invalid in some months */
68: private array $skipDays = [];
69: private string $ordinalScope;
70: /** @var list<int> */
71: private array $hours;
72: /** @var list<int> */
73: private array $minutes;
74: /** @var list<int> */
75: private array $seconds;
76: /** @var list<int> seconds of the day, for FREQ coarser than HOURLY */
77: private array $timeOfDay = [];
78: /** @var array<int, int> */
79: private array $week1Cache = [];
80:
81: /**
82: * @param ?int $horizon additional inclusive end of the expansion (timestamp)
83: * @param int $limit maximal number of occurrences; exceeding it throws ResourceLimitException
84: * @param int $maxIterations maximal number of FREQ periods examined; exceeding it throws ResourceLimitException
85: * @throws InvalidRecurrenceRuleException recurrence.unsupported-rscale for a rule of another calendar system than GREGORIAN
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(); // other calendar systems would give wrong dates
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: * @return Generator<int, int>
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: // DTSTART is always the first instance, even when UNTIL or the horizon is earlier
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: /** @var list<array{int, int}> instances moved forward into the next month, see SKIP=FORWARD */
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: // sub-daily periods of a day (or hour) that cannot match are skipped at once
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: // the next period may produce an earlier instance on the same day
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: // skips times before DTSTART and wall-clock times repeated by a DST transition
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: * For sub-daily rules: seconds until the next day (or hour, or minute) that may match,
241: * 0 when the current period may produce an occurrence.
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: * Earliest possible instant of a period (a day early, to be safe with any UTC offset).
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: * Apply the RFC 5545 defaults: rule parts missing from the rule are taken from DTSTART.
270: *
271: * @return bool false when the rule cannot produce any further occurrence
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: // An ordinal BYDAY means "nth weekday of the month" for MONTHLY rules and for YEARLY
301: // rules restricted by BYMONTH, "nth weekday of the year" for other YEARLY rules.
302: // The RFC does not allow ordinals with other frequencies, so they are ignored there.
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: // SKIP applies to invalid days of the month produced by BYMONTHDAY (or the day of DTSTART);
318: // BYYEARDAY and BYWEEKNO select existing days only, negative BYMONTHDAY values are omitted
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, // a leap second cannot be represented
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: * Candidate [days, seconds of day] pairs of one period, sorted.
352: *
353: * @return list<array{int, int}>
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: * @return list<int>
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: * @return list<int>
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: * Matching days of a month, sorted; with SKIP an invalid day is moved and may be in the next month.
434: *
435: * @return list<int>
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: // BYDAY applies after SKIP (RFC 7529, section 4.1)
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: * Week numbering per RFC 5545: week 1 is the first week (starting on WKST)
530: * with at least four days in the year.
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: * @param list<array{int, int}> $candidates
556: * @return list<array{int, int}>
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: * Convert a wall-clock time in the DTSTART timezone to a timestamp.
573: *
574: * The UTC offset only changes at timezone transitions, so the last offset is reused
575: * while the result stays more than a day away from any transition. Times close to
576: * a transition (including nonexistent and ambiguous times) are resolved by PHP.
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; // the first entry describes the start of the range, not a transition
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: * Days since 1970-01-01 for a proleptic Gregorian date.
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: * @return array{int, int, int} year, month, day
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: * ISO-8601 weekday (1 = Monday ... 7 = Sunday).
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: