PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
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 10.11 All 204 releases
booking / includes / _shared-ui-catalog / class-wpbc-ui-catalog-registry.php

class-wpbc-ui-catalog-registry.php in Booking Calendar 11.8.4, at includes/_shared-ui-catalog/class-wpbc-ui-catalog-registry.php

585 lines 21.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registry for independent template-driven catalogs.
4 *
5 * @package Booking Calendar
6 * @since 11.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Store validated catalog mechanics, optional providers, and trusted templates.
15 */
16 final class WPBC_UI_Catalog_Registry {
17
18 /**
19 * Shared registry instance.
20 *
21 * @var WPBC_UI_Catalog_Registry|null
22 */
23 private static $instance = null;
24
25 /**
26 * Registered catalog configurations keyed by stable catalog ID.
27 *
28 * @var array
29 */
30 private $configurations = array();
31
32 /**
33 * Optional catalog providers keyed by stable catalog ID.
34 *
35 * @var array
36 */
37 private $providers = array();
38
39 /**
40 * Trusted template files keyed by catalog ID and template ID.
41 *
42 * @var array
43 */
44 private $template_files = array();
45
46 /**
47 * Prevent direct construction.
48 */
49 private function __construct() {}
50
51 /**
52 * Return the request-scoped shared registry.
53 *
54 * @return WPBC_UI_Catalog_Registry Shared registry instance.
55 */
56 public static function get_instance() {
57 if ( null === self::$instance ) {
58 self::$instance = new self();
59 }
60
61 return self::$instance;
62 }
63
64 /**
65 * Register one catalog configuration and optional provider.
66 *
67 * Template paths are trusted code declarations and are never localized to
68 * JavaScript. Duplicate identifiers fail rather than replacing a catalog.
69 *
70 * @param mixed $configuration Raw catalog configuration.
71 * @param WPBC_UI_Catalog_Provider|null $provider Optional domain provider.
72 * @param mixed $template_files Template ID to PHP file map.
73 *
74 * @return true|WP_Error True when registered, otherwise a safe error.
75 */
76 public function register( $configuration, $provider = null, $template_files = array() ) {
77 $configuration = $this->normalize_configuration( $configuration );
78 if ( is_wp_error( $configuration ) ) {
79 return $configuration;
80 }
81
82 $catalog_id = $configuration['id'];
83 if ( isset( $this->configurations[ $catalog_id ] ) ) {
84 return $this->get_error( 'duplicate_catalog', __( 'A catalog with this identifier is already registered.', 'booking' ) );
85 }
86
87 if ( null !== $provider ) {
88 $provider_catalog_id = '';
89 if ( $provider instanceof WPBC_UI_Catalog_Provider ) {
90 $raw_provider_catalog_id = $provider->get_catalog_id();
91 $provider_catalog_id = is_scalar( $raw_provider_catalog_id ) ? sanitize_key( (string) $raw_provider_catalog_id ) : '';
92 }
93 if ( $catalog_id !== $provider_catalog_id ) {
94 return $this->get_error( 'invalid_provider', __( 'The catalog provider is invalid.', 'booking' ) );
95 }
96 }
97
98 $normalized_template_files = $this->normalize_template_files( $template_files );
99 if ( is_wp_error( $normalized_template_files ) ) {
100 return $normalized_template_files;
101 }
102
103 $this->configurations[ $catalog_id ] = $configuration;
104 $this->template_files[ $catalog_id ] = $normalized_template_files;
105 if ( null !== $provider ) {
106 $this->providers[ $catalog_id ] = $provider;
107 }
108
109 return true;
110 }
111
112 /**
113 * Determine whether a catalog identifier is registered.
114 *
115 * @param string $catalog_id Catalog identifier.
116 *
117 * @return bool True when registered.
118 */
119 public function has( $catalog_id ) {
120 $catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : '';
121
122 return isset( $this->configurations[ $catalog_id ] );
123 }
124
125 /**
126 * Return one registered configuration.
127 *
128 * @param string $catalog_id Catalog identifier.
129 *
130 * @return array Registered configuration or an empty array.
131 */
132 public function get_configuration( $catalog_id ) {
133 $catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : '';
134
135 return isset( $this->configurations[ $catalog_id ] ) ? $this->configurations[ $catalog_id ] : array();
136 }
137
138 /**
139 * Return all registered configurations.
140 *
141 * @return array<string,array> Configurations keyed by catalog ID.
142 */
143 public function get_configurations() {
144 return $this->configurations;
145 }
146
147 /**
148 * Return trusted template files for one catalog.
149 *
150 * @param string $catalog_id Catalog identifier.
151 *
152 * @return array<string,string> Template paths keyed by template ID.
153 */
154 public function get_template_files( $catalog_id ) {
155 $catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : '';
156
157 return isset( $this->template_files[ $catalog_id ] ) ? $this->template_files[ $catalog_id ] : array();
158 }
159
160 /**
161 * Ask one registered provider for a normalized response.
162 *
163 * @param string $catalog_id Catalog identifier.
164 * @param WPBC_UI_Catalog_Request $request Validated shared request.
165 *
166 * @return WPBC_UI_Catalog_Response|WP_Error Provider response or safe error.
167 */
168 public function get_response( $catalog_id, $request ) {
169 $catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : '';
170 if ( ! $request instanceof WPBC_UI_Catalog_Request || $catalog_id !== $request->get_catalog_id() ) {
171 return $this->get_error( 'invalid_request', __( 'The catalog request is invalid.', 'booking' ) );
172 }
173
174 if ( ! isset( $this->providers[ $catalog_id ] ) ) {
175 return $this->get_error( 'missing_provider', __( 'The catalog data provider is not available.', 'booking' ) );
176 }
177
178 $response = $this->providers[ $catalog_id ]->get_response( $request );
179 if ( is_wp_error( $response ) || $response instanceof WPBC_UI_Catalog_Response ) {
180 return $response;
181 }
182
183 return $this->get_error( 'invalid_provider_response', __( 'The catalog provider returned an invalid response.', 'booking' ) );
184 }
185
186 /**
187 * Normalize trusted catalog configuration into a stable shared contract.
188 *
189 * @param mixed $configuration Raw configuration.
190 *
191 * @return array|WP_Error Normalized configuration or safe error.
192 */
193 private function normalize_configuration( $configuration ) {
194 if ( ! is_array( $configuration ) || empty( $configuration['id'] ) ) {
195 return $this->get_error( 'invalid_configuration', __( 'The catalog configuration is invalid.', 'booking' ) );
196 }
197
198 $catalog_id = sanitize_key( (string) $configuration['id'] );
199 if ( '' === $catalog_id ) {
200 return $this->get_error( 'invalid_catalog_id', __( 'The catalog identifier is invalid.', 'booking' ) );
201 }
202
203 $page_size_config = isset( $configuration['items_per_page'] ) && is_array( $configuration['items_per_page'] ) ? $configuration['items_per_page'] : array();
204 $page_size_options = $this->normalize_positive_integers( isset( $page_size_config['options'] ) ? $page_size_config['options'] : array() );
205 $maximum_page_size = isset( $page_size_config['maximum'] ) ? max( 1, absint( $page_size_config['maximum'] ) ) : 100;
206 $page_size_options = array_values(
207 array_filter(
208 $page_size_options,
209 static function ( $page_size ) use ( $maximum_page_size ) {
210 return $page_size <= $maximum_page_size;
211 }
212 )
213 );
214 if ( empty( $page_size_options ) ) {
215 return $this->get_error( 'invalid_page_sizes', __( 'The catalog page-size configuration is invalid.', 'booking' ) );
216 }
217
218 $default_page_size = isset( $page_size_config['default'] ) ? absint( $page_size_config['default'] ) : $page_size_options[0];
219 if ( ! in_array( $default_page_size, $page_size_options, true ) ) {
220 return $this->get_error( 'invalid_default_page_size', __( 'The default catalog page size is invalid.', 'booking' ) );
221 }
222
223 $sorting_config = isset( $configuration['sorting'] ) && is_array( $configuration['sorting'] ) ? $configuration['sorting'] : array();
224 $allowed_sorting_keys = $this->normalize_identifiers( isset( $sorting_config['allowed_keys'] ) ? $sorting_config['allowed_keys'] : array() );
225 $default_sorting_key = isset( $sorting_config['default_key'] ) ? sanitize_key( (string) $sorting_config['default_key'] ) : '';
226 $default_sorting_order = isset( $sorting_config['default_order'] ) ? strtolower( (string) $sorting_config['default_order'] ) : '';
227 if ( empty( $allowed_sorting_keys ) || ! in_array( $default_sorting_key, $allowed_sorting_keys, true ) || ! in_array( $default_sorting_order, array( 'asc', 'desc' ), true ) ) {
228 return $this->get_error( 'invalid_sorting', __( 'The catalog sorting configuration is invalid.', 'booking' ) );
229 }
230
231 $column_config = isset( $configuration['columns'] ) && is_array( $configuration['columns'] ) ? $configuration['columns'] : array();
232 $allowed_columns = $this->normalize_identifiers( isset( $column_config['allowed'] ) ? $column_config['allowed'] : array() );
233 $default_visible_columns = $this->normalize_allowed_identifiers(
234 isset( $column_config['default_visible'] ) ? $column_config['default_visible'] : array(),
235 $allowed_columns
236 );
237 $default_column_order = $this->normalize_allowed_identifiers(
238 isset( $column_config['default_order'] ) ? $column_config['default_order'] : array(),
239 $allowed_columns
240 );
241 $required_columns = $this->normalize_allowed_identifiers(
242 isset( $column_config['required'] ) ? $column_config['required'] : array(),
243 $allowed_columns
244 );
245 $column_definitions = $this->normalize_column_definitions(
246 isset( $column_config['definitions'] ) ? $column_config['definitions'] : array(),
247 $allowed_columns
248 );
249 $view_config = isset( $configuration['views'] ) && is_array( $configuration['views'] ) ? $configuration['views'] : array();
250 $view_definitions = $this->normalize_view_definitions(
251 isset( $view_config['definitions'] ) ? $view_config['definitions'] : array(),
252 $allowed_columns
253 );
254 $default_view = isset( $view_config['default'] ) ? sanitize_key( (string) $view_config['default'] ) : '';
255 if ( '' === $default_view || ! isset( $view_definitions[ $default_view ] ) ) {
256 $default_view = ! empty( $view_definitions ) ? (string) key( $view_definitions ) : '';
257 }
258
259 $templates = $this->normalize_template_map( isset( $configuration['templates'] ) ? $configuration['templates'] : array() );
260 $template_packs = $this->normalize_template_packs( isset( $configuration['template_packs'] ) ? $configuration['template_packs'] : array() );
261 $default_template_pack = isset( $configuration['default_template_pack'] ) ? sanitize_key( (string) $configuration['default_template_pack'] ) : '';
262 if (
263 empty( $templates['catalog'] )
264 || empty( $templates['shell'] )
265 || empty( $templates['empty'] )
266 || empty( $templates['error'] )
267 || empty( $template_packs[ $default_template_pack ] )
268 ) {
269 return $this->get_error( 'invalid_templates', __( 'The catalog template configuration is invalid.', 'booking' ) );
270 }
271
272 $features = array();
273 foreach ( isset( $configuration['features'] ) && is_array( $configuration['features'] ) ? $configuration['features'] : array() as $feature_id => $is_enabled ) {
274 $feature_id = is_scalar( $feature_id ) ? sanitize_key( (string) $feature_id ) : '';
275 if ( '' !== $feature_id ) {
276 $features[ $feature_id ] = (bool) $is_enabled;
277 }
278 }
279 $hierarchy_configuration = WPBC_UI_Catalog_Hierarchy::normalize_configuration(
280 isset( $configuration['hierarchy'] ) ? $configuration['hierarchy'] : null,
281 ! empty( $features['hierarchy'] )
282 );
283 if ( is_wp_error( $hierarchy_configuration ) ) {
284 return $hierarchy_configuration;
285 }
286
287 $search_configuration = isset( $configuration['search'] ) && is_array( $configuration['search'] )
288 ? $configuration['search']
289 : array();
290 $search_debounce_delay = isset( $search_configuration['debounce_delay_ms'] ) && is_scalar( $search_configuration['debounce_delay_ms'] )
291 ? absint( $search_configuration['debounce_delay_ms'] )
292 : 300;
293 $search_debounce_delay = min( 2000, $search_debounce_delay );
294 $search_immediate_clear = ! isset( $search_configuration['immediate_clear'] )
295 || (bool) $search_configuration['immediate_clear'];
296
297 $i18n = array();
298 foreach ( isset( $configuration['i18n'] ) && is_array( $configuration['i18n'] ) ? $configuration['i18n'] : array() as $message_id => $message ) {
299 $message_id = is_scalar( $message_id ) ? sanitize_key( (string) $message_id ) : '';
300 if ( '' !== $message_id && is_scalar( $message ) ) {
301 $i18n[ $message_id ] = sanitize_text_field( (string) $message );
302 }
303 }
304
305 $action = isset( $configuration['action'] ) && is_scalar( $configuration['action'] ) ? preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $configuration['action'] ) : '';
306 $nonce_name = isset( $configuration['nonce_name'] ) && is_scalar( $configuration['nonce_name'] ) ? sanitize_key( (string) $configuration['nonce_name'] ) : '';
307 if ( '' === $action || '' === $nonce_name ) {
308 return $this->get_error( 'invalid_transport', __( 'The catalog transport configuration is invalid.', 'booking' ) );
309 }
310
311 $normalized_configuration = array(
312 'id' => $catalog_id,
313 'schema_version' => WPBC_UI_Catalog_Response::SCHEMA_VERSION,
314 'action' => $action,
315 'nonce_name' => $nonce_name,
316 'items_per_page' => array(
317 'default' => $default_page_size,
318 'options' => $page_size_options,
319 'maximum' => $maximum_page_size,
320 ),
321 'sorting' => array(
322 'default_key' => $default_sorting_key,
323 'default_order' => $default_sorting_order,
324 'allowed_keys' => $allowed_sorting_keys,
325 ),
326 'columns' => array(
327 'allowed' => $allowed_columns,
328 'default_visible' => $default_visible_columns,
329 'default_order' => $default_column_order,
330 'required' => $required_columns,
331 'definitions' => $column_definitions,
332 ),
333 'views' => array(
334 'default' => $default_view,
335 'definitions' => $view_definitions,
336 ),
337 'features' => $features,
338 'search' => array(
339 'debounce_delay_ms' => $search_debounce_delay,
340 'immediate_clear' => $search_immediate_clear,
341 ),
342 'hierarchy' => $hierarchy_configuration,
343 'templates' => $templates,
344 'template_packs' => $template_packs,
345 'default_template_pack' => $default_template_pack,
346 'i18n' => $i18n,
347 );
348
349 $default_request = WPBC_UI_Catalog_Request::create( $normalized_configuration );
350
351 return is_wp_error( $default_request ) ? $default_request : $normalized_configuration;
352 }
353
354 /**
355 * Normalize allow-listed column presets used by the generic view selector.
356 *
357 * @param mixed $view_definitions Raw preset definitions keyed by identifier.
358 * @param array $allowed_columns Registered column identifiers.
359 *
360 * @return array<string,array<string,mixed>> Browser-safe preset metadata.
361 */
362 private function normalize_view_definitions( $view_definitions, $allowed_columns ) {
363 $normalized_views = array();
364
365 if ( ! is_array( $view_definitions ) ) {
366 return $normalized_views;
367 }
368
369 foreach ( $view_definitions as $view_id => $view_definition ) {
370 $view_id = is_scalar( $view_id ) ? sanitize_key( (string) $view_id ) : '';
371 if ( '' === $view_id || ! is_array( $view_definition ) ) {
372 continue;
373 }
374
375 $view_fields = $this->normalize_allowed_identifiers(
376 isset( $view_definition['fields'] ) ? $view_definition['fields'] : array(),
377 $allowed_columns
378 );
379 if ( empty( $view_fields ) ) {
380 continue;
381 }
382
383 $normalized_views[ $view_id ] = array(
384 'id' => $view_id,
385 'label' => isset( $view_definition['label'] ) && is_scalar( $view_definition['label'] )
386 ? sanitize_text_field( (string) $view_definition['label'] )
387 : $view_id,
388 'fields' => $view_fields,
389 );
390 }
391
392 return $normalized_views;
393 }
394
395 /**
396 * Normalize presentation metadata for allow-listed catalog columns.
397 *
398 * @param mixed $column_definitions Raw column definitions keyed by ID.
399 * @param array $allowed_columns Registered column identifiers.
400 *
401 * @return array<string,array<string,mixed>> Browser-safe column metadata.
402 */
403 private function normalize_column_definitions( $column_definitions, $allowed_columns ) {
404 $normalized_definitions = array();
405
406 if ( ! is_array( $column_definitions ) ) {
407 return $normalized_definitions;
408 }
409
410 foreach ( $allowed_columns as $column_id ) {
411 $column_definition = isset( $column_definitions[ $column_id ] ) && is_array( $column_definitions[ $column_id ] )
412 ? $column_definitions[ $column_id ]
413 : array();
414 $sort_key = isset( $column_definition['sort_key'] ) && is_scalar( $column_definition['sort_key'] )
415 ? sanitize_key( (string) $column_definition['sort_key'] )
416 : '';
417 $column_label = isset( $column_definition['label'] ) && is_scalar( $column_definition['label'] )
418 ? sanitize_text_field( (string) $column_definition['label'] )
419 : $column_id;
420 $column_class = isset( $column_definition['class'] ) && is_scalar( $column_definition['class'] )
421 ? sanitize_html_class( (string) $column_definition['class'] )
422 : 'column-' . sanitize_html_class( $column_id );
423 $normalized_definitions[ $column_id ] = array(
424 'id' => $column_id,
425 'label' => $column_label,
426 'class' => $column_class,
427 'sort_key' => $sort_key,
428 'required' => ! empty( $column_definition['required'] ),
429 'reorderable' => ! isset( $column_definition['reorderable'] ) || ! empty( $column_definition['reorderable'] ),
430 );
431 }
432
433 return $normalized_definitions;
434 }
435
436 /**
437 * Normalize trusted template file declarations.
438 *
439 * @param mixed $template_files Raw template file map.
440 *
441 * @return array|WP_Error Template paths or safe error.
442 */
443 private function normalize_template_files( $template_files ) {
444 $normalized_template_files = array();
445
446 if ( ! is_array( $template_files ) ) {
447 return $this->get_error( 'invalid_template_files', __( 'The catalog template files are invalid.', 'booking' ) );
448 }
449
450 foreach ( $template_files as $template_id => $template_file ) {
451 $template_id = is_scalar( $template_id ) ? sanitize_key( (string) $template_id ) : '';
452 $template_file = is_scalar( $template_file ) ? (string) $template_file : '';
453 $resolved_file = '' !== $template_file ? realpath( $template_file ) : false;
454 if ( '' === $template_id || false === $resolved_file || 'php' !== strtolower( pathinfo( $resolved_file, PATHINFO_EXTENSION ) ) || ! is_readable( $resolved_file ) ) {
455 return $this->get_error( 'invalid_template_file', __( 'A catalog template file is invalid.', 'booking' ) );
456 }
457 $normalized_template_files[ $template_id ] = $resolved_file;
458 }
459
460 return $normalized_template_files;
461 }
462
463 /**
464 * Normalize template IDs keyed by their presentation role.
465 *
466 * @param mixed $template_map Raw role-to-template map.
467 *
468 * @return array<string,string> Normalized template map.
469 */
470 private function normalize_template_map( $template_map ) {
471 $normalized_templates = array();
472
473 if ( ! is_array( $template_map ) ) {
474 return $normalized_templates;
475 }
476
477 foreach ( $template_map as $template_role => $template_id ) {
478 $template_role = is_scalar( $template_role ) ? sanitize_key( (string) $template_role ) : '';
479 $template_id = is_scalar( $template_id ) ? sanitize_key( (string) $template_id ) : '';
480 if ( '' !== $template_role && '' !== $template_id ) {
481 $normalized_templates[ $template_role ] = $template_id;
482 }
483 }
484
485 return $normalized_templates;
486 }
487
488 /**
489 * Normalize allow-listed presentation packs.
490 *
491 * @param mixed $template_packs Raw template packs.
492 *
493 * @return array<string,array> Normalized template packs.
494 */
495 private function normalize_template_packs( $template_packs ) {
496 $normalized_template_packs = array();
497
498 if ( ! is_array( $template_packs ) ) {
499 return $normalized_template_packs;
500 }
501
502 foreach ( $template_packs as $template_pack_id => $template_map ) {
503 $template_pack_id = is_scalar( $template_pack_id ) ? sanitize_key( (string) $template_pack_id ) : '';
504 $template_map = $this->normalize_template_map( $template_map );
505 if ( '' !== $template_pack_id && ! empty( $template_map ) ) {
506 $normalized_template_packs[ $template_pack_id ] = $template_map;
507 }
508 }
509
510 return $normalized_template_packs;
511 }
512
513 /**
514 * Normalize scalar identifiers.
515 *
516 * @param mixed $identifiers Raw identifiers.
517 *
518 * @return array<int,string> Sanitized unique identifiers.
519 */
520 private function normalize_identifiers( $identifiers ) {
521 $normalized_identifiers = array();
522
523 if ( ! is_array( $identifiers ) ) {
524 return $normalized_identifiers;
525 }
526
527 foreach ( $identifiers as $identifier ) {
528 $identifier = is_scalar( $identifier ) ? sanitize_key( (string) $identifier ) : '';
529 if ( '' !== $identifier && ! in_array( $identifier, $normalized_identifiers, true ) ) {
530 $normalized_identifiers[] = $identifier;
531 }
532 }
533
534 return $normalized_identifiers;
535 }
536
537 /**
538 * Keep valid defaults in their declared order.
539 *
540 * @param mixed $identifiers Raw defaults.
541 * @param array $allowed_identifiers Allowed identifiers.
542 *
543 * @return array<int,string> Valid defaults.
544 */
545 private function normalize_allowed_identifiers( $identifiers, $allowed_identifiers ) {
546 return array_values( array_intersect( $this->normalize_identifiers( $identifiers ), $allowed_identifiers ) );
547 }
548
549 /**
550 * Normalize positive integer configuration values.
551 *
552 * @param mixed $integers Raw integer values.
553 *
554 * @return array<int,int> Positive unique integers.
555 */
556 private function normalize_positive_integers( $integers ) {
557 $normalized_integers = array();
558
559 if ( ! is_array( $integers ) ) {
560 return $normalized_integers;
561 }
562
563 foreach ( $integers as $integer ) {
564 $integer = is_scalar( $integer ) ? absint( $integer ) : 0;
565 if ( 0 < $integer && ! in_array( $integer, $normalized_integers, true ) ) {
566 $normalized_integers[] = $integer;
567 }
568 }
569
570 return $normalized_integers;
571 }
572
573 /**
574 * Create a namespaced registry error.
575 *
576 * @param string $error_code Short error code.
577 * @param string $error_message User-facing error message.
578 *
579 * @return WP_Error Registry error.
580 */
581 private function get_error( $error_code, $error_message ) {
582 return new WP_Error( 'wpbc_ui_catalog_' . sanitize_key( $error_code ), $error_message );
583 }
584 }
585