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: * Finds a PHP timezone for a custom VTIMEZONE definition (RFC 5545, section 3.6.5).
15: *
16: * PHP cannot create a timezone from custom rules, so the transitions defined by the
17: * STANDARD and DAYLIGHT observances are calculated for the years around the reference
18: * date and compared with the transitions of the IANA timezones. A definition without
19: * transitions becomes a fixed UTC offset. The first matching timezone is returned,
20: * zones used by the Windows mapping are preferred.
21: */
22: final class TimezoneGuesser {
23: /** @var array<string, DateTimeZone|false> */
24: private static array $cache = [];
25:
26: /**
27: * @param list<array{start: string, offsetFrom: string, offsetTo: string, rrule?: ?string, rdates?: list<string>}> $observances
28: * start is the local DTSTART like "19701025T030000", offsets look like "+0100"
29: * @param list<string> $preferred timezones tried first
30: * @param list<string> $named timezones named by the definition (its TZID, X-LIC-LOCATION); they are
31: * used also for definitions without transitions when their offset matches
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; // invalid DTSTART, offset or RRULE
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: * Offset in effect at $from and the offset changes in [$from, $until).
58: *
59: * @param list<array{start: string, offsetFrom: string, offsetTo: string, rrule?: ?string, rdates?: list<string>}> $observances
60: * @return array{int, list<array{int, int}>}
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: // local times are in TZOFFSETFROM; the UNTIL of a rule is in UTC (RFC 5545, section 3.3.10),
68: // so the rule is expanded in the fixed offset and gives UTC timestamps
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; // includes DTSTART
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: * @param list<array{int, int}> $transitions
101: * @param list<string> $preferred
102: */
103: private static function find(int $initial, array $transitions, int $from, int $until, array $preferred): ?DateTimeZone {
104: // a fixed offset is matched only with the named timezones, any zone could have it
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: * @param list<array{int, int}> $transitions
129: * @return list<array{int, int}>
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: * Timezone with a fixed offset, e.g. "+03:00", or "+00:19:32" for a local mean time.
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: * Parse a UTC-OFFSET value ("+0100", "-053000").
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: