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 / _shared-ui-catalog / class-wpbc-ui-catalog-hierarchy.php

class-wpbc-ui-catalog-hierarchy.php in Booking Calendar 11.9, at includes/_shared-ui-catalog/class-wpbc-ui-catalog-hierarchy.php

358 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared structural hierarchy contracts for 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 * Normalize hierarchy configuration, response state, and domain-neutral nodes.
15 *
16 * This class validates structural relationships only. Catalog repositories and
17 * DTOs remain responsible for domain identity, permissions, business counts,
18 * storage, search policy, sorting, and group-safe pagination.
19 */
20 final class WPBC_UI_Catalog_Hierarchy {
21
22 /**
23 * Maximum length accepted for an opaque hierarchy node identifier.
24 *
25 * @var int
26 */
27 const MAXIMUM_NODE_ID_LENGTH = 191;
28
29 /**
30 * Normalize the optional registered hierarchy mechanics.
31 *
32 * `global` persistence stores only the catalog-wide `all_expanded` Boolean.
33 * Per-node persistence is deliberately not part of this shared contract.
34 *
35 * @param mixed $hierarchy_configuration Candidate registered configuration.
36 * @param bool $is_feature_enabled Whether the hierarchy feature is enabled.
37 *
38 * @return array<string,mixed>|WP_Error Normalized configuration or safe error.
39 */
40 public static function normalize_configuration( $hierarchy_configuration, $is_feature_enabled ) {
41 if ( null === $hierarchy_configuration ) {
42 $hierarchy_configuration = array();
43 }
44 if ( ! is_array( $hierarchy_configuration ) ) {
45 return self::get_error( 'invalid_configuration' );
46 }
47
48 $allowed_keys = array( 'persistence', 'preference_key' );
49 if ( array_diff( array_keys( $hierarchy_configuration ), $allowed_keys ) ) {
50 return self::get_error( 'invalid_configuration' );
51 }
52
53 $persistence = isset( $hierarchy_configuration['persistence'] ) && is_scalar( $hierarchy_configuration['persistence'] )
54 ? sanitize_key( (string) $hierarchy_configuration['persistence'] )
55 : 'none';
56 if ( ! in_array( $persistence, array( 'none', 'global' ), true ) ) {
57 return self::get_error( 'invalid_configuration' );
58 }
59 if ( ! $is_feature_enabled ) {
60 $persistence = 'none';
61 }
62
63 $preference_key = isset( $hierarchy_configuration['preference_key'] ) && is_scalar( $hierarchy_configuration['preference_key'] )
64 ? sanitize_key( (string) $hierarchy_configuration['preference_key'] )
65 : '';
66 if ( 'global' === $persistence && '' === $preference_key ) {
67 return self::get_error( 'invalid_configuration' );
68 }
69
70 return array(
71 'persistence' => $persistence,
72 'preference_key' => 'global' === $persistence ? $preference_key : '',
73 );
74 }
75
76 /**
77 * Normalize the optional catalog-wide disclosure preference.
78 *
79 * JSON scalar support keeps the state compatible with URL-encoded AJAX
80 * requests and the shared preference store. Per-node identifiers are
81 * rejected so domains cannot accidentally create a second persistence model.
82 *
83 * @param mixed $preference_state Candidate JSON string or stored array.
84 *
85 * @return array{all_expanded:?bool}|WP_Error Normalized preference or safe error.
86 */
87 public static function normalize_preference_state( $preference_state ) {
88 if ( '' === $preference_state || null === $preference_state ) {
89 return array(
90 'all_expanded' => null,
91 );
92 }
93 if ( is_scalar( $preference_state ) ) {
94 $preference_state = json_decode( (string) $preference_state, true );
95 }
96 if ( ! is_array( $preference_state ) || array_diff( array_keys( $preference_state ), array( 'all_expanded' ) ) ) {
97 return self::get_error( 'invalid_preference' );
98 }
99
100 $all_expanded = array_key_exists( 'all_expanded', $preference_state ) ? $preference_state['all_expanded'] : null;
101 if ( ! is_bool( $all_expanded ) && null !== $all_expanded ) {
102 return self::get_error( 'invalid_preference' );
103 }
104
105 return array(
106 'all_expanded' => $all_expanded,
107 );
108 }
109
110 /**
111 * Normalize catalog-wide hierarchy response state.
112 *
113 * @param mixed $hierarchy_response Candidate provider response section.
114 * @param bool $is_feature_enabled Whether the registered feature is enabled.
115 *
116 * @return array<string,mixed>|WP_Error Normalized hierarchy state or safe error.
117 */
118 public static function normalize_response( $hierarchy_response, $is_feature_enabled ) {
119 if ( null === $hierarchy_response ) {
120 $hierarchy_response = array();
121 }
122 if ( ! is_array( $hierarchy_response ) ) {
123 return self::get_error( 'malformed_response' );
124 }
125
126 $allowed_keys = array( 'enabled', 'expanded_by_default', 'preference_state' );
127 if ( array_diff( array_keys( $hierarchy_response ), $allowed_keys ) ) {
128 return self::get_error( 'malformed_response' );
129 }
130
131 $is_enabled = array_key_exists( 'enabled', $hierarchy_response )
132 ? $hierarchy_response['enabled']
133 : (bool) $is_feature_enabled;
134 if ( ! is_bool( $is_enabled ) || ( $is_enabled && ! $is_feature_enabled ) ) {
135 return self::get_error( 'malformed_response' );
136 }
137
138 $expanded_by_default = array_key_exists( 'expanded_by_default', $hierarchy_response )
139 ? $hierarchy_response['expanded_by_default']
140 : false;
141 if ( ! is_bool( $expanded_by_default ) ) {
142 return self::get_error( 'malformed_response' );
143 }
144
145 $preference_state = self::normalize_preference_state(
146 isset( $hierarchy_response['preference_state'] )
147 ? $hierarchy_response['preference_state']
148 : array()
149 );
150 if ( is_wp_error( $preference_state ) ) {
151 return self::get_error( 'malformed_response' );
152 }
153
154 return array(
155 'enabled' => $is_enabled,
156 'expanded_by_default' => $expanded_by_default,
157 'preference_state' => $preference_state,
158 );
159 }
160
161 /**
162 * Normalize and validate structural node metadata on catalog items.
163 *
164 * Enabled hierarchy responses must contain a parent-first flat node list.
165 * Requiring parents before descendants makes cycles impossible and lets the
166 * browser apply visibility in one linear pass. Flat responses are returned
167 * unchanged and incur no hierarchy validation or behavior.
168 *
169 * @param array $items JSON-safe normalized catalog items.
170 * @param bool $is_hierarchy_enabled Whether hierarchy is active for this response.
171 *
172 * @return array<int,array<string,mixed>>|WP_Error Normalized items or safe error.
173 */
174 public static function normalize_items( $items, $is_hierarchy_enabled ) {
175 if ( ! is_array( $items ) ) {
176 return self::get_error( 'malformed_items' );
177 }
178 if ( ! $is_hierarchy_enabled ) {
179 return $items;
180 }
181
182 $normalized_items = array();
183 $nodes_by_id = array();
184 $child_counts = array();
185
186 foreach ( $items as $item ) {
187 if ( ! is_array( $item ) || ! isset( $item['hierarchy'] ) || ! is_array( $item['hierarchy'] ) ) {
188 return self::get_error( 'malformed_node' );
189 }
190
191 $node = self::normalize_node( $item['hierarchy'] );
192 if ( is_wp_error( $node ) ) {
193 return $node;
194 }
195 if ( isset( $nodes_by_id[ $node['node_id'] ] ) ) {
196 return self::get_error( 'duplicate_node' );
197 }
198
199 if ( '' !== $node['parent_node_id'] ) {
200 if ( ! isset( $nodes_by_id[ $node['parent_node_id'] ] ) ) {
201 return self::get_error( 'missing_parent' );
202 }
203
204 $parent_node = $nodes_by_id[ $node['parent_node_id'] ];
205 if ( ! $parent_node['is_container'] || $node['depth'] !== $parent_node['depth'] + 1 ) {
206 return self::get_error( 'invalid_relationship' );
207 }
208 $child_counts[ $node['parent_node_id'] ] = isset( $child_counts[ $node['parent_node_id'] ] )
209 ? $child_counts[ $node['parent_node_id'] ] + 1
210 : 1;
211 } elseif ( 0 !== $node['depth'] ) {
212 return self::get_error( 'invalid_relationship' );
213 }
214
215 $item['hierarchy'] = array_merge( $item['hierarchy'], $node );
216 $nodes_by_id[ $node['node_id'] ] = $node;
217 $normalized_items[] = $item;
218 }
219
220 foreach ( $nodes_by_id as $node_id => $node ) {
221 $rendered_child_count = isset( $child_counts[ $node_id ] ) ? $child_counts[ $node_id ] : 0;
222 if (
223 $rendered_child_count !== $node['rendered_children_count']
224 || $node['rendered_children_count'] > $node['children_count']
225 || ( 0 < $rendered_child_count && ! $node['is_container'] )
226 || ( $node['expandable'] && 0 === $rendered_child_count )
227 ) {
228 return self::get_error( 'invalid_child_count' );
229 }
230 }
231
232 return $normalized_items;
233 }
234
235 /**
236 * Normalize one domain-neutral hierarchy node.
237 *
238 * Domain-specific keys already present in the hierarchy array are preserved
239 * by normalize_items(); this method returns only the shared structural keys.
240 *
241 * @param array $node Candidate hierarchy node metadata.
242 *
243 * @return array<string,mixed>|WP_Error Normalized node or safe error.
244 */
245 private static function normalize_node( $node ) {
246 $required_keys = array(
247 'node_id',
248 'parent_node_id',
249 'node_kind',
250 'is_container',
251 'expandable',
252 'children_count',
253 'rendered_children_count',
254 'depth',
255 'position',
256 'is_last_sibling',
257 );
258 if ( array_diff( $required_keys, array_keys( $node ) ) ) {
259 return self::get_error( 'malformed_node' );
260 }
261
262 $node_id = self::normalize_node_id( $node['node_id'], false );
263 $parent_node_id = self::normalize_node_id( $node['parent_node_id'], true );
264 if ( is_wp_error( $node_id ) || is_wp_error( $parent_node_id ) || $node_id === $parent_node_id ) {
265 return self::get_error( 'malformed_node' );
266 }
267
268 $node_kind = is_scalar( $node['node_kind'] ) ? sanitize_key( (string) $node['node_kind'] ) : '';
269 if ( ! in_array( $node_kind, array( 'entity', 'virtual' ), true ) ) {
270 return self::get_error( 'malformed_node' );
271 }
272 if ( ! is_bool( $node['is_container'] ) || ! is_bool( $node['expandable'] ) || ! is_bool( $node['is_last_sibling'] ) ) {
273 return self::get_error( 'malformed_node' );
274 }
275 if ( $node['expandable'] && ! $node['is_container'] ) {
276 return self::get_error( 'invalid_relationship' );
277 }
278
279 $children_count = self::normalize_count( $node['children_count'] );
280 $rendered_children_count = self::normalize_count( $node['rendered_children_count'] );
281 $depth = self::normalize_count( $node['depth'] );
282 $position = self::normalize_count( $node['position'] );
283 if ( is_wp_error( $children_count ) || is_wp_error( $rendered_children_count ) || is_wp_error( $depth ) || is_wp_error( $position ) ) {
284 return self::get_error( 'malformed_node' );
285 }
286
287 return array(
288 'node_id' => $node_id,
289 'parent_node_id' => $parent_node_id,
290 'node_kind' => $node_kind,
291 'is_container' => $node['is_container'],
292 'expandable' => $node['expandable'],
293 'children_count' => $children_count,
294 'rendered_children_count' => $rendered_children_count,
295 'depth' => $depth,
296 'position' => $position,
297 'is_last_sibling' => $node['is_last_sibling'],
298 );
299 }
300
301 /**
302 * Normalize one opaque hierarchy node identifier.
303 *
304 * @param mixed $node_id Candidate identifier.
305 * @param bool $is_empty_valid Whether an empty root-parent identifier is valid.
306 *
307 * @return string|WP_Error Normalized identifier or safe error.
308 */
309 private static function normalize_node_id( $node_id, $is_empty_valid ) {
310 if ( null === $node_id && $is_empty_valid ) {
311 return '';
312 }
313 if ( ! is_scalar( $node_id ) ) {
314 return self::get_error( 'malformed_node' );
315 }
316
317 $node_id = trim( sanitize_text_field( (string) $node_id ) );
318 if ( ( '' === $node_id && ! $is_empty_valid ) || self::MAXIMUM_NODE_ID_LENGTH < strlen( $node_id ) ) {
319 return self::get_error( 'malformed_node' );
320 }
321
322 return $node_id;
323 }
324
325 /**
326 * Normalize one nonnegative integer hierarchy value.
327 *
328 * @param mixed $candidate Candidate count, depth, or position.
329 *
330 * @return int|WP_Error Normalized integer or safe error.
331 */
332 private static function normalize_count( $candidate ) {
333 if ( ! is_scalar( $candidate ) || ! preg_match( '/^\d+$/', (string) $candidate ) ) {
334 return self::get_error( 'malformed_node' );
335 }
336
337 return absint( $candidate );
338 }
339
340 /**
341 * Return a namespaced safe hierarchy error.
342 *
343 * @param string $error_code Short error suffix.
344 *
345 * @return WP_Error Safe hierarchy error.
346 */
347 private static function get_error( $error_code ) {
348 $error_message = 'invalid_preference' === $error_code
349 ? __( 'The catalog hierarchy preference is invalid.', 'booking' )
350 : __( 'The catalog hierarchy response is malformed.', 'booking' );
351
352 return new WP_Error(
353 'wpbc_ui_catalog_hierarchy_' . sanitize_key( $error_code ),
354 $error_message
355 );
356 }
357 }
358