PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-catalog-booking-resources / mutations / class-wpbc-catalog-booking-resources-inline-editor.php

class-wpbc-catalog-booking-resources-inline-editor.php in Booking Calendar 11.9, at includes/page-catalog-booking-resources/mutations/class-wpbc-catalog-booking-resources-inline-editor.php

492 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Independent reviewed inline updates for the template-driven Resource catalog.
4 *
5 * @package Booking Calendar
6 * @since 11.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Build, preview, and apply row-specific drafts without legacy editor services.
15 *
16 * Browser drafts are never trusted as complete Resource records. Every preview
17 * and apply rebuilds its plan from current authorized repository values, and a
18 * signed review binds the normalized replacements to those current values.
19 */
20 final class WPBC_Catalog_Booking_Resources_Inline_Editor {
21
22 /** Maximum number of visible rows accepted from one catalog page. */
23 const MAX_ROWS = 100;
24
25 /** Maximum bytes accepted for one submitted scalar field. */
26 const MAX_FIELD_BYTES = 10000;
27
28 /** @var WPBC_Catalog_Booking_Resources_Repository Independent read repository. */
29 private $repository;
30
31 /** @var WPBC_Catalog_Booking_Resource_Inspector_Schema Current field allow-lists. */
32 private $schema;
33
34 /** @var WPBC_Catalog_Booking_Resource_Updater Independent validated updater. */
35 private $updater;
36
37 /** @var WPBC_Catalog_Booking_Resources_Inline_Fields Domain field provider. */
38 private $inline_fields;
39
40 /**
41 * Initialize independent collaborators.
42 *
43 * @param WPBC_Catalog_Booking_Resources_Repository|null $repository Optional repository for tests.
44 * @param WPBC_Catalog_Booking_Resource_Inspector_Schema|null $schema Optional schema service for tests.
45 * @param WPBC_Catalog_Booking_Resource_Updater|null $updater Optional updater for tests.
46 * @param WPBC_Catalog_Booking_Resources_Inline_Fields|null $inline_fields Optional field provider for tests.
47 */
48 public function __construct( $repository = null, $schema = null, $updater = null, $inline_fields = null ) {
49 $this->repository = $repository instanceof WPBC_Catalog_Booking_Resources_Repository ? $repository : new WPBC_Catalog_Booking_Resources_Repository();
50 $this->schema = $schema instanceof WPBC_Catalog_Booking_Resource_Inspector_Schema ? $schema : new WPBC_Catalog_Booking_Resource_Inspector_Schema();
51 $this->updater = $updater instanceof WPBC_Catalog_Booking_Resource_Updater ? $updater : new WPBC_Catalog_Booking_Resource_Updater();
52 $this->inline_fields = $inline_fields instanceof WPBC_Catalog_Booking_Resources_Inline_Fields ? $inline_fields : new WPBC_Catalog_Booking_Resources_Inline_Fields( $this->schema );
53 }
54
55 /**
56 * Return row-specific controls for the requested visible Resources.
57 *
58 * @param array<int,mixed> $resource_ids Requested Resource IDs in catalog order.
59 * @return array<string,mixed>|WP_Error Client-safe schema or validation error.
60 */
61 public function get_schema( $resource_ids ) {
62 $resources = $this->get_authorized_resources( $resource_ids );
63 if ( is_wp_error( $resources ) ) {
64 return $resources;
65 }
66
67 $rows = array();
68 foreach ( $resources as $resource ) {
69 $rows[] = $this->build_row_schema( $resource );
70 }
71
72 return array(
73 'maximum_rows' => self::MAX_ROWS,
74 'rows' => $rows,
75 );
76 }
77
78 /**
79 * Build a signed, non-mutating review of row-specific drafts.
80 *
81 * @param array<int,mixed> $raw_rows Untrusted row draft envelopes.
82 * @return array<string,mixed>|WP_Error Review contract or validation error.
83 */
84 public function preview( $raw_rows ) {
85 $plan = $this->build_plan( $raw_rows );
86 if ( is_wp_error( $plan ) ) {
87 return $plan;
88 }
89 $review_rows = array();
90 foreach ( $plan['client_changes'] as $change ) {
91 $review_fields = array();
92 foreach ( $change['fields'] as $field ) {
93 $review_fields[] = array(
94 'key' => isset( $field['key'] ) ? sanitize_key( $field['key'] ) : '',
95 'label' => (string) $field['label'],
96 'before' => (string) $field['old'],
97 'after' => (string) $field['new'],
98 );
99 }
100 $review_rows[] = array(
101 'id' => absint( $change['resource_id'] ),
102 'title' => (string) $change['title'],
103 'fields' => $review_fields,
104 'notes' => array(),
105 );
106 }
107
108 return array(
109 'changes' => $plan['client_changes'],
110 'review' => array( 'rows' => $review_rows ),
111 'review_token' => $this->create_review_token( $plan ),
112 );
113 }
114
115 /**
116 * Apply a reviewed plan after rebuilding and revalidating current values.
117 *
118 * Completed rows are compensated in reverse order when a later update fails.
119 *
120 * @param array<int,mixed> $raw_rows Untrusted row draft envelopes.
121 * @param string $review_token Token returned by preview().
122 * @return array<string,mixed>|WP_Error Apply result or safe error.
123 */
124 public function apply( $raw_rows, $review_token ) {
125 $plan = $this->build_plan( $raw_rows, true );
126 if ( is_wp_error( $plan ) ) {
127 return $plan;
128 }
129 $expected_token = $this->create_review_token( $plan );
130 if ( '' === $review_token || ! hash_equals( $expected_token, $review_token ) ) {
131 return new WP_Error( 'wpbc_catalog_inline_review_stale', __( 'One or more Booking Resources changed after the review. Review the inline changes again before applying them.', 'booking' ) );
132 }
133 if ( empty( $plan['rows'] ) ) {
134 return new WP_Error( 'wpbc_catalog_inline_no_changes', __( 'The inline editor does not contain any changes.', 'booking' ) );
135 }
136
137 $saved_rows = array();
138 foreach ( $plan['rows'] as $row_plan ) {
139 $updated = $this->updater->update( absint( $row_plan['resource_id'] ), $row_plan['new_fields'] );
140 if ( is_wp_error( $updated ) ) {
141 $rollback_errors = $this->rollback( $saved_rows );
142 if ( ! empty( $rollback_errors ) ) {
143 return new WP_Error( 'wpbc_catalog_inline_rollback_failed', __( 'The inline update stopped, and one or more completed changes could not be restored. Reload the catalog and verify the affected Booking Resources.', 'booking' ) );
144 }
145
146 return $updated;
147 }
148 $saved_rows[] = array(
149 'resource_id' => absint( $row_plan['resource_id'] ),
150 'fields' => $row_plan['old_fields'],
151 );
152 }
153
154 $updated_ids = array_values( array_map( 'absint', wp_list_pluck( $saved_rows, 'resource_id' ) ) );
155 do_action( 'wpbc_catalog_booking_resources_inline_updated', $updated_ids );
156
157 return array(
158 'updated_ids' => $updated_ids,
159 'updated_count' => count( $updated_ids ),
160 );
161 }
162
163 /**
164 * Build a current-value-bound plan from submitted row drafts.
165 *
166 * @param array<int,mixed> $raw_rows Untrusted row envelopes.
167 * @param bool $allow_no_changes Whether apply may compare an empty rebuilt plan.
168 * @return array<string,mixed>|WP_Error Normalized plan or validation error.
169 */
170 private function build_plan( $raw_rows, $allow_no_changes = false ) {
171 $requested_rows = $this->normalize_requested_rows( $raw_rows );
172 if ( is_wp_error( $requested_rows ) ) {
173 return $requested_rows;
174 }
175 $resources = $this->get_authorized_resources( wp_list_pluck( $requested_rows, 'resource_id' ) );
176 if ( is_wp_error( $resources ) ) {
177 return $resources;
178 }
179 $resources_by_id = array();
180 foreach ( $resources as $resource ) {
181 $resources_by_id[ absint( $resource['id'] ) ] = $resource;
182 }
183
184 $plan_rows = array();
185 $client_changes = array();
186 foreach ( $requested_rows as $requested_row ) {
187 $resource_id = absint( $requested_row['resource_id'] );
188 $resource = isset( $resources_by_id[ $resource_id ] ) ? $resources_by_id[ $resource_id ] : null;
189 if ( null === $resource ) {
190 return new WP_Error( 'wpbc_catalog_inline_resource_unavailable', __( 'One of the Booking Resources is no longer available to this account.', 'booking' ) );
191 }
192
193 $row_schema = $this->build_row_schema( $resource );
194 $fields_by_key = array();
195 foreach ( $row_schema['fields'] as $field ) {
196 $fields_by_key[ $field['key'] ] = $field;
197 }
198
199 $old_fields = $this->get_complete_fields( $resource );
200 $new_fields = $old_fields;
201 $field_changes = array();
202 $has_changes = false;
203 foreach ( $requested_row['fields'] as $field_key => $submitted_value ) {
204 if ( ! isset( $fields_by_key[ $field_key ] ) ) {
205 return new WP_Error( 'wpbc_catalog_inline_field_unsupported', __( 'One of the inline fields is no longer available for this Booking Resource.', 'booking' ) );
206 }
207 $field = $fields_by_key[ $field_key ];
208 $normalized = $this->normalize_field_value( $submitted_value, $field );
209 $current_value = (string) $field['value'];
210 if ( is_wp_error( $normalized ) ) {
211 return $normalized;
212 }
213 if ( (string) $normalized === $current_value ) {
214 continue;
215 }
216 $new_fields[ $field_key ] = $normalized;
217 $has_changes = true;
218 $field_changes[] = array(
219 'key' => $field_key,
220 'label' => $field['label'],
221 'old' => $this->format_value( $current_value, $field ),
222 'new' => $this->format_value( $normalized, $field ),
223 );
224 }
225
226 if ( ! $has_changes ) {
227 continue;
228 }
229 $plan_rows[] = array(
230 'resource_id' => absint( $resource['id'] ),
231 'old_fields' => $old_fields,
232 'new_fields' => $new_fields,
233 );
234 $client_changes[] = array(
235 'resource_id' => absint( $resource['id'] ),
236 'title' => (string) $resource['title'],
237 'fields' => $field_changes,
238 );
239 }
240
241 if ( empty( $plan_rows ) && ! $allow_no_changes ) {
242 return new WP_Error( 'wpbc_catalog_inline_no_changes', __( 'The inline editor does not contain any changes.', 'booking' ) );
243 }
244
245 return array(
246 'rows' => $plan_rows,
247 'client_changes' => $client_changes,
248 );
249 }
250
251 /**
252 * Normalize bounded row envelopes while rejecting duplicates and objects.
253 *
254 * @param array<int,mixed> $raw_rows Untrusted row envelopes.
255 * @return array<int,array<string,mixed>>|WP_Error Normalized rows or error.
256 */
257 private function normalize_requested_rows( $raw_rows ) {
258 if ( ! is_array( $raw_rows ) || empty( $raw_rows ) || count( $raw_rows ) > self::MAX_ROWS ) {
259 return new WP_Error( 'wpbc_catalog_inline_rows_invalid', __( 'The inline Booking Resource changes are invalid.', 'booking' ) );
260 }
261 $normalized_rows = array();
262 $seen_ids = array();
263 foreach ( $raw_rows as $raw_row ) {
264 if ( ! is_array( $raw_row ) || empty( $raw_row['resource_id'] ) || empty( $raw_row['fields'] ) || ! is_array( $raw_row['fields'] ) ) {
265 return new WP_Error( 'wpbc_catalog_inline_row_invalid', __( 'One of the inline Booking Resource rows is invalid.', 'booking' ) );
266 }
267 $resource_id = absint( $raw_row['resource_id'] );
268 if ( ! $resource_id || isset( $seen_ids[ $resource_id ] ) || count( $raw_row['fields'] ) > 5 ) {
269 return new WP_Error( 'wpbc_catalog_inline_row_invalid', __( 'One of the inline Booking Resource rows is invalid.', 'booking' ) );
270 }
271 $normalized_fields = array();
272 foreach ( $raw_row['fields'] as $field_key => $field_value ) {
273 $field_key = sanitize_key( $field_key );
274 if ( '' === $field_key || ! is_scalar( $field_value ) || strlen( (string) $field_value ) > self::MAX_FIELD_BYTES ) {
275 return new WP_Error( 'wpbc_catalog_inline_value_invalid', __( 'One of the inline Booking Resource values is invalid.', 'booking' ) );
276 }
277 $normalized_fields[ $field_key ] = (string) $field_value;
278 }
279 $seen_ids[ $resource_id ] = true;
280 $normalized_rows[] = array( 'resource_id' => $resource_id, 'fields' => $normalized_fields );
281 }
282
283 return $normalized_rows;
284 }
285
286 /**
287 * Load unique authorized Resources while preserving requested order.
288 *
289 * @param array<int,mixed> $resource_ids Requested IDs.
290 * @return array<int,array<string,mixed>>|WP_Error Resources or validation error.
291 */
292 private function get_authorized_resources( $resource_ids ) {
293 $normalized_ids = array();
294 foreach ( is_array( $resource_ids ) ? $resource_ids : array() as $resource_id ) {
295 $resource_id = absint( $resource_id );
296 if ( $resource_id ) {
297 $normalized_ids[ $resource_id ] = $resource_id;
298 }
299 }
300 $normalized_ids = array_values( $normalized_ids );
301 if ( empty( $normalized_ids ) || count( $normalized_ids ) > self::MAX_ROWS ) {
302 return new WP_Error( 'wpbc_catalog_inline_selection_invalid', __( 'The inline Booking Resource selection is invalid.', 'booking' ) );
303 }
304 $resources = array();
305 foreach ( $normalized_ids as $resource_id ) {
306 $resource = $this->repository->get_resource( $resource_id );
307 if ( is_wp_error( $resource ) ) {
308 return $resource;
309 }
310 if ( null === $resource ) {
311 return new WP_Error( 'wpbc_catalog_inline_resource_unavailable', __( 'One of the Booking Resources is no longer available to this account.', 'booking' ) );
312 }
313 $resources[] = $resource;
314 }
315 $shortcodes = $this->repository->get_publishing_shortcodes( $normalized_ids );
316 foreach ( $resources as $resource_index => $resource ) {
317 $resources[ $resource_index ] = $this->prepare_resource( $resource, $shortcodes );
318 }
319
320 return $resources;
321 }
322
323 /**
324 * Attach persisted values that are intentionally absent from list records.
325 *
326 * Inline updates submit a complete updater snapshot. Loading the current
327 * publishing shortcode here prevents an unrelated inline title, cost, or
328 * priority change from replacing a customized shortcode with its fallback.
329 *
330 * @param array<string,mixed> $resource Authorized Resource record.
331 * @param array<int,string> $shortcodes Persisted shortcodes keyed by Resource ID.
332 * @return array<string,mixed> Resource with its persisted shortcode.
333 */
334 private function prepare_resource( $resource, $shortcodes ) {
335 $resource_id = isset( $resource['id'] ) ? absint( $resource['id'] ) : 0;
336
337 $resource['publishing_shortcode'] = isset( $shortcodes[ $resource_id ] )
338 ? (string) $shortcodes[ $resource_id ]
339 : '[booking resource_id=' . $resource_id . ']';
340
341 return $resource;
342 }
343
344 /**
345 * Build executable-free inline controls from the independent inspector schema.
346 *
347 * @param array<string,mixed> $resource Authorized Resource record.
348 * @return array<string,mixed> Client-safe row schema.
349 */
350 private function build_row_schema( $resource ) {
351 return array(
352 'resource_id' => absint( $resource['id'] ),
353 'title' => (string) $resource['title'],
354 'fields' => $this->inline_fields->get_inline_fields( $resource ),
355 );
356 }
357
358 /**
359 * Return the complete validated-updater submission for one Resource.
360 *
361 * Partial inline drafts are merged into this snapshot before persistence so
362 * fields outside the current table view retain their canonical values.
363 *
364 * @param array<string,mixed> $resource Current authorized Resource.
365 * @return array<string,mixed> Complete editable field map.
366 */
367 private function get_complete_fields( $resource ) {
368 $fields = array(
369 'title' => isset( $resource['title'] ) ? (string) $resource['title'] : '',
370 'description' => isset( $resource['description'] ) ? (string) $resource['description'] : '',
371 'picture_url' => isset( $resource['picture_url'] ) ? (string) $resource['picture_url'] : '',
372 );
373 $edit_schema = $this->schema->get_edit_schema( $resource );
374 foreach ( ! is_wp_error( $edit_schema ) && isset( $edit_schema['sections'] ) ? $edit_schema['sections'] : array() as $section ) {
375 foreach ( isset( $section['fields'] ) ? (array) $section['fields'] : array() as $field ) {
376 $field_key = isset( $field['key'] ) ? sanitize_key( $field['key'] ) : '';
377 if ( '' !== $field_key && ! empty( $field['editable'] ) && array_key_exists( 'value', $field ) && is_scalar( $field['value'] ) ) {
378 $fields[ $field_key ] = (string) $field['value'];
379 }
380 }
381 }
382
383 return $fields;
384 }
385
386 /**
387 * Normalize a draft using the server-authoritative field definition.
388 *
389 * @param string $submitted_value Submitted scalar value.
390 * @param array<string,mixed> $field Current field definition.
391 * @return string|WP_Error Normalized value or validation error.
392 */
393 private function normalize_field_value( $submitted_value, $field ) {
394 $field_key = $field['key'];
395 if ( 'title' === $field_key ) {
396 $submitted_value = sanitize_text_field( $submitted_value );
397 if ( '' === $submitted_value || $this->get_text_length( $submitted_value ) > 200 ) {
398 return new WP_Error( 'wpbc_catalog_inline_title_invalid', __( 'Enter a valid Booking Resource title.', 'booking' ) );
399 }
400 return $submitted_value;
401 }
402 if ( 'description' === $field_key ) {
403 $submitted_value = wp_kses_post( $submitted_value );
404 return $this->get_text_length( $submitted_value ) <= 2000 ? $submitted_value : new WP_Error( 'wpbc_catalog_inline_description_invalid', __( 'The Booking Resource description is too long.', 'booking' ) );
405 }
406 if ( 'select' === $field['type'] ) {
407 $allowed_values = array_map( 'strval', wp_list_pluck( $field['options'], 'value' ) );
408 return in_array( (string) $submitted_value, $allowed_values, true ) ? (string) $submitted_value : new WP_Error( 'wpbc_catalog_inline_option_invalid', __( 'Select an available value for this Booking Resource.', 'booking' ) );
409 }
410 if ( 'number' === $field['type'] ) {
411 $submitted_value = str_replace( ',', '.', trim( (string) $submitted_value ) );
412 if ( '' === $submitted_value || ! is_numeric( $submitted_value ) || ! is_finite( (float) $submitted_value ) ) {
413 return new WP_Error( 'wpbc_catalog_inline_number_invalid', __( 'Enter a valid number for this Booking Resource.', 'booking' ) );
414 }
415 if ( isset( $field['min'] ) && (float) $submitted_value < (float) $field['min'] ) {
416 return new WP_Error( 'wpbc_catalog_inline_number_invalid', __( 'Enter a valid number for this Booking Resource.', 'booking' ) );
417 }
418 if ( 'priority' === $field_key && ! preg_match( '/^\d+$/', $submitted_value ) ) {
419 return new WP_Error( 'wpbc_catalog_inline_priority_invalid', __( 'Priority must be a non-negative whole number.', 'booking' ) );
420 }
421 return 'priority' === $field_key ? (string) absint( $submitted_value ) : (string) (float) $submitted_value;
422 }
423
424 return sanitize_text_field( $submitted_value );
425 }
426
427 /**
428 * Format a review value with its field affixes or select label.
429 *
430 * @param string $field_value Normalized value.
431 * @param array<string,mixed> $field Field definition.
432 * @return string Human-readable plain value.
433 */
434 private function format_value( $field_value, $field ) {
435 foreach ( isset( $field['options'] ) ? (array) $field['options'] : array() as $option ) {
436 if ( (string) $option['value'] === (string) $field_value ) {
437 return (string) $option['label'];
438 }
439 }
440 $prefix = isset( $field['prefix'] ) ? (string) $field['prefix'] : '';
441 $suffix = isset( $field['suffix'] ) && '' !== (string) $field['suffix'] ? ' ' . (string) $field['suffix'] : '';
442
443 return trim( $prefix . wp_strip_all_tags( wpbc_lang( (string) $field_value ) ) . $suffix );
444 }
445
446 /**
447 * Sign current old values and normalized replacements for this user.
448 *
449 * @param array<string,mixed> $plan Current inline plan.
450 * @return string Review token.
451 */
452 private function create_review_token( $plan ) {
453 return wp_hash(
454 wp_json_encode(
455 array(
456 'user_id' => get_current_user_id(),
457 'rows' => isset( $plan['rows'] ) ? $plan['rows'] : array(),
458 )
459 ),
460 'nonce'
461 );
462 }
463
464 /**
465 * Restore completed Resources in reverse order after a later failure.
466 *
467 * @param array<int,array<string,mixed>> $saved_rows Completed rows.
468 * @return array<int,string> Rollback errors.
469 */
470 private function rollback( $saved_rows ) {
471 $errors = array();
472 foreach ( array_reverse( $saved_rows ) as $saved_row ) {
473 $restored = $this->updater->update( absint( $saved_row['resource_id'] ), $saved_row['fields'] );
474 if ( is_wp_error( $restored ) ) {
475 $errors[] = $restored->get_error_message();
476 }
477 }
478
479 return $errors;
480 }
481
482 /**
483 * Return string length without requiring the multibyte extension.
484 *
485 * @param string $text Text to measure.
486 * @return int Character or byte length.
487 */
488 private function get_text_length( $text ) {
489 return function_exists( 'mb_strlen' ) ? mb_strlen( (string) $text ) : strlen( (string) $text );
490 }
491 }
492