1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Value;
5:
6: use DateInterval;
7: use DateTimeImmutable;
8: use DateTimeInterface;
9: use DateTimeZone;
10: use Exception;
11: use om\ICal\Exception\InvalidValueException;
12: use om\ICal\Exception\TimezoneResolutionException;
13: use om\RRule\LocalTime;
14: use Stringable;
15:
16: /**
17: * A DATE or DATE-TIME value that keeps its meaning: date, floating, UTC or zoned time.
18: *
19: * Floating times and dates have no timezone; they are converted to an instant only when
20: * a timezone is given to toDateTime(). Local values can always be read with format().
21: */
22: final readonly class DateTimeValue implements Stringable {
23: /**
24: * @param DateTimeImmutable $dateTime the value in its timezone; the wall-clock time in UTC for dates and floating times
25: * @param ?string $tzid TZID parameter, also kept for floating times whose TZID could not be resolved
26: */
27: private function __construct(
28: public DateTimeType $type,
29: private DateTimeImmutable $dateTime,
30: public ?string $tzid = null,
31: ) {
32: }
33:
34: public static function date(int $year, int $month, int $day): self {
35: return new self(DateTimeType::Date, self::wallClock(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day)));
36: }
37:
38: /**
39: * A floating time with the wall-clock time of the given date.
40: */
41: public static function floating(DateTimeInterface $wallClock, ?string $unresolvedTzid = null): self {
42: return new self(DateTimeType::Floating, self::wallClock($wallClock->format('Y-m-d H:i:s')), $unresolvedTzid);
43: }
44:
45: /**
46: * A UTC time for a UTC date, a zoned time otherwise.
47: */
48: public static function fromDateTime(DateTimeInterface $dateTime, ?string $tzid = null): self {
49: $dateTime = DateTimeImmutable::createFromInterface($dateTime);
50: $name = $dateTime->getTimezone()->getName();
51: if (in_array($name, ['UTC', 'Z', '+00:00', 'GMT', 'Etc/UTC', 'Etc/GMT', 'Etc/Universal', 'Etc/Zulu'], true)) {
52: return new self(DateTimeType::Utc, $dateTime->setTimezone(new DateTimeZone('UTC')));
53: }
54: return new self(DateTimeType::Zoned, $dateTime, $tzid ?? $name);
55: }
56:
57: /**
58: * Parse "20261010", "20261010T100000" or "20261010T100000Z".
59: *
60: * @param ?DateTimeZone $timezone resolved TZID; null keeps a TZID value floating
61: * @throws InvalidValueException
62: */
63: public static function parse(string $value, bool $date = false, ?string $tzid = null, ?DateTimeZone $timezone = null): self {
64: $raw = $value;
65: $value = strtoupper(trim($value));
66: if (!preg_match('/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/D', $value, $match, PREG_UNMATCHED_AS_NULL)
67: || !checkdate((int) $match[2], (int) $match[3], (int) $match[1])
68: || ($match[4] !== null && ((int) $match[4] > 23 || (int) $match[5] > 59 || (int) $match[6] > 60))
69: || ($date && $match[4] !== null)) {
70: throw InvalidValueException::create('value.invalid-date-time', 'Invalid DATE-TIME value: ' . $raw, rawValue: $raw);
71: }
72: [, $year, $month, $day, $hour, $minute, $second, $utc] = $match;
73: $local = sprintf('%s-%s-%s %s:%s:%s', $year, $month, $day, $hour ?? '00', $minute ?? '00', min((int) ($second ?? 0), 59));
74:
75: if ($hour === null) {
76: return new self(DateTimeType::Date, self::wallClock($local));
77: }
78: if ($utc !== null) {
79: return new self(DateTimeType::Utc, new DateTimeImmutable($local, new DateTimeZone('UTC')));
80: }
81: if ($tzid !== null && $timezone !== null) {
82: // ambiguous and nonexistent local times as RFC 5545 requires (PHP is not consistent)
83: $instant = LocalTime::timestamp($timezone, self::wallClock($local)->getTimestamp());
84: return new self(DateTimeType::Zoned, (new DateTimeImmutable('@' . $instant))->setTimezone($timezone), $tzid);
85: }
86: return new self(DateTimeType::Floating, self::wallClock($local), $tzid);
87: }
88:
89: /**
90: * Whether parse() accepts the value, without creating objects.
91: */
92: public static function isValid(string $value, bool $date = false): bool {
93: $value = strtoupper(trim($value));
94: return (bool) preg_match('/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/D', $value, $match, PREG_UNMATCHED_AS_NULL)
95: && checkdate((int) $match[2], (int) $match[3], (int) $match[1])
96: && ($match[4] === null || ((int) $match[4] < 24 && (int) $match[5] < 60 && (int) $match[6] <= 60))
97: && !($date && $match[4] !== null);
98: }
99:
100: public function isDate(): bool {
101: return $this->type === DateTimeType::Date;
102: }
103:
104: public function isFloating(): bool {
105: return $this->type === DateTimeType::Floating;
106: }
107:
108: public function isUtc(): bool {
109: return $this->type === DateTimeType::Utc;
110: }
111:
112: public function isZoned(): bool {
113: return $this->type === DateTimeType::Zoned;
114: }
115:
116: /**
117: * Timezone of a UTC or zoned value.
118: */
119: public function timezone(): ?DateTimeZone {
120: return $this->type === DateTimeType::Utc || $this->type === DateTimeType::Zoned ? $this->dateTime->getTimezone() : null;
121: }
122:
123: /**
124: * Format the local value (see DateTimeInterface::format()) without any conversion.
125: */
126: public function format(string $format): string {
127: return $this->dateTime->format($format);
128: }
129:
130: /**
131: * The instant of the value.
132: *
133: * UTC and zoned values keep their timezone unless one is given. Dates (midnight) and floating
134: * times need a timezone: the given one, or the $default one (e.g. X-WR-TIMEZONE).
135: *
136: * @throws TimezoneResolutionException for a date or floating time without a timezone
137: */
138: public function toDateTime(?DateTimeZone $timezone = null, ?DateTimeZone $default = null): DateTimeImmutable {
139: if ($this->type === DateTimeType::Utc || $this->type === DateTimeType::Zoned) {
140: return $timezone === null ? $this->dateTime : $this->dateTime->setTimezone($timezone);
141: }
142: $timezone ??= $default ?? throw TimezoneResolutionException::create(
143: 'timezone.floating',
144: sprintf('The %s value %s needs a timezone to become an instant.', $this->type === DateTimeType::Date ? 'DATE' : 'floating', $this),
145: );
146: // ambiguous and nonexistent local times as RFC 5545 requires (PHP is not consistent)
147: return (new DateTimeImmutable('@' . LocalTime::timestamp($timezone, $this->dateTime->getTimestamp())))->setTimezone($timezone);
148: }
149:
150: /**
151: * Add a duration: days keep the local time (also across DST), hours are elapsed time
152: * for UTC and zoned values (RFC 5545, section 3.3.6).
153: */
154: public function add(DateInterval $interval): self {
155: return new self($this->type, $this->dateTime->add($interval), $this->tzid);
156: }
157:
158: /**
159: * A value of the same kind at another instant or wall-clock time.
160: *
161: * @internal
162: */
163: public function withDateTime(DateTimeImmutable $dateTime): self {
164: return new self($this->type, match ($this->type) {
165: DateTimeType::Utc, DateTimeType::Zoned => $dateTime->setTimezone($this->dateTime->getTimezone()),
166: default => self::wallClock($dateTime->format('Y-m-d H:i:s')),
167: }, $this->tzid);
168: }
169:
170: /**
171: * Wall-clock time as a UTC DateTimeImmutable: for recurrence expansion of dates and floating times.
172: *
173: * @internal
174: */
175: public function wallClockAsUtc(): DateTimeImmutable {
176: return $this->type === DateTimeType::Zoned || $this->type === DateTimeType::Utc
177: ? self::wallClock($this->dateTime->format('Y-m-d H:i:s'))
178: : $this->dateTime;
179: }
180:
181: /**
182: * The value for recurrence calculations: the instant of UTC and zoned values,
183: * the wall-clock time (as UTC) of dates and floating times.
184: *
185: * @internal
186: */
187: public function base(): DateTimeImmutable {
188: return $this->dateTime;
189: }
190:
191: public function equals(self $other): bool {
192: return $this->type === $other->type && $this->dateTime == $other->dateTime;
193: }
194:
195: /**
196: * The iCalendar value, e.g. "20261010T100000Z" (the TZID parameter is not included).
197: */
198: public function __toString(): string {
199: return match ($this->type) {
200: DateTimeType::Date => $this->dateTime->format('Ymd'),
201: DateTimeType::Utc => $this->dateTime->format('Ymd\THis\Z'),
202: default => $this->dateTime->format('Ymd\THis'),
203: };
204: }
205:
206: private static function wallClock(string $local): DateTimeImmutable {
207: try {
208: return new DateTimeImmutable($local, new DateTimeZone('UTC'));
209: } catch (Exception $e) {
210: throw InvalidValueException::create('value.invalid-date-time', 'Invalid DATE-TIME value: ' . $local, rawValue: $local, previous: $e);
211: }
212: }
213: }
214: