1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Timezone;
5:
6: use DateTimeImmutable;
7: use DateTimeInterface;
8: use DateTimeZone;
9: use Exception;
10: use InvalidArgumentException;
11: use om\ICal\Calendar;
12: use om\ICal\Component;
13: use om\ICal\Property;
14: use om\ICal\Value\DateTimeValue;
15: use om\ICal\Value\PropertyFactory;
16: use om\ICal\Value\Text;
17: use om\ICal\Value\UtcOffset;
18: use om\RRule\Rule;
19:
20: /**
21: * Creates a VTIMEZONE component (RFC 5545, section 3.6.5) of a PHP timezone from its transitions
22: * (DateTimeZone::getTransitions()) in a range of time.
23: *
24: * Transitions of the same kind (offsets, abbreviation, DST) on the same weekday of a month
25: * (e.g. the last Sunday of March) in consecutive years become one observance with a yearly
26: * RRULE; other transitions are observances of their own. A rule still in use after the range
27: * has no UNTIL, so the definition also covers later dates while the timezone keeps the rule.
28: * The first observance is the transition in effect at the start of the range.
29: */
30: final class VTimezoneBuilder {
31: /** Years after the last date of a recurrence without an end (RRULE without COUNT and UNTIL) covered by the definition. */
32: public const int OPEN_YEARS = 10;
33:
34: private const int YEAR = 366 * 86400;
35:
36: /**
37: * VTIMEZONE components of the IANA TZIDs used by the components (also in their child
38: * components), in the order of first use. Every definition covers the dates of its TZID with
39: * a year before and after; a recurrence also its last occurrence, and OPEN_YEARS after its
40: * start when it has no end. Other TZIDs (not IANA names) are skipped.
41: *
42: * @param iterable<Component> $components
43: * @param list<string> $defined TZIDs that already have a definition
44: * @return list<Component>
45: */
46: public static function forComponents(iterable $components, array $defined = []): array {
47: $ranges = [];
48: foreach ($components as $component) {
49: self::collect($component, $ranges);
50: }
51: $result = [];
52: foreach ($ranges as $tzid => [$min, $max]) {
53: if (!in_array($tzid, $defined, true)) {
54: $result[] = self::build(new DateTimeZone($tzid), $min - self::YEAR, $max + self::YEAR, $tzid);
55: }
56: }
57: return $result;
58: }
59:
60: /**
61: * @param ?string $tzid TZID of the definition, the name of the timezone by default
62: * @throws InvalidArgumentException when $to is before $from
63: */
64: public static function build(DateTimeZone $timezone, DateTimeInterface|int $from, DateTimeInterface|int $to, ?string $tzid = null): Component {
65: $from = $from instanceof DateTimeInterface ? $from->getTimestamp() : $from;
66: $to = $to instanceof DateTimeInterface ? $to->getTimestamp() : $to;
67: if ($to < $from) {
68: throw new InvalidArgumentException('The end of the range is before its start.');
69: }
70: $transitions = self::transitions($timezone, $from, $to);
71: $observances = [];
72: foreach (self::runs($timezone, $transitions, $to) as [$run, $open]) {
73: $first = $run[0];
74: $properties = [
75: Property::create('DTSTART', gmdate('Ymd\THis', $first['ts'] + $first['from'])),
76: Property::create('TZOFFSETFROM', UtcOffset::format($first['from'])),
77: Property::create('TZOFFSETTO', UtcOffset::format($first['offset'])),
78: ];
79: if (count($run) > 1 || $open) {
80: $rule = self::rule($first);
81: $rrule = sprintf('FREQ=YEARLY;BYMONTH=%d;BYDAY=%d%s', $rule[0], $rule[2], array_search($rule[1], Rule::WEEKDAYS, true));
82: $properties[] = Property::create('RRULE', $open ? $rrule : $rrule . ';UNTIL=' . gmdate('Ymd\THis\Z', $run[count($run) - 1]['ts']));
83: }
84: if ($first['abbr'] !== '') {
85: $properties[] = Property::create('TZNAME', Text::escape($first['abbr']));
86: }
87: $observances[] = new Component($first['isdst'] ? 'DAYLIGHT' : 'STANDARD', $properties);
88: }
89: $name = $timezone->getName();
90: return new Component('VTIMEZONE', [
91: Property::create('TZID', Text::escape($tzid ?? $name)),
92: Property::create('X-LIC-LOCATION', Text::escape($name)),
93: ], $observances);
94: }
95:
96: /**
97: * Instants of the values of IANA TZIDs, extended to the end of a recurrence.
98: *
99: * @param array<string, array{int, int}> $ranges TZID => [first, last]
100: * @param-out array<string, array{int, int}> $ranges
101: */
102: private static function collect(Component $component, array &$ranges): void {
103: $used = [];
104: foreach ($component->properties as $property) {
105: $tzid = $property->parameter('TZID');
106: if ($tzid === null || !PropertyFactory::isTimezoneName($tzid)) {
107: continue;
108: }
109: $timezone = new DateTimeZone($tzid);
110: foreach (explode(',', $property->value) as $item) {
111: foreach (explode('/', $item, 2) as $part) { // the start and the end of a PERIOD
112: if (DateTimeValue::isValid($part)) {
113: $timestamp = DateTimeValue::parse($part, false, $tzid, $timezone)->toDateTime($timezone)->getTimestamp();
114: $ranges[$tzid] = [min($ranges[$tzid][0] ?? $timestamp, $timestamp), max($ranges[$tzid][1] ?? $timestamp, $timestamp)];
115: $used[$tzid] = max($used[$tzid] ?? $timestamp, $timestamp);
116: }
117: }
118: }
119: }
120: if ($used !== [] && $component->has('RRULE')) {
121: $end = self::recurrenceEnd($component, max($used));
122: foreach (array_keys($used) as $tzid) {
123: [$first, $lastUsed] = $ranges[$tzid] ?? [$end, $end];
124: $ranges[$tzid] = [$first, max($lastUsed, $end)];
125: }
126: }
127: foreach ($component->components as $child) {
128: self::collect($child, $ranges);
129: }
130: }
131:
132: /**
133: * End of the last occurrence of a recurring component; OPEN_YEARS after its last date without an end.
134: */
135: private static function recurrenceEnd(Component $component, int $last): int {
136: $open = $last + self::OPEN_YEARS * self::YEAR;
137: $item = (new Calendar())->itemOf($component);
138: if ($item === null) {
139: return $last;
140: }
141: foreach ($item->recurrenceRules() as $rule) {
142: if (!$rule->isGregorian() || ($rule->count === null && $rule->until === null)) {
143: return $open;
144: }
145: }
146: try {
147: foreach ($item->occurrences(10000) as $occurrence) {
148: $last = max($last, $occurrence->end->toDateTime(null, new DateTimeZone('UTC'))->getTimestamp());
149: }
150: } catch (Exception) {
151: return $open; // a limit of the recurrence
152: }
153: return $last;
154: }
155:
156: /**
157: * The transition in effect at $from and the transitions until $to, with the offset before each one.
158: *
159: * @return non-empty-list<array{ts: int, from: int, offset: int, isdst: bool, abbr: string}>
160: */
161: private static function transitions(DateTimeZone $timezone, int $from, int $to): array {
162: $previous = null;
163: foreach ([self::YEAR, 10 * self::YEAR, 150 * self::YEAR] as $window) {
164: $list = self::changes($timezone, $from - $window, $from);
165: if (count($list) > 1) {
166: $previous = $list[count($list) - 1];
167: break;
168: }
169: }
170: if ($previous === null) {
171: // no transition before the range: the offset of the range since 1970 (or the start of the range)
172: $state = self::changes($timezone, $from, $from)[0];
173: $previous = [...$state, 'ts' => $from >= 0 ? min($from, -$state['offset']) : $from];
174: }
175: $result = [$previous];
176: foreach (array_slice(self::changes($timezone, $from, $to), 1) as $transition) {
177: if ($transition['ts'] > $previous['ts']) {
178: $result[] = $transition;
179: }
180: }
181: return $result;
182: }
183:
184: /**
185: * Transitions of getTransitions($begin, $end) with the offset before each one; the first item is
186: * the state at $begin. A transition exactly at $begin is merged into the state.
187: *
188: * @return non-empty-list<array{ts: int, from: int, offset: int, isdst: bool, abbr: string}>
189: */
190: private static function changes(DateTimeZone $timezone, int $begin, int $end): array {
191: $list = $timezone->getTransitions($begin, $end) ?: [['ts' => $begin, 'offset' => $timezone->getOffset(new DateTimeImmutable('@' . $begin)), 'isdst' => false, 'abbr' => '']];
192: $first = array_shift($list);
193: $result = [['ts' => $begin, 'from' => (int) $first['offset'], 'offset' => (int) $first['offset'], 'isdst' => (bool) $first['isdst'], 'abbr' => (string) $first['abbr']]];
194: $offset = (int) $first['offset'];
195: foreach ($list as $transition) {
196: if ($transition['ts'] <= $begin) {
197: $result[0] = ['ts' => $begin, 'from' => (int) $transition['offset'], 'offset' => (int) $transition['offset'], 'isdst' => (bool) $transition['isdst'], 'abbr' => (string) $transition['abbr']];
198: $offset = (int) $transition['offset'];
199: continue;
200: }
201: $result[] = ['ts' => (int) $transition['ts'], 'from' => $offset, 'offset' => (int) $transition['offset'], 'isdst' => (bool) $transition['isdst'], 'abbr' => (string) $transition['abbr']];
202: $offset = (int) $transition['offset'];
203: }
204: return $result;
205: }
206:
207: /**
208: * Transitions grouped into observances: runs of the same kind and rule in consecutive years;
209: * a run is open when its rule continues after the range.
210: *
211: * @param non-empty-list<array{ts: int, from: int, offset: int, isdst: bool, abbr: string}> $transitions
212: * @return list<array{non-empty-list<array{ts: int, from: int, offset: int, isdst: bool, abbr: string}>, bool}>
213: */
214: private static function runs(DateTimeZone $timezone, array $transitions, int $to): array {
215: $runs = [];
216: $current = []; // kind => index in $runs
217: foreach ($transitions as $transition) {
218: $kind = self::kind($transition);
219: $index = $current[$kind] ?? null;
220: if ($index !== null) {
221: $last = $runs[$index][count($runs[$index]) - 1];
222: if (self::year($transition) === self::year($last) + 1 && self::rule($transition) === self::rule($last)) {
223: $runs[$index][] = $transition;
224: continue;
225: }
226: }
227: $current[$kind] = count($runs);
228: $runs[] = [$transition];
229: }
230:
231: // the runs of the last transitions of each kind continue when the next one follows the rule
232: $after = array_slice(self::changes($timezone, $to, $to + 2 * self::YEAR), 1);
233: $result = [];
234: foreach ($runs as $index => $run) {
235: $last = $run[count($run) - 1];
236: $open = false;
237: if (($current[self::kind($last)] ?? null) === $index) {
238: foreach ($after as $next) {
239: if (self::kind($next) === self::kind($last)) {
240: $open = self::year($next) === self::year($last) + 1 && self::rule($next) === self::rule($last);
241: break;
242: }
243: }
244: }
245: $result[] = [$run, $open];
246: }
247: return $result;
248: }
249:
250: /**
251: * @param array{ts: int, from: int, offset: int, isdst: bool, abbr: string} $transition
252: */
253: private static function kind(array $transition): string {
254: return implode('|', [(int) $transition['isdst'], $transition['from'], $transition['offset'], $transition['abbr']]);
255: }
256:
257: /**
258: * @param array{ts: int, from: int, offset: int, isdst: bool, abbr: string} $transition
259: */
260: private static function year(array $transition): int {
261: return (int) gmdate('Y', $transition['ts'] + $transition['from']);
262: }
263:
264: /**
265: * Month, ISO weekday, the week of the month (-1 for the last one) and the local time.
266: *
267: * @param array{ts: int, from: int, offset: int, isdst: bool, abbr: string} $transition
268: * @return array{int, int, int, string}
269: */
270: private static function rule(array $transition): array {
271: $local = $transition['ts'] + $transition['from'];
272: $day = (int) gmdate('j', $local);
273: $week = $day + 7 > (int) gmdate('t', $local) ? -1 : intdiv($day - 1, 7) + 1;
274: return [(int) gmdate('n', $local), (int) gmdate('N', $local), $week, gmdate('His', $local)];
275: }
276: }
277: