*/ public static function schema(): array { $slot = [ 'type' => 'object', 'additionalProperties' => false, 'required' => ['id', 'start', 'end', 'nextDay'], 'properties' => [ 'id' => ['type' => 'string', 'pattern' => '^[a-zA-Z0-9_-]{1,64}$'], 'start' => ['type' => 'string'], 'end' => ['type' => 'string'], 'nextDay' => ['type' => 'boolean'], ], ]; return [ 'type' => 'object', 'additionalProperties' => false, 'properties' => [ 'type' => ['type' => 'string', 'enum' => ['continuous', 'daily']], 'startDate' => ['type' => 'string'], 'endDate' => ['type' => 'string'], 'days' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'additionalProperties' => false, 'required' => ['date', 'closed', 'slots'], 'properties' => [ 'date' => ['type' => 'string'], 'closed' => ['type' => 'boolean'], 'slots' => ['type' => 'array', 'items' => $slot], ], ], ], ], ]; } /** @return array */ public static function validate(mixed $input, string $timezone, bool $series = false): array { if ($input === '' || $input === null || $input === []) { return ['type' => 'continuous']; } if (! is_array($input) || ! in_array($input['type'] ?? '', ['continuous', 'daily'], true)) { throw new InvalidArgumentException(__('Choose a valid schedule type.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } if ($input['type'] === 'continuous') { return ['type' => 'continuous']; } if ($series) { throw new InvalidArgumentException(__('Daily schedules are available only for standalone events.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } try { $zone = new DateTimeZone($timezone); } catch (\Exception) { throw new InvalidArgumentException(__('Choose a valid event timezone.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $start = self::date($input['startDate'] ?? '', $zone); $end = self::date($input['endDate'] ?? '', $zone); if ($end < $start) { throw new InvalidArgumentException(__('The schedule end date must not precede its start.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $rows = $input['days'] ?? null; if (! is_array($rows) || count($rows) !== (int) $start->diff($end)->days + 1) { throw new InvalidArgumentException(__('Provide hours or mark Closed for every date in the range.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $days = []; $ids = []; foreach ($rows as $row) { if (! is_array($row)) { throw new InvalidArgumentException(__('Invalid schedule date.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $date = self::date($row['date'] ?? '', $zone); $key = $date->format('Y-m-d'); if ($date < $start || $date > $end || isset($days[$key]) || ! is_bool($row['closed'] ?? null)) { throw new InvalidArgumentException(__('Schedule dates must be unique and inside the selected range.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $slots = $row['slots'] ?? null; if (! is_array($slots) || ($row['closed'] ? $slots !== [] : $slots === [])) { /* translators: %s is a validated ISO calendar date. */ throw new InvalidArgumentException(sprintf(__('Enter at least one time slot for %s, or mark it Closed.', 'proeventswp'), $key)); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $clean = []; foreach ($slots as $slot) { if (! is_array($slot) || ! is_string($slot['id'] ?? null) || preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $slot['id']) !== 1 || isset($ids[strtolower($slot['id'])])) { throw new InvalidArgumentException(__('Each time slot needs a unique ID.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } foreach (['start', 'end'] as $field) { if (! is_string($slot[$field] ?? null) || preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $slot[$field]) !== 1) { /* translators: %s is a validated ISO calendar date. */ throw new InvalidArgumentException(sprintf(__('Enter valid opening and closing times for %s.', 'proeventswp'), $key)); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } } if (! is_bool($slot['nextDay'] ?? null)) { throw new InvalidArgumentException(__('Specify whether the slot ends the next day.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } // Match the projection table's case-insensitive WordPress collation. $ids[strtolower($slot['id'])] = true; $clean[] = array_intersect_key($slot, array_flip(['id', 'start', 'end', 'nextDay'])); } usort($clean, static fn (array $a, array $b): int => strcmp($a['start'], $b['start'])); $days[$key] = ['date' => $key, 'closed' => $row['closed'], 'slots' => $clean]; } ksort($days); $schedule = ['type' => 'daily', 'startDate' => $start->format('Y-m-d'), 'endDate' => $end->format('Y-m-d'), 'days' => array_values($days)]; $intervals = self::intervals($schedule, $timezone); if ($intervals === []) { throw new InvalidArgumentException(__('A daily schedule needs at least one open time slot.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $previous = null; foreach ($intervals as $interval) { $time = $interval['time']; if ($time->end <= $time->start) { throw new InvalidArgumentException(__('Closing must follow opening; use Ends next day for overnight hours.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } if ($previous !== null && $time->start < $previous) { throw new InvalidArgumentException(__('Time slots overlap, including across midnight. Adjust the hours.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $previous = $time->end; } return $schedule; } /** @param array $schedule * @return list */ public static function intervals(array $schedule, string $timezone): array { $result = []; foreach ($schedule['days'] ?? [] as $day) { foreach ($day['slots'] as $slot) { $endDate = new DateTimeImmutable($day['date'], new DateTimeZone($timezone)); if ($slot['nextDay']) { $endDate = $endDate->modify('+1 day'); } try { $time = EventTime::fromLocal($day['date'] . 'T' . $slot['start'], $endDate->format('Y-m-d') . 'T' . $slot['end'], $timezone); } catch (InvalidArgumentException $error) { /* translators: %1$s is the date; %2$s and %3$s are local times; %4$s is the validation error. */ throw new InvalidArgumentException(sprintf(__('Check hours for %1$s (%2$s–%3$s): %4$s Use Ends next day for overnight hours.', 'proeventswp'), $day['date'], $slot['start'], $slot['end'], $error->getMessage())); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $result[] = ['id' => $slot['id'], 'time' => $time]; } } usort($result, static fn (array $a, array $b): int => $a['time']->start <=> $b['time']->start); return $result; } private static function date(mixed $value, DateTimeZone $zone): DateTimeImmutable { $date = is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value) === 1 ? DateTimeImmutable::createFromFormat('!Y-m-d', $value, $zone) : false; if ($date === false || $date->format('Y-m-d') !== $value) { throw new InvalidArgumentException(__('Enter valid schedule start and end dates.', 'proeventswp')); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } return $date; } }