start->setTimezone(new DateTimeZone('UTC')); } public function endUtc(): DateTimeImmutable { return $this->end->setTimezone(new DateTimeZone('UTC')); } /** Preserve generated UTC instants when a fold has identical local clock readings. */ public static function fromStored(int $eventId, string $start, string $end, string $timezone, bool $firstFold = false): self { $stored = get_post_meta($eventId, '_pcwp_recurrence_time', true); $normalize = static fn (string $value): string => strlen($value) === 16 ? $value . ':00' : $value; if (is_array($stored) && ! ($firstFold && ($stored['rfc'] ?? null) === false) && ($stored['timezone'] ?? '') === $timezone && ($stored['start_local'] ?? '') === $normalize($start) && ($stored['end_local'] ?? '') === $normalize($end)) { try { $zone = new DateTimeZone($timezone); $startUtc = new DateTimeImmutable((string) $stored['start_utc'], new DateTimeZone('UTC')); $endUtc = new DateTimeImmutable((string) $stored['end_utc'], new DateTimeZone('UTC')); if ($startUtc->setTimezone($zone)->format('Y-m-d\TH:i:s') === $normalize($start) && $endUtc->setTimezone($zone)->format('Y-m-d\TH:i:s') === $normalize($end)) { return new self($startUtc->setTimezone($zone), $endUtc->setTimezone($zone), $timezone); } } catch (\Exception) { // A stale or incomplete generated pair falls back to strict editorial validation. } } return self::fromLocal($start, $end, $timezone, $firstFold || (is_array($stored) && ! empty($stored['rfc']))); } private static function parse(string $value, DateTimeZone $zone, bool $firstFold): DateTimeImmutable { $format = strlen($value) === 16 ? '!Y-m-d\TH:i' : '!Y-m-d\TH:i:s'; $date = DateTimeImmutable::createFromFormat($format, $value, $zone); $errors = DateTimeImmutable::getLastErrors(); if ($date === false || (is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { throw new InvalidArgumentException('Invalid local date and time.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $expected = strlen($value) === 16 ? $date->format('Y-m-d\TH:i') : $date->format('Y-m-d\TH:i:s'); if ($expected !== $value) { throw new InvalidArgumentException('The local date and time does not exist in this timezone.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } if ($firstFold) { $wall = (new DateTimeImmutable($date->format('Y-m-d H:i:s'), new DateTimeZone('UTC')))->getTimestamp(); $previousOffset = null; foreach ($zone->getTransitions($date->getTimestamp() - 86400, $date->getTimestamp() + 86400) ?: [] as $transition) { if ($previousOffset !== null && $transition['offset'] < $previousOffset && $wall >= $transition['ts'] + $transition['offset'] && $wall < $transition['ts'] + $previousOffset) { return (new DateTimeImmutable('@' . ($wall - $previousOffset)))->setTimezone($zone); } $previousOffset = $transition['offset']; } } return $date; } }