PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Modules / MCP / Support / SlotResolver.php

SlotResolver.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.5.0, at app/Modules/MCP/Support/SlotResolver.php

340 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\App\Hooks\Handlers\TimeSlotServiceHandler;
6 use FluentBooking\App\Models\CalendarSlot;
7
8 defined('ABSPATH') || exit;
9
10 /**
11 * Thin wrapper over the slot engine for the MCP tools.
12 *
13 * An agent must see the same availability as the booking page, so this
14 * computes nothing. It calls the same service the public page uses
15 * (TimeSlotServiceHandler::initService) and only reshapes and trims the
16 * result: slots keyed by date as "HH:MM" strings cost about a tenth of the
17 * engine's per-slot arrays in tokens. See docs/mcp-server-spec.md §10.
18 */
19 class SlotResolver
20 {
21 // Hard ceiling on a slot query, in days.
22 const MAX_RANGE_DAYS = 62;
23
24 /**
25 * Hard ceiling on slots in one response, applied at a whole-date boundary.
26 * At ~8.6 bytes per slot, 600 keeps a response near 1,500 tokens (the
27 * budget in docs/mcp-server-spec.md §10). Truncation is always reported.
28 */
29 const MAX_SLOTS = 600;
30
31 /**
32 * Available slots for an event, keyed by date, in the requested timezone.
33 *
34 * @param CalendarSlot $event
35 * @param string $from 'Y-m-d' in $timezone
36 * @param string $to 'Y-m-d' in $timezone
37 * @param string $timezone resolved IANA identifier
38 * @param int|null $duration minutes; the event default when null
39 * @param int|null $hostId for team events, restrict to one host
40 * @return array|\WP_Error
41 */
42 public static function getSlots(CalendarSlot $event, $from, $to, $timezone, $duration = null, $hostId = null)
43 {
44 if ($event->status !== 'active') {
45 // The slot engine doesn't check status (BookingController does), so
46 // a draft event would otherwise show slots the public page refuses.
47 return [
48 'slots' => [],
49 'reason' => sprintf(
50 /* translators: %s: the event type's current status */
51 __('The event type is "%s", not active, so it accepts no bookings.', 'fluent-booking'),
52 $event->status
53 ),
54 ];
55 }
56
57 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
58
59 if (is_wp_error($service)) {
60 return MCPHelper::error('slot_engine_unavailable', $service->get_error_message());
61 }
62
63 $slots = [];
64 $spotsRemaining = [];
65 $lastError = null;
66
67 // getAvailableSpots() only returns the start date's month (the booking
68 // page renders one month at a time), so walk the range month by month
69 // and merge. MAX_RANGE_DAYS keeps this to at most three calls.
70 $cursor = $from;
71
72 while ($cursor <= $to) {
73 $spots = $service->getAvailableSpots($cursor . ' 00:00:00', $timezone, $duration, $hostId);
74
75 if (is_wp_error($spots)) {
76 // A month past the bookable range says nothing about the others.
77 // Only surface the error if no month yields anything.
78 $lastError = $spots;
79 } else {
80 self::mergeMonth((array) $spots, $from, $to, $slots, $spotsRemaining);
81 }
82
83 $next = gmdate('Y-m-01', strtotime(gmdate('Y-m-01', strtotime($cursor)) . ' +1 month')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
84
85 // Guard against a non-advancing cursor.
86 if ($next <= $cursor) {
87 break;
88 }
89
90 $cursor = $next;
91 }
92
93 if (!$slots && $lastError) {
94 return [
95 'slots' => [],
96 'reason' => $lastError->get_error_message(),
97 ];
98 }
99
100 ksort($slots);
101
102 return self::truncate($slots, $spotsRemaining);
103 }
104
105 /**
106 * Apply MAX_SLOTS at a whole-date boundary, since half a day would read
107 * as a half-booked day.
108 *
109 * @param array $slots
110 * @param array $spotsRemaining
111 * @return array
112 */
113 private static function truncate($slots, $spotsRemaining)
114 {
115 $kept = [];
116 $count = 0;
117 $truncatedAt = '';
118
119 foreach ($slots as $date => $times) {
120 if ($count && ($count + count($times)) > self::MAX_SLOTS) {
121 $truncatedAt = $date;
122 break;
123 }
124
125 $kept[$date] = $times;
126 $count += count($times);
127 }
128
129 $result = ['slots' => $kept];
130
131 if ($spotsRemaining) {
132 $result['spots_remaining'] = array_intersect_key($spotsRemaining, $kept);
133 }
134
135 if ($truncatedAt) {
136 $result['truncated'] = true;
137 $result['truncated_at'] = $truncatedAt;
138 $result['truncation_note'] = sprintf(
139 /* translators: 1: the slot ceiling, 2: the first date omitted from the response */
140 __('Capped at %1$d slots. Dates from %2$s onward are not included — query again starting there.', 'fluent-booking'),
141 self::MAX_SLOTS,
142 $truncatedAt
143 );
144 }
145
146 return $result;
147 }
148
149 /**
150 * Fold one month's engine output into the result, trimmed to the window.
151 * The engine can snap its start back to the 1st, so both bounds are trimmed.
152 *
153 * @param array $spots raw engine output
154 * @param string $from
155 * @param string $to
156 * @param array $slots accumulator, by reference
157 * @param array $spotsRemaining accumulator, by reference
158 */
159 private static function mergeMonth($spots, $from, $to, &$slots, &$spotsRemaining)
160 {
161 foreach ($spots as $date => $daySlots) {
162 if ($date < $from || $date > $to || !$daySlots) {
163 continue;
164 }
165
166 $times = isset($slots[$date]) ? $slots[$date] : [];
167
168 foreach ($daySlots as $slot) {
169 $start = isset($slot['start']) ? $slot['start'] : '';
170
171 if (!$start) {
172 continue;
173 }
174
175 $time = gmdate('H:i', strtotime($start)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
176
177 if (!in_array($time, $times, true)) {
178 $times[] = $time;
179 }
180
181 // `remaining` is false on events that don't track spots (most),
182 // so only build the map when there is something to put in it.
183 if (isset($slot['remaining']) && $slot['remaining'] !== false && $slot['remaining'] !== null) {
184 $spotsRemaining[$date][$time] = (int) $slot['remaining'];
185 }
186 }
187
188 if ($times) {
189 sort($times);
190 $slots[$date] = $times;
191 }
192 }
193 }
194
195 /**
196 * Is one specific slot bookable right now?
197 *
198 * Asks the engine directly rather than scanning getSlots() output, because
199 * write paths rely on this and need the current state.
200 *
201 * @param CalendarSlot $event
202 * @param string $startUtc 'Y-m-d H:i:s' in UTC, already validated by
203 * MCPHelper::toUtc()
204 * @param string $timezone resolved IANA identifier, for the response
205 * @param int|null $duration minutes
206 * @param int|null $hostId
207 * @return array|\WP_Error
208 */
209 public static function checkSlot(CalendarSlot $event, $startUtc, $timezone, $duration = null, $hostId = null)
210 {
211 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
212
213 if (is_wp_error($service)) {
214 return MCPHelper::error('slot_engine_unavailable', $service->get_error_message());
215 }
216
217 $duration = $event->getDuration($duration);
218
219 if ($event->status !== 'active') {
220 return array_merge(
221 [
222 'available' => false,
223 'duration' => (int) $duration,
224 'reason' => sprintf(
225 /* translators: %s: the event type's current status */
226 __('The event type is "%s", not active, so it accepts no bookings.', 'fluent-booking'),
227 $event->status
228 ),
229 ],
230 MCPHelper::timePair($startUtc, $timezone, 'start')
231 );
232 }
233
234 $endUtc = gmdate('Y-m-d H:i:s', strtotime($startUtc) + ($duration * 60)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
235
236 $slot = $service->isSpotAvailable($startUtc, $endUtc, $duration, $hostId);
237
238 return array_merge(
239 [
240 'available' => (bool) $slot,
241 'duration' => (int) $duration,
242 ],
243 MCPHelper::timePair($startUtc, $timezone, 'start'),
244 MCPHelper::timePair($endUtc, $timezone, 'end')
245 );
246 }
247
248 /**
249 * @param CalendarSlot $event
250 * @param mixed $hostId
251 * @return int|null|\WP_Error
252 */
253 public static function validateHostId(CalendarSlot $event, $hostId)
254 {
255 return MCPHelper::resolveEventHost($event, $hostId);
256 }
257
258 /**
259 * Clamp a requested window to something answerable, defaulting to the next
260 * 14 days when the caller gives no bounds.
261 *
262 * @param string $from 'Y-m-d' or empty
263 * @param string $to 'Y-m-d' or empty
264 * @return array|\WP_Error [$from, $to]
265 */
266 public static function resolveRange($from, $to)
267 {
268 // An unparseable date is an error, not a request for the default
269 // window. Matches BookingTools::dateRange().
270 foreach (['from' => $from, 'to' => $to] as $key => $value) {
271 if (self::suppliedDate($value) && !self::normalizeDate($value)) {
272 return MCPHelper::error(
273 'invalid_date',
274 __('from and to must be dates in Y-m-d form.', 'fluent-booking'),
275 ['parameter' => $key, 'received' => is_scalar($value) ? (string) $value : '']
276 );
277 }
278 }
279
280 $from = self::normalizeDate($from);
281 $to = self::normalizeDate($to);
282
283 if (!$from) {
284 $from = gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
285 }
286
287 if (!$to) {
288 $to = gmdate('Y-m-d', strtotime($from . ' +13 days')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
289 }
290
291 if ($to < $from) {
292 return MCPHelper::error(
293 'invalid_range',
294 __('The end of the range is before its start.', 'fluent-booking')
295 );
296 }
297
298 $days = (strtotime($to) - strtotime($from)) / DAY_IN_SECONDS;
299
300 if ($days > self::MAX_RANGE_DAYS) {
301 return MCPHelper::error(
302 'range_too_large',
303 sprintf(
304 /* translators: %d: maximum number of days allowed in one availability query */
305 __('Availability can be queried %d days at a time. Ask for a narrower window.', 'fluent-booking'),
306 self::MAX_RANGE_DAYS
307 ),
308 ['max_days' => self::MAX_RANGE_DAYS]
309 );
310 }
311
312 return [$from, $to];
313 }
314
315 /**
316 * @param mixed $date
317 * @return bool whether the caller supplied anything at all
318 */
319 private static function suppliedDate($date)
320 {
321 return is_string($date) ? trim($date) !== '' : !empty($date);
322 }
323
324 /**
325 * @param mixed $date
326 * @return string 'Y-m-d', or '' when unparseable
327 */
328 private static function normalizeDate($date)
329 {
330 $date = is_string($date) ? trim($date) : '';
331
332 if (!$date) {
333 return '';
334 }
335
336 // Y-m-d only. strtotime() would accept "next tuesday" or "5".
337 return MCPHelper::isRealDate($date) ? $date : '';
338 }
339 }
340