| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om; |
| 5: | |
| 6: | use DateTimeImmutable; |
| 7: | use DateTimeZone; |
| 8: | use Exception; |
| 9: | use InvalidArgumentException; |
| 10: | use om\RRule\Expander; |
| 11: | use om\RRule\Rule; |
| 12: | |
| 13: | |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | |
| 20: | |
| 21: | |
| 22: | final class TimezoneGuesser { |
| 23: | |
| 24: | private static array $cache = []; |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | |
| 31: | |
| 32: | |
| 33: | public static function guess(array $observances, ?DateTimeImmutable $reference = null, array $preferred = [], array $named = []): ?DateTimeZone { |
| 34: | if ($observances === []) { |
| 35: | return null; |
| 36: | } |
| 37: | $year = (int) ($reference ?? new DateTimeImmutable())->format('Y'); |
| 38: | $from = Expander::daysFromCivil($year - 1, 1, 1) * 86400; |
| 39: | $until = Expander::daysFromCivil($year + 2, 1, 1) * 86400; |
| 40: | |
| 41: | try { |
| 42: | [$initial, $transitions] = self::transitions($observances, $from, $until); |
| 43: | } catch (InvalidArgumentException | Exception) { |
| 44: | return null; |
| 45: | } |
| 46: | |
| 47: | $key = json_encode([$initial, $transitions]) . implode(',', $named) . '|' . implode(',', $preferred); |
| 48: | if (!isset(self::$cache[$key])) { |
| 49: | self::$cache[$key] = $transitions === [] |
| 50: | ? self::find($initial, [], $from, $until, $named) ?? self::fixedOffset($initial) |
| 51: | : self::find($initial, $transitions, $from, $until, [...$named, ...$preferred]) ?? false; |
| 52: | } |
| 53: | return self::$cache[$key] ?: null; |
| 54: | } |
| 55: | |
| 56: | |
| 57: | |
| 58: | |
| 59: | |
| 60: | |
| 61: | |
| 62: | private static function transitions(array $observances, int $from, int $until): array { |
| 63: | $all = []; |
| 64: | foreach ($observances as $observance) { |
| 65: | $offsetFrom = self::offset($observance['offsetFrom']); |
| 66: | $offsetTo = self::offset($observance['offsetTo']); |
| 67: | |
| 68: | |
| 69: | $zone = self::fixedOffsetZone($offsetFrom); |
| 70: | $start = new DateTimeImmutable($observance['start'], $zone); |
| 71: | $instants = [$start->getTimestamp()]; |
| 72: | if (!empty($observance['rrule'])) { |
| 73: | $instants = []; |
| 74: | foreach (new Expander(Rule::fromString($observance['rrule'], true), $start, $until + 2 * 86400) as $timestamp) { |
| 75: | $instants[] = $timestamp; |
| 76: | } |
| 77: | } |
| 78: | foreach ($observance['rdates'] ?? [] as $rdate) { |
| 79: | $instants[] = (new DateTimeImmutable($rdate, $zone))->getTimestamp(); |
| 80: | } |
| 81: | foreach ($instants as $timestamp) { |
| 82: | $all[] = [$timestamp, $offsetTo]; |
| 83: | } |
| 84: | } |
| 85: | usort($all, static fn(array $a, array $b): int => $a[0] <=> $b[0]); |
| 86: | |
| 87: | $initial = self::offset($observances[0]['offsetFrom']); |
| 88: | $transitions = []; |
| 89: | foreach ($all as [$timestamp, $offset]) { |
| 90: | if ($timestamp < $from) { |
| 91: | $initial = $offset; |
| 92: | } elseif ($timestamp < $until) { |
| 93: | $transitions[] = [$timestamp, $offset]; |
| 94: | } |
| 95: | } |
| 96: | return [$initial, self::withoutRepeatedOffsets($initial, $transitions)]; |
| 97: | } |
| 98: | |
| 99: | |
| 100: | |
| 101: | |
| 102: | |
| 103: | private static function find(int $initial, array $transitions, int $from, int $until, array $preferred): ?DateTimeZone { |
| 104: | |
| 105: | $candidates = array_unique($transitions === [] ? $preferred : [...$preferred, ...DateTimeZone::listIdentifiers()]); |
| 106: | foreach ($candidates as $name) { |
| 107: | try { |
| 108: | $timezone = new DateTimeZone($name); |
| 109: | } catch (Exception) { |
| 110: | continue; |
| 111: | } |
| 112: | $known = $timezone->getTransitions($from, $until - 1) ?: []; |
| 113: | if ($known === [] || $known[0]['offset'] !== $initial) { |
| 114: | continue; |
| 115: | } |
| 116: | $changes = []; |
| 117: | foreach (array_slice($known, 1) as $transition) { |
| 118: | $changes[] = [$transition['ts'], $transition['offset']]; |
| 119: | } |
| 120: | if (self::withoutRepeatedOffsets($initial, $changes) === $transitions) { |
| 121: | return $timezone; |
| 122: | } |
| 123: | } |
| 124: | return null; |
| 125: | } |
| 126: | |
| 127: | |
| 128: | |
| 129: | |
| 130: | |
| 131: | private static function withoutRepeatedOffsets(int $offset, array $transitions): array { |
| 132: | $result = []; |
| 133: | foreach ($transitions as [$timestamp, $next]) { |
| 134: | if ($next !== $offset) { |
| 135: | $result[] = [$timestamp, $next]; |
| 136: | $offset = $next; |
| 137: | } |
| 138: | } |
| 139: | return $result; |
| 140: | } |
| 141: | |
| 142: | private static function fixedOffset(int $offset): DateTimeZone { |
| 143: | $sign = $offset < 0 ? '-' : '+'; |
| 144: | $offset = abs($offset); |
| 145: | return new DateTimeZone(sprintf('%s%02d:%02d', $sign, intdiv($offset, 3600), intdiv($offset % 3600, 60))); |
| 146: | } |
| 147: | |
| 148: | |
| 149: | |
| 150: | |
| 151: | private static function fixedOffsetZone(int $offset): DateTimeZone { |
| 152: | $seconds = abs($offset); |
| 153: | $name = sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', intdiv($seconds, 3600), intdiv($seconds % 3600, 60)); |
| 154: | return new DateTimeZone($seconds % 60 === 0 ? $name : sprintf('%s:%02d', $name, $seconds % 60)); |
| 155: | } |
| 156: | |
| 157: | |
| 158: | |
| 159: | |
| 160: | public static function offset(string $value): int { |
| 161: | if (!preg_match('/^([+-])(\d{2})(\d{2})(\d{2})?$/D', trim($value), $match)) { |
| 162: | throw new InvalidArgumentException("Invalid UTC offset: $value"); |
| 163: | } |
| 164: | $seconds = (int) $match[2] * 3600 + (int) $match[3] * 60 + (int) ($match[4] ?? 0); |
| 165: | return $match[1] === '-' ? -$seconds : $seconds; |
| 166: | } |
| 167: | } |
| 168: | |