| 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: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | final class VTimezoneBuilder { |
| 31: | |
| 32: | public const int OPEN_YEARS = 10; |
| 33: | |
| 34: | private const int YEAR = 366 * 86400; |
| 35: | |
| 36: | |
| 37: | |
| 38: | |
| 39: | |
| 40: | |
| 41: | |
| 42: | |
| 43: | |
| 44: | |
| 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: | |
| 62: | |
| 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: | |
| 98: | |
| 99: | |
| 100: | |
| 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) { |
| 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: | |
| 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; |
| 152: | } |
| 153: | return $last; |
| 154: | } |
| 155: | |
| 156: | |
| 157: | |
| 158: | |
| 159: | |
| 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: | |
| 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: | |
| 186: | |
| 187: | |
| 188: | |
| 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: | |
| 209: | |
| 210: | |
| 211: | |
| 212: | |
| 213: | |
| 214: | private static function runs(DateTimeZone $timezone, array $transitions, int $to): array { |
| 215: | $runs = []; |
| 216: | $current = []; |
| 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: | |
| 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: | |
| 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: | |
| 259: | |
| 260: | private static function year(array $transition): int { |
| 261: | return (int) gmdate('Y', $transition['ts'] + $transition['from']); |
| 262: | } |
| 263: | |
| 264: | |
| 265: | |
| 266: | |
| 267: | |
| 268: | |
| 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: | |