1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Timezone;
5:
6: use DateTimeImmutable;
7: use om\ICal\Component;
8: use om\ICal\Value\Text;
9: use om\TimezoneGuesser;
10: use WeakMap;
11:
12: /**
13: * Resolves a TZID defined by a VTIMEZONE component of the calendar.
14: *
15: * PHP cannot create a timezone from custom rules, so the IANA timezone with the same
16: * transitions around the reference date is used (see TimezoneGuesser), preferring the
17: * timezone of the same name. A definition without transitions becomes a fixed offset.
18: */
19: final class VTimezoneResolver implements TimezoneResolver {
20: /** @var WeakMap<Component, array<string, ?ResolvedTimezone>> */
21: private WeakMap $cache;
22:
23: public function __construct(
24: private readonly TimezoneResolver $names = new CompositeTimezoneResolver(new IanaTimezoneResolver(), new AliasTimezoneResolver()),
25: private readonly ?DateTimeImmutable $reference = null,
26: ) {
27: $this->cache = new WeakMap();
28: }
29:
30: public function resolve(string $tzid, Component $calendar): ?ResolvedTimezone {
31: $cache = $this->cache[$calendar] ?? [];
32: if (!array_key_exists($tzid, $cache)) {
33: $cache[$tzid] = $this->find($tzid, $calendar);
34: $this->cache[$calendar] = $cache;
35: }
36: return $cache[$tzid];
37: }
38:
39: private function find(string $tzid, Component $calendar): ?ResolvedTimezone {
40: foreach ($calendar->components('VTIMEZONE') as $definition) {
41: $defined = $definition->property('TZID')?->value;
42: if ($defined === null || Text::unescape($defined) !== $tzid) {
43: continue;
44: }
45: // X-LIC-LOCATION (Mozilla) and the TZID itself name the intended zone
46: $location = $definition->property('X-LIC-LOCATION')?->value;
47: $named = array_values(array_unique(array_filter([
48: $location === null ? null : $this->names->resolve($location, $calendar)?->timezone->getName(),
49: $this->names->resolve($tzid, $calendar)?->timezone->getName(),
50: ])));
51: $preferred = array_values(array_unique(AliasTimezoneResolver::windowsTimezones()));
52: $timezone = TimezoneGuesser::guess(self::observances($definition), $this->reference, $preferred, $named);
53: return $timezone === null ? null : new ResolvedTimezone($tzid, $timezone, TimezoneSource::VTimezone);
54: }
55: return null;
56: }
57:
58: /**
59: * @return list<array{start: string, offsetFrom: string, offsetTo: string, rrule: ?string, rdates: list<string>}>
60: */
61: public static function observances(Component $definition): array {
62: $observances = [];
63: foreach ($definition->components as $observance) {
64: if ($observance->name !== 'STANDARD' && $observance->name !== 'DAYLIGHT') {
65: continue;
66: }
67: $rdates = [];
68: foreach ($observance->properties('RDATE') as $rdate) {
69: array_push($rdates, ...explode(',', $rdate->value));
70: }
71: $observances[] = [
72: 'start' => $observance->property('DTSTART')->value ?? '19700101T000000',
73: 'offsetFrom' => $observance->property('TZOFFSETFROM')->value ?? '+0000',
74: 'offsetTo' => $observance->property('TZOFFSETTO')->value ?? '+0000',
75: 'rrule' => $observance->property('RRULE')?->value,
76: 'rdates' => $rdates,
77: ];
78: }
79: return $observances;
80: }
81: }
82: