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 / booking_modes_v3 / class-wpbc-booking-modes-v3-compiler.php

class-wpbc-booking-modes-v3-compiler.php in Booking Calendar 11.9, at includes/booking_modes_v3/class-wpbc-booking-modes-v3-compiler.php

1,276 lines 50.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Focused Booking Modes V3 presentation compiler.
4 *
5 * @package Booking Calendar
6 * @since 11.8.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Compile only the native, sidebar, horizontal, or page-options surface asked for.
15 */
16 final class WPBC_Booking_Modes_V3_Compiler {
17
18 /** @var WPBC_Booking_Modes_V3_Compiler|null */
19 private static $instance = null;
20
21 /** @var array<string,int> */
22 private $compile_counts = array(
23 'native' => 0,
24 'sidebar' => 0,
25 'root' => 0,
26 'horizontal' => 0,
27 'page_options' => 0,
28 );
29
30 /** @var array<string,array> */
31 private $cache = array();
32
33 /**
34 * Prevent direct construction.
35 */
36 private function __construct() {}
37
38 /**
39 * Return the shared request-local compiler.
40 *
41 * @return WPBC_Booking_Modes_V3_Compiler Compiler instance.
42 */
43 public static function get_instance() {
44 if ( null === self::$instance ) {
45 self::$instance = new self();
46 }
47
48 return self::$instance;
49 }
50
51 /**
52 * Compile the WordPress Booking Calendar submenu without collecting page metadata.
53 *
54 * @param array $submenu_rows Source WordPress submenu rows.
55 * @param array $definition Selected validated mode.
56 * @param string $edition_id Exact current edition ID.
57 *
58 * @return array Native submenu rows in configured order plus inherited rows.
59 */
60 public function compile_native( $submenu_rows, $definition, $edition_id ) {
61 $cache_key = 'native|' . $definition['id'] . '|' . $edition_id;
62 if ( isset( $this->cache[ $cache_key ] ) ) {
63 return $this->cache[ $cache_key ];
64 }
65
66 ++$this->compile_counts['native'];
67 $compatibility_menu = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance()->get_native_menu( $definition['id'] );
68 $compiled = array();
69 $source_position = 0;
70 foreach ( array_slice( $submenu_rows, 0, WPBC_Booking_Modes_V3_Definition_Validator::MAX_COLLECTION_ENTRIES ) as $source_row ) {
71 $menu_slug = is_array( $source_row ) && isset( $source_row[2] ) && is_scalar( $source_row[2] ) ? sanitize_key( (string) $source_row[2] ) : '';
72 if ( '' === $menu_slug ) {
73 continue;
74 }
75
76 $definition_options = isset( $definition['wordpress_menu'][ $menu_slug ] ) ? $definition['wordpress_menu'][ $menu_slug ] : array();
77 $compatibility_options = isset( $compatibility_menu[ $menu_slug ] ) ? $compatibility_menu[ $menu_slug ] : array();
78 if ( ! empty( $definition_options ) && ! $this->is_surface_visible( $definition_options, $edition_id ) ) {
79 continue;
80 }
81 if ( isset( $compatibility_options['visible'] ) && false === $compatibility_options['visible'] ) {
82 continue;
83 }
84
85 $menu_row = $source_row;
86 if ( array_key_exists( 'title', $definition_options ) ) {
87 $menu_row[0] = $definition_options['title'];
88 }
89 if ( array_key_exists( 'title', $compatibility_options ) ) {
90 $menu_row[0] = $compatibility_options['title'];
91 }
92 $menu_row['_wpbc_v3_position'] = isset( $compatibility_options['position'] )
93 ? absint( $compatibility_options['position'] )
94 : 10000 + $source_position;
95 $menu_row['_wpbc_v3_sequence'] = $source_position;
96 $compiled[] = $menu_row;
97 ++$source_position;
98 }
99
100 uasort(
101 $compiled,
102 static function ( $first_row, $second_row ) {
103 if ( $first_row['_wpbc_v3_position'] === $second_row['_wpbc_v3_position'] ) {
104 return $first_row['_wpbc_v3_sequence'] === $second_row['_wpbc_v3_sequence'] ? 0 : ( $first_row['_wpbc_v3_sequence'] < $second_row['_wpbc_v3_sequence'] ? -1 : 1 );
105 }
106 return $first_row['_wpbc_v3_position'] < $second_row['_wpbc_v3_position'] ? -1 : 1;
107 }
108 );
109 $compiled = array_values( $compiled );
110 foreach ( $compiled as $row_index => $menu_row ) {
111 unset( $menu_row['_wpbc_v3_position'], $menu_row['_wpbc_v3_sequence'] );
112 $compiled[ $row_index ] = $menu_row;
113 }
114
115 $this->cache[ $cache_key ] = $compiled;
116 return $compiled;
117 }
118
119 /**
120 * Reapply source authority after the released native submenu filter.
121 *
122 * Extensions may change labels, order, or remove rows. They cannot introduce
123 * an unregistered slug or replace its capability, callback, or destination.
124 *
125 * @param array $filtered_rows Rows returned by the released result filter.
126 * @param array $compiled_rows Source-authorized rows before that filter.
127 *
128 * @return array Source-authorized filtered submenu rows.
129 */
130 public function enforce_native_source_constraints( $filtered_rows, $compiled_rows ) {
131 $allowed_rows = array();
132 foreach ( $compiled_rows as $compiled_row ) {
133 if ( is_array( $compiled_row ) && isset( $compiled_row[2] ) && is_scalar( $compiled_row[2] ) ) {
134 $allowed_rows[ sanitize_key( (string) $compiled_row[2] ) ] = $compiled_row;
135 }
136 }
137
138 $normalized = array();
139 $seen = array();
140 foreach ( array_slice( is_array( $filtered_rows ) ? $filtered_rows : array(), 0, WPBC_Booking_Modes_V3_Definition_Validator::MAX_COLLECTION_ENTRIES ) as $filtered_row ) {
141 $menu_slug = is_array( $filtered_row ) && isset( $filtered_row[2] ) && is_scalar( $filtered_row[2] ) ? sanitize_key( (string) $filtered_row[2] ) : '';
142 if ( '' === $menu_slug || isset( $seen[ $menu_slug ] ) || ! isset( $allowed_rows[ $menu_slug ] ) ) {
143 continue;
144 }
145 $menu_row = $allowed_rows[ $menu_slug ];
146 if ( isset( $filtered_row[0] ) && is_scalar( $filtered_row[0] ) ) {
147 $menu_row[0] = wp_strip_all_tags( (string) $filtered_row[0] );
148 }
149 $normalized[] = $menu_row;
150 $seen[ $menu_slug ] = true;
151 }
152
153 return $normalized;
154 }
155
156 /**
157 * Compile the sidebar tree from original contributor metadata.
158 *
159 * @param array $source_navigation Original registered navigation.
160 * @param array $definition Selected validated mode.
161 * @param string $edition_id Exact current edition ID.
162 *
163 * @return array<string,array<string,mixed>> Legacy-compatible presentation tree.
164 */
165 public function compile_sidebar( $source_navigation, $definition, $edition_id ) {
166 $cache_key = 'sidebar|' . $definition['id'] . '|' . $edition_id;
167 if ( isset( $this->cache[ $cache_key ] ) ) {
168 return $this->cache[ $cache_key ];
169 }
170 ++$this->compile_counts['sidebar'];
171 $source_index = WPBC_Booking_Modes_V3_Source_Index::get_instance();
172 $source_index->capture( $source_navigation );
173 $captured_navigation = $source_index->get_navigation();
174 if ( ! empty( $captured_navigation ) ) {
175 $source_navigation = $captured_navigation;
176 }
177 $compatibility_registry = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance();
178 $compatibility_groups = $compatibility_registry->get_groups( $definition['id'] );
179 $reserved_routes = $this->collect_reserved_routes( $definition );
180 $compiled = array();
181
182 foreach ( $definition['sidebar_menu'] as $root_key => $root_options ) {
183 $compatibility_group = isset( $compatibility_groups[ $root_key ] ) ? $compatibility_groups[ $root_key ] : array();
184 if ( ! $this->is_surface_visible( $root_options, $edition_id ) || ( isset( $compatibility_group['visible'] ) && false === $compatibility_group['visible'] ) ) {
185 continue;
186 }
187
188 $root_items = array();
189 if ( isset( $root_options['page_id'] ) ) {
190 $compiled_node = $this->compile_page_node( $root_options['page_id'], $root_options, $definition, $edition_id, $reserved_routes );
191 if ( null !== $compiled_node ) {
192 $root_items = ! empty( $compiled_node['subtabs'] )
193 ? $compiled_node['subtabs']
194 : array( $root_key => $compiled_node );
195 }
196 } else {
197 $source_items = isset( $source_navigation[ $root_key ] ) && is_array( $source_navigation[ $root_key ] ) ? $source_navigation[ $root_key ] : array();
198 if ( isset( $root_options['items'] ) && ! empty( $root_options['items'] ) ) {
199 $root_items = $this->compile_configured_sidebar_nodes( $root_options['items'], $definition, $edition_id, $reserved_routes );
200 }
201
202 foreach ( $source_items as $source_item_key => $source_item ) {
203 $route = array( 'page' => $root_key, 'tab' => $source_item_key, 'subtab' => '' );
204 if ( isset( $root_items[ $source_item_key ] ) || isset( $reserved_routes[ WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route ) ] ) || ! $this->is_source_visible( $source_item ) ) {
205 continue;
206 }
207 $root_items[ $source_item_key ] = $this->prepare_inherited_source_node( $source_item, $route );
208 }
209 }
210
211 if ( ! empty( $root_items ) ) {
212 $compiled[ $root_key ] = $root_items;
213 }
214 }
215
216 foreach ( $source_navigation as $source_root_key => $source_items ) {
217 if ( isset( $definition['sidebar_menu'][ $source_root_key ] ) || isset( $compiled[ $source_root_key ] ) || ! is_array( $source_items ) ) {
218 continue;
219 }
220 $inherited_items = array();
221 foreach ( $source_items as $source_item_key => $source_item ) {
222 $route = array( 'page' => $source_root_key, 'tab' => $source_item_key, 'subtab' => '' );
223 if ( isset( $reserved_routes[ WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route ) ] ) || ! $this->is_source_visible( $source_item ) ) {
224 continue;
225 }
226 $inherited_items[ $source_item_key ] = $this->prepare_inherited_source_node( $source_item, $route );
227 }
228 if ( ! empty( $inherited_items ) ) {
229 $compiled[ $source_root_key ] = $inherited_items;
230 }
231 }
232
233 $compiled = $this->apply_legacy_page_placement_changes( $compiled, $definition, $edition_id );
234 foreach ( $compiled as $root_key => $root_items ) {
235 unset( $root_items );
236 if ( isset( $compatibility_groups[ $root_key ]['visible'] ) && false === $compatibility_groups[ $root_key ]['visible'] ) {
237 unset( $compiled[ $root_key ] );
238 }
239 }
240 foreach ( $compiled as &$compiled_root_nodes ) {
241 if ( is_array( $compiled_root_nodes ) ) {
242 $this->add_sidebar_filter_identities( $compiled_root_nodes );
243 }
244 }
245 unset( $compiled_root_nodes );
246 $source_authorized_compiled = $compiled;
247 $available_canonical_page_ids = $this->get_available_canonical_page_ids();
248
249 /**
250 * Filter the focused V3 sidebar result using the released result hook.
251 *
252 * @param array $compiled Legacy-compatible sidebar tree.
253 * @param string $mode_id Selected mode ID.
254 * @param array $placement_summary Released canonical placement map.
255 * @param array $available_ids Available local definition page IDs.
256 * @param array $context Cached Booking Modes context.
257 */
258 $compiled = apply_filters(
259 'wpbc_booking_modes_resolved_navigation',
260 $compiled,
261 $definition['id'],
262 $compatibility_registry->get_resolved_page_placements( $definition['id'], $available_canonical_page_ids ),
263 $available_canonical_page_ids,
264 wpbc_booking_modes_get_context()
265 );
266 $compiled = is_array( $compiled ) ? $compiled : $source_authorized_compiled;
267 $compiled = $this->enforce_sidebar_source_constraints( $compiled, $source_authorized_compiled );
268 $this->cache[ $cache_key ] = $compiled;
269
270 return $compiled;
271 }
272
273 /**
274 * Compile root section labels/icons/expansion for an already compiled sidebar.
275 *
276 * @param array $legacy_roots Original root section metadata.
277 * @param array $sidebar Compiled sidebar tree.
278 * @param array $definition Selected validated mode.
279 *
280 * @return array<string,array<string,mixed>> Root section metadata.
281 */
282 public function compile_roots( $legacy_roots, $sidebar, $definition ) {
283 $cache_key = 'root|' . $definition['id'];
284 if ( isset( $this->cache[ $cache_key ] ) ) {
285 return $this->cache[ $cache_key ];
286 }
287
288 ++$this->compile_counts['root'];
289 $compiled = array();
290 $compatibility_groups = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance()->get_groups( $definition['id'] );
291 foreach ( $sidebar as $root_key => $root_items ) {
292 $root_options = isset( $definition['sidebar_menu'][ $root_key ] ) ? $definition['sidebar_menu'][ $root_key ] : array();
293 $legacy_root = isset( $legacy_roots[ $root_key ] ) && is_array( $legacy_roots[ $root_key ] ) ? $legacy_roots[ $root_key ] : array();
294 $is_declared_root = isset( $definition['sidebar_menu'][ $root_key ] );
295 $is_registered_root = isset( $legacy_root['type'] ) && 'menu' === $legacy_root['type'];
296 if ( ! $is_declared_root && ! $is_registered_root ) {
297 continue;
298 }
299 $compatibility_group = isset( $compatibility_groups[ $root_key ] ) ? $compatibility_groups[ $root_key ] : array();
300 $first_item = is_array( $root_items ) ? reset( $root_items ) : array();
301 $source_root = $this->get_source_root_node( $root_options, $definition );
302 $presentation_sources = array( $root_options, $compatibility_group, $legacy_root, $source_root, $first_item );
303 $title = $this->resolve_root_presentation_value( 'title', $presentation_sources, $root_key );
304 $font_icon = $this->resolve_root_presentation_value( 'font_icon', $presentation_sources, '' );
305 $compiled[ $root_key ] = array(
306 'type' => 'menu',
307 'title' => $title,
308 'font_icon' => $font_icon,
309 'mode_font_icon' => $font_icon,
310 'mode_expanded' => isset( $root_options['expanded'] ) ? $root_options['expanded'] : 'On',
311 'mode_standalone' => isset( $root_options['page_id'] ) && 1 === count( $root_items ) && is_array( $first_item ) && empty( $first_item['subtabs'] ),
312 );
313 $compiled[ $root_key ]['_wpbc_v3_position'] = isset( $compatibility_group['position'] ) ? absint( $compatibility_group['position'] ) : 10000 + count( $compiled );
314 }
315 uasort(
316 $compiled,
317 static function ( $first_root, $second_root ) {
318 if ( $first_root['_wpbc_v3_position'] === $second_root['_wpbc_v3_position'] ) {
319 return 0;
320 }
321
322 return $first_root['_wpbc_v3_position'] < $second_root['_wpbc_v3_position'] ? -1 : 1;
323 }
324 );
325 foreach ( $compiled as $root_key => $root_definition ) {
326 unset( $root_definition['_wpbc_v3_position'] );
327 $compiled[ $root_key ] = $root_definition;
328 }
329 $source_authorized_roots = $compiled;
330
331 /**
332 * Filter V3 root presentation using the released hook signature.
333 *
334 * @param array $compiled Root navigation.
335 * @param string $mode_id Selected mode ID.
336 * @param array $sidebar Compiled sidebar tree.
337 */
338 $compiled = apply_filters( 'wpbc_booking_modes_resolved_root_navigation', $compiled, $definition['id'], $sidebar );
339 $compiled = is_array( $compiled ) ? $compiled : $source_authorized_roots;
340 $compiled = $this->enforce_root_constraints( $compiled, $sidebar, $definition, $legacy_roots );
341 $this->cache[ $cache_key ] = $compiled;
342
343 return $compiled;
344 }
345
346 /**
347 * Compile only the horizontal host currently being rendered.
348 *
349 * @param array $source_navigation Original navigation tree.
350 * @param array $definition Selected validated mode.
351 * @param string $edition_id Exact current edition ID.
352 * @param string $active_page Current page slug.
353 * @param string $active_tab Current tab slug.
354 * @param string $active_subtab Current subtab slug.
355 *
356 * @return array Legacy-compatible tree with only the current host replaced.
357 */
358 public function compile_horizontal( $source_navigation, $definition, $edition_id, $active_page, $active_tab, $active_subtab ) {
359 $cache_key = 'horizontal|' . $definition['id'] . '|' . $edition_id . '|' . $active_page . '|' . $active_tab . '|' . $active_subtab;
360 if ( isset( $this->cache[ $cache_key ] ) ) {
361 return $this->cache[ $cache_key ];
362 }
363
364 ++$this->compile_counts['horizontal'];
365 $source_index = WPBC_Booking_Modes_V3_Source_Index::get_instance();
366 $source_index->capture( $source_navigation );
367 $captured_navigation = $source_index->get_navigation();
368 if ( ! empty( $captured_navigation ) ) {
369 $source_navigation = $captured_navigation;
370 }
371 $host_page_id = $this->find_page_id_for_route( $definition, $active_page, $active_tab, $active_subtab );
372 if ( '' === $host_page_id || ! isset( $source_navigation[ $active_page ] ) ) {
373 $this->cache[ $cache_key ] = $source_navigation;
374 return $source_navigation;
375 }
376
377 $compiled_host = array();
378 $reserved_routes = array();
379 $has_overrides = false;
380 foreach ( $definition['pages'] as $page_id => $page_definition ) {
381 if ( ! isset( $page_definition['horizontal_menu'] ) ) {
382 continue;
383 }
384 $horizontal = $page_definition['horizontal_menu'];
385 $show_here = isset( $horizontal['show_on_pages'] )
386 ? in_array( $host_page_id, $horizontal['show_on_pages'], true )
387 : $page_definition['route']['page'] === $active_page;
388 if ( ! $show_here ) {
389 continue;
390 }
391
392 $has_overrides = true;
393 $route_key = WPBC_Booking_Modes_V3_Source_Index::get_route_key( $page_definition['route'] );
394 $reserved_routes[ $route_key ] = true;
395 if ( ! $this->is_surface_visible( $horizontal, $edition_id ) ) {
396 continue;
397 }
398 $record = WPBC_Booking_Modes_V3_Source_Index::get_instance()->get_record( $page_definition['route'] );
399 if ( null === $record ) {
400 continue;
401 }
402
403 $node = $record['node'];
404 if ( array_key_exists( 'title', $horizontal ) ) {
405 $node['title'] = $horizontal['title'];
406 }
407 if ( array_key_exists( 'font_icon', $horizontal ) ) {
408 $node['font_icon'] = $horizontal['font_icon'];
409 }
410 $node['top_navigation_hidden'] = false;
411 $node['is_active'] = $page_definition['route']['page'] === $active_page
412 && $page_definition['route']['tab'] === $active_tab
413 && ( '' === $page_definition['route']['subtab'] || $page_definition['route']['subtab'] === $active_subtab );
414 $node_key = isset( $compiled_host[ $record['tab'] ] ) ? $page_id : $record['tab'];
415 $compiled_host[ $node_key ] = $node;
416 }
417
418 if ( ! $has_overrides ) {
419 $this->cache[ $cache_key ] = $source_navigation;
420 return $source_navigation;
421 }
422
423 foreach ( $source_navigation[ $active_page ] as $tab_key => $source_node ) {
424 $route = array( 'page' => $active_page, 'tab' => $tab_key, 'subtab' => '' );
425 if ( ! isset( $reserved_routes[ WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route ) ] ) && $this->is_source_visible( $source_node ) ) {
426 $compiled_host[ $tab_key ] = $source_node;
427 }
428 }
429
430 $compiled_navigation = $source_navigation;
431 $compiled_navigation[ $active_page ] = $compiled_host;
432 $this->cache[ $cache_key ] = $compiled_navigation;
433
434 return $compiled_navigation;
435 }
436
437 /**
438 * Apply current-route page options without changing controller callbacks.
439 *
440 * @param array $current_page_params Original tab/subtab parameters.
441 * @param array $definition Selected validated mode.
442 * @param string $page_slug Current page slug.
443 * @param string $tab_slug Current tab slug.
444 * @param string $subtab_slug Current subtab slug.
445 *
446 * @return array Updated presentation parameters.
447 */
448 public function apply_page_options( $current_page_params, $definition, $page_slug, $tab_slug, $subtab_slug ) {
449 ++$this->compile_counts['page_options'];
450 $page_id = $this->find_page_id_for_route( $definition, $page_slug, $tab_slug, $subtab_slug );
451 if ( '' === $page_id || empty( $definition['pages'][ $page_id ]['page_options'] ) ) {
452 return $current_page_params;
453 }
454
455 $target_key = '' !== $subtab_slug && isset( $current_page_params['subtab'] ) ? 'subtab' : 'tab';
456 $current_page_params[ $target_key ] = array_merge(
457 isset( $current_page_params[ $target_key ] ) && is_array( $current_page_params[ $target_key ] ) ? $current_page_params[ $target_key ] : array(),
458 $definition['pages'][ $page_id ]['page_options']
459 );
460
461 return $current_page_params;
462 }
463
464 /**
465 * Return per-surface compiler invocation counts for development tests.
466 *
467 * @return array<string,int> Invocation counts.
468 */
469 public function get_compile_counts() {
470 return $this->compile_counts;
471 }
472
473 /**
474 * Resolve the exact current product edition identifier.
475 *
476 * @return string One of the six V3 edition IDs.
477 */
478 public static function get_current_edition_id() {
479 $version_type = function_exists( 'wpbc_get_version_type__and_mu' ) ? wpbc_get_version_type__and_mu() : 'free';
480 $edition_map = array(
481 'free' => 'free',
482 'personal' => 'personal',
483 'biz_s' => 'business_small',
484 'biz_m' => 'business_medium',
485 'biz_l' => 'business_large',
486 'multiuser' => 'multiuser',
487 );
488
489 return isset( $edition_map[ $version_type ] ) ? $edition_map[ $version_type ] : 'free';
490 }
491
492 /**
493 * Compile one configured local page from indexed source metadata.
494 *
495 * @param string $page_id Local definition page ID.
496 * @param array $options Sidebar node overrides.
497 * @param array $definition Selected mode.
498 * @param string $edition_id Exact edition.
499 * @param array $reserved_routes Reserved explicit source placements.
500 *
501 * @return array|null Prepared source node, or null when unavailable/hidden.
502 */
503 private function compile_page_node( $page_id, $options, $definition, $edition_id, $reserved_routes ) {
504 if ( ! isset( $definition['pages'][ $page_id ] ) || ! $this->is_surface_visible( $options, $edition_id ) ) {
505 return null;
506 }
507
508 $page_definition = $definition['pages'][ $page_id ];
509 $record = WPBC_Booking_Modes_V3_Source_Index::get_instance()->get_record( $page_definition['route'] );
510 if ( null === $record ) {
511 return null;
512 }
513
514 $node = $record['node'];
515 $node['_wpbc_v3_source_route'] = $page_definition['route'];
516 $node['_wpbc_v3_source_identity'] = WPBC_Booking_Modes_V3_Source_Index::get_route_key( $page_definition['route'] );
517 if ( array_key_exists( 'title', $options ) ) {
518 $node['title'] = $options['title'];
519 }
520 foreach ( array( 'font_icon', 'font_icon_right' ) as $icon_key ) {
521 if ( array_key_exists( $icon_key, $options ) ) {
522 $node[ $icon_key ] = $options[ $icon_key ];
523 }
524 }
525
526 $has_declared_children = isset( $options['items'] ) && ! empty( $options['items'] );
527 $has_source_children = ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] );
528 if ( $has_declared_children || $has_source_children ) {
529 $node['subtabs'] = $this->compile_source_children( $record, $options, $definition, $edition_id, $reserved_routes );
530 }
531 if ( ! empty( $node['subtabs'] ) ) {
532 $node['is_active'] = ! empty( $node['is_active'] ) || $this->has_active_descendant( $node['subtabs'] );
533 $node['_wpbc_v3_expanded'] = isset( $options['expanded'] ) ? $options['expanded'] : 'when_active';
534 if ( ! empty( $node['css_classes'] ) ) {
535 $node['_wpbc_v3_branch_css_classes'] = $node['css_classes'];
536 $node['style'] = isset( $node['style'] ) ? rtrim( $node['style'], '; ' ) . ';order:initial;' : 'order:initial;';
537 }
538 }
539
540 return $node;
541 }
542
543 /**
544 * Compile or inherit a source-linked parent's own children.
545 *
546 * @param array $record Indexed parent record.
547 * @param array $options Sidebar overrides.
548 * @param array $definition Selected mode.
549 * @param string $edition_id Exact edition.
550 * @param array $reserved_routes Reserved source identities.
551 *
552 * @return array<string,array<string,mixed>> Prepared children.
553 */
554 private function compile_source_children( $record, $options, $definition, $edition_id, $reserved_routes ) {
555 $source_children = isset( $record['node']['subtabs'] ) && is_array( $record['node']['subtabs'] ) ? $record['node']['subtabs'] : array();
556 if ( ! isset( $options['items'] ) || empty( $options['items'] ) ) {
557 return $this->prepare_inherited_source_children( $source_children, $record );
558 }
559
560 $compiled = $this->compile_configured_sidebar_nodes( $options['items'], $definition, $edition_id, $reserved_routes );
561
562 foreach ( $source_children as $child_key => $source_child ) {
563 $route = array( 'page' => $record['page'], 'tab' => $record['tab'], 'subtab' => $child_key );
564 if ( ! isset( $compiled[ $child_key ] ) && ! isset( $reserved_routes[ WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route ) ] ) && $this->is_source_visible( $source_child ) ) {
565 $compiled[ $child_key ] = $this->prepare_inherited_source_node( $source_child, $route );
566 }
567 }
568
569 return $compiled;
570 }
571
572 /**
573 * Collect every explicit source placement, including hidden nodes.
574 *
575 * @param array $definition Selected mode.
576 *
577 * @return array<string,bool> Route-key reservation map.
578 */
579 private function collect_reserved_routes( $definition ) {
580 $reserved = array();
581 $walk = function ( $nodes ) use ( &$walk, &$reserved, $definition ) {
582 foreach ( $nodes as $node ) {
583 if ( isset( $node['page_id'] ) && isset( $definition['pages'][ $node['page_id'] ] ) ) {
584 $reserved[ WPBC_Booking_Modes_V3_Source_Index::get_route_key( $definition['pages'][ $node['page_id'] ]['route'] ) ] = true;
585 }
586 if ( isset( $node['items'] ) && is_array( $node['items'] ) ) {
587 $walk( $node['items'] );
588 }
589 }
590 };
591 $walk( $definition['sidebar_menu'] );
592
593 return $reserved;
594 }
595
596 /**
597 * Determine whether a configured surface record is visible in this edition.
598 *
599 * @param array $options Validated surface options.
600 * @param string $edition_id Exact edition ID.
601 *
602 * @return bool True when no explicit presentation suppression applies.
603 */
604 private function is_surface_visible( $options, $edition_id ) {
605 if ( array_key_exists( 'visible', $options ) && false === $options['visible'] ) {
606 return false;
607 }
608 if ( array_key_exists( 'editions', $options ) && ! in_array( $edition_id, $options['editions'], true ) ) {
609 return false;
610 }
611
612 return true;
613 }
614
615 /**
616 * Preserve source visibility flags for declaration-omitted inherited nodes.
617 *
618 * Explicit declaration entries may intentionally expose a source node whose
619 * original horizontal/sidebar renderer marked it hidden. The source record
620 * must still exist, so this helper is used only for automatic inheritance.
621 *
622 * @param mixed $source_node Registered source node.
623 *
624 * @return bool True when the source node remains presentable.
625 */
626 private function is_source_visible( $source_node ) {
627 return is_array( $source_node ) && empty( $source_node['disabled'] ) && empty( $source_node['hided'] );
628 }
629
630 /**
631 * Keep inherited source metadata while applying V3 branch-only layout state.
632 *
633 * Source CSS ordering classes belong to the complete folder wrapper in the
634 * recursive/sidebar context. The child heading retains every other source
635 * class and receives an inline neutral order so its children cannot precede it.
636 *
637 * @param array $source_node Original source node.
638 *
639 * @return array Prepared inherited node.
640 */
641 private function prepare_inherited_source_node( $source_node, $route = array(), $source_identity = '' ) {
642 if ( ! empty( $route ) ) {
643 $source_node['_wpbc_v3_source_route'] = $route;
644 $source_identity = WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route );
645 }
646 if ( '' !== $source_identity ) {
647 $source_node['_wpbc_v3_source_identity'] = $source_identity;
648 }
649 if ( empty( $source_node['subtabs'] ) || ! is_array( $source_node['subtabs'] ) ) {
650 return $source_node;
651 }
652
653 $prepared_children = array();
654 foreach ( $source_node['subtabs'] as $child_key => $source_child ) {
655 if ( ! $this->is_source_visible( $source_child ) ) {
656 continue;
657 }
658 $child_route = array();
659 if ( isset( $route['page'], $route['tab'] ) && empty( $route['subtab'] ) ) {
660 $child_route = array( 'page' => $route['page'], 'tab' => $route['tab'], 'subtab' => $child_key );
661 }
662 $child_identity = $source_identity . '>' . strlen( (string) $child_key ) . ':' . (string) $child_key;
663 $prepared_children[ $child_key ] = $this->prepare_inherited_source_node( $source_child, $child_route, $child_identity );
664 }
665 $source_node['subtabs'] = $prepared_children;
666 if ( empty( $prepared_children ) ) {
667 return $source_node;
668 }
669
670 $source_node['_wpbc_v3_expanded'] = 'when_active';
671 $source_node['is_active'] = ! empty( $source_node['is_active'] ) || $this->has_active_descendant( $prepared_children );
672 if ( ! empty( $source_node['css_classes'] ) ) {
673 $source_node['_wpbc_v3_branch_css_classes'] = $source_node['css_classes'];
674 $source_node['style'] = isset( $source_node['style'] )
675 ? rtrim( $source_node['style'], '; ' ) . ';order:initial;'
676 : 'order:initial;';
677 }
678
679 return $source_node;
680 }
681
682 /**
683 * Compile an ordered map of explicit source-linked and pure folder nodes.
684 *
685 * @param array $configured_nodes Sidebar node declarations.
686 * @param array $definition Selected mode declaration.
687 * @param string $edition_id Exact current edition ID.
688 * @param array $reserved_routes Explicit source reservations.
689 *
690 * @return array<string,array<string,mixed>> Prepared visible nodes.
691 */
692 private function compile_configured_sidebar_nodes( $configured_nodes, $definition, $edition_id, $reserved_routes ) {
693 $compiled_nodes = array();
694 foreach ( $configured_nodes as $node_key => $node_options ) {
695 if ( ! $this->is_surface_visible( $node_options, $edition_id ) ) {
696 continue;
697 }
698 if ( isset( $node_options['page_id'] ) ) {
699 $compiled_node = $this->compile_page_node( $node_options['page_id'], $node_options, $definition, $edition_id, $reserved_routes );
700 } else {
701 $compiled_node = $this->compile_pure_folder_node( $node_options, $definition, $edition_id, $reserved_routes );
702 }
703 if ( null !== $compiled_node ) {
704 $compiled_nodes[ $node_key ] = $compiled_node;
705 }
706 }
707
708 return $compiled_nodes;
709 }
710
711 /**
712 * Compile a source-less nested folder using only validated presentation data.
713 *
714 * @param array $options Pure folder declaration.
715 * @param array $definition Selected mode declaration.
716 * @param string $edition_id Exact current edition ID.
717 * @param array $reserved_routes Explicit source reservations.
718 *
719 * @return array<string,mixed>|null Prepared folder, or null when empty.
720 */
721 private function compile_pure_folder_node( $options, $definition, $edition_id, $reserved_routes ) {
722 if ( empty( $options['title'] ) || empty( $options['items'] ) ) {
723 return null;
724 }
725
726 $children = $this->compile_configured_sidebar_nodes( $options['items'], $definition, $edition_id, $reserved_routes );
727 if ( empty( $children ) ) {
728 return null;
729 }
730
731 $node = array(
732 'title' => $options['title'],
733 'url' => '',
734 'disabled' => false,
735 'hided' => false,
736 'is_active' => $this->has_active_descendant( $children ),
737 'subtabs' => $children,
738 '_wpbc_v3_expanded' => isset( $options['expanded'] ) ? $options['expanded'] : 'when_active',
739 '_wpbc_v3_pure_folder' => true,
740 );
741 foreach ( array( 'font_icon', 'font_icon_right' ) as $icon_key ) {
742 if ( array_key_exists( $icon_key, $options ) ) {
743 $node[ $icon_key ] = $options[ $icon_key ];
744 }
745 }
746
747 return $node;
748 }
749
750 /**
751 * Prepare source children while retaining their exact registered order.
752 *
753 * @param array $source_children Source child nodes.
754 * @param array $parent_record Indexed source parent.
755 *
756 * @return array<string,array<string,mixed>> Eligible inherited children.
757 */
758 private function prepare_inherited_source_children( $source_children, $parent_record ) {
759 $prepared_children = array();
760 foreach ( $source_children as $child_key => $source_child ) {
761 if ( ! $this->is_source_visible( $source_child ) ) {
762 continue;
763 }
764 $route = array( 'page' => $parent_record['page'], 'tab' => $parent_record['tab'], 'subtab' => $child_key );
765 $prepared_children[ $child_key ] = $this->prepare_inherited_source_node( $source_child, $route );
766 }
767
768 return $prepared_children;
769 }
770
771 /**
772 * Check recursive active state without changing source callbacks or URLs.
773 *
774 * @param array $nodes Prepared child node map.
775 *
776 * @return bool True when any descendant is active.
777 */
778 private function has_active_descendant( $nodes ) {
779 foreach ( $nodes as $node ) {
780 if ( ! empty( $node['is_active'] ) ) {
781 return true;
782 }
783 if ( ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] ) && $this->has_active_descendant( $node['subtabs'] ) ) {
784 return true;
785 }
786 }
787
788 return false;
789 }
790
791 /**
792 * Apply bounded changes made through the released legacy placement hooks.
793 *
794 * @param array $compiled Source-authorized V3 sidebar.
795 * @param array $definition Selected compiler definition.
796 * @param string $edition_id Exact current edition ID.
797 *
798 * @return array Sidebar with compatible label, removal, order, and placement changes.
799 */
800 private function apply_legacy_page_placement_changes( $compiled, $definition, $edition_id ) {
801 $compatibility_registry = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance();
802 if ( null === $compatibility_registry->get( $definition['id'] ) ) {
803 return $compiled;
804 }
805
806 $available_page_ids = $this->get_available_canonical_page_ids();
807 $change_set = $compatibility_registry->get_page_placement_changes( $definition['id'], $available_page_ids );
808 foreach ( $change_set['removed'] as $canonical_page_id ) {
809 $canonical_page = WPBC_Booking_Modes_V3_Page_Registry::get_instance()->get( $canonical_page_id );
810 if ( is_array( $canonical_page ) ) {
811 $this->remove_sidebar_source_identity( $compiled, WPBC_Booking_Modes_V3_Source_Index::get_route_key( $canonical_page ) );
812 }
813 }
814
815 foreach ( $change_set['placements'] as $canonical_page_id => $placement ) {
816 $canonical_page = WPBC_Booking_Modes_V3_Page_Registry::get_instance()->get( $canonical_page_id );
817 if ( ! is_array( $canonical_page ) ) {
818 continue;
819 }
820 $route = array( 'page' => $canonical_page['page'], 'tab' => $canonical_page['tab'], 'subtab' => $canonical_page['subtab'] );
821 $source_identity = WPBC_Booking_Modes_V3_Source_Index::get_route_key( $route );
822 $this->remove_sidebar_source_identity( $compiled, $source_identity );
823 if ( empty( $placement['visible'] ) || $this->is_route_explicitly_suppressed( $source_identity, $definition, $edition_id ) ) {
824 continue;
825 }
826
827 $record = WPBC_Booking_Modes_V3_Source_Index::get_instance()->get_record( $route );
828 if ( null === $record || ! $this->is_source_visible( $record['node'] ) ) {
829 continue;
830 }
831 $node = $this->prepare_inherited_source_node( $record['node'], $route );
832 foreach ( array( 'title', 'font_icon', 'font_icon_right' ) as $presentation_key ) {
833 if ( array_key_exists( $presentation_key, $placement ) ) {
834 $node[ $presentation_key ] = $placement[ $presentation_key ];
835 }
836 }
837 $node['_wpbc_v3_position'] = isset( $placement['position'] ) ? absint( $placement['position'] ) : 1000;
838 $target_group = ! empty( $placement['group'] ) ? $placement['group'] : $route['page'];
839 $navigation_key = ! empty( $placement['navigation_key'] ) ? $placement['navigation_key'] : ( '' !== $route['subtab'] ? $route['subtab'] : $route['tab'] );
840
841 if ( '' !== $route['subtab'] && $target_group === $route['page'] ) {
842 $parent_route = array( 'page' => $route['page'], 'tab' => $route['tab'], 'subtab' => '' );
843 if ( $this->insert_sidebar_source_child( $compiled, WPBC_Booking_Modes_V3_Source_Index::get_route_key( $parent_route ), $navigation_key, $node ) ) {
844 continue;
845 }
846 }
847 if ( ! isset( $compiled[ $target_group ] ) || ! is_array( $compiled[ $target_group ] ) ) {
848 $compiled[ $target_group ] = array();
849 }
850 $compiled[ $target_group ][ $navigation_key ] = $node;
851 $compiled[ $target_group ] = $this->sort_compatibility_nodes( $compiled[ $target_group ] );
852 }
853
854 return $compiled;
855 }
856
857 /**
858 * Check whether a V3 declaration explicitly suppresses one source route.
859 *
860 * Released compatibility filters may remove or restyle eligible nodes, but
861 * cannot restore a route hidden by the selected mode or current edition.
862 *
863 * @param string $source_identity Collision-safe source identity.
864 * @param array $definition Selected mode declaration.
865 * @param string $edition_id Exact current edition ID.
866 *
867 * @return bool True when the declaration suppresses the route.
868 */
869 private function is_route_explicitly_suppressed( $source_identity, $definition, $edition_id ) {
870 $is_suppressed = false;
871 $walk = function ( $nodes, $ancestor_suppressed = false ) use ( &$walk, &$is_suppressed, $source_identity, $definition, $edition_id ) {
872 foreach ( $nodes as $node ) {
873 $node_suppressed = $ancestor_suppressed || ! $this->is_surface_visible( $node, $edition_id );
874 if ( isset( $node['page_id'], $definition['pages'][ $node['page_id'] ] ) ) {
875 $route_identity = WPBC_Booking_Modes_V3_Source_Index::get_route_key( $definition['pages'][ $node['page_id'] ]['route'] );
876 if ( $source_identity === $route_identity && $node_suppressed ) {
877 $is_suppressed = true;
878 return;
879 }
880 }
881 if ( isset( $node['items'] ) && is_array( $node['items'] ) ) {
882 $walk( $node['items'], $node_suppressed );
883 }
884 }
885 };
886 $walk( $definition['sidebar_menu'] );
887
888 return $is_suppressed;
889 }
890
891 /**
892 * Remove one source identity from any root in a complete sidebar.
893 *
894 * @param array $sidebar Sidebar roots passed by reference.
895 * @param string $source_identity Collision-safe source identity.
896 *
897 * @return bool True when a node was removed.
898 */
899 private function remove_sidebar_source_identity( &$sidebar, $source_identity ) {
900 foreach ( $sidebar as &$root_nodes ) {
901 if ( is_array( $root_nodes ) && $this->remove_source_identity( $root_nodes, $source_identity ) ) {
902 unset( $root_nodes );
903 return true;
904 }
905 }
906 unset( $root_nodes );
907
908 return false;
909 }
910
911 /**
912 * Insert one source child below its parent in any sidebar root.
913 *
914 * @param array $sidebar Sidebar roots passed by reference.
915 * @param string $parent_identity Parent source identity.
916 * @param string $navigation_key Presentation child key.
917 * @param array $child_node Prepared source child.
918 *
919 * @return bool True when the source parent was found.
920 */
921 private function insert_sidebar_source_child( &$sidebar, $parent_identity, $navigation_key, $child_node ) {
922 foreach ( $sidebar as &$root_nodes ) {
923 if ( is_array( $root_nodes ) && $this->insert_source_child( $root_nodes, $parent_identity, $navigation_key, $child_node ) ) {
924 unset( $root_nodes );
925 return true;
926 }
927 }
928 unset( $root_nodes );
929
930 return false;
931 }
932
933 /**
934 * Remove one source identity from any sidebar depth.
935 *
936 * @param array $nodes Navigation nodes passed by reference.
937 * @param string $source_identity Collision-safe source identity.
938 *
939 * @return bool True when a node was removed.
940 */
941 private function remove_source_identity( &$nodes, $source_identity ) {
942 foreach ( $nodes as $node_key => &$node ) {
943 if ( is_array( $node ) && isset( $node['_wpbc_v3_source_identity'] ) && $source_identity === $node['_wpbc_v3_source_identity'] ) {
944 unset( $nodes[ $node_key ] );
945 unset( $node );
946 return true;
947 }
948 if ( is_array( $node ) && ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] ) && $this->remove_source_identity( $node['subtabs'], $source_identity ) ) {
949 unset( $node );
950 return true;
951 }
952 }
953 unset( $node );
954
955 return false;
956 }
957
958 /**
959 * Insert one compatible subtab below its original source parent.
960 *
961 * @param array $nodes Navigation nodes passed by reference.
962 * @param string $parent_identity Parent source identity.
963 * @param string $navigation_key Presentation child key.
964 * @param array $child_node Prepared source child.
965 *
966 * @return bool True when the source parent was found.
967 */
968 private function insert_source_child( &$nodes, $parent_identity, $navigation_key, $child_node ) {
969 foreach ( $nodes as &$node ) {
970 if ( ! is_array( $node ) ) {
971 continue;
972 }
973 if ( isset( $node['_wpbc_v3_source_identity'] ) && $parent_identity === $node['_wpbc_v3_source_identity'] ) {
974 $node['subtabs'] = isset( $node['subtabs'] ) && is_array( $node['subtabs'] ) ? $node['subtabs'] : array();
975 $node['subtabs'][ $navigation_key ] = $child_node;
976 $node['subtabs'] = $this->sort_compatibility_nodes( $node['subtabs'] );
977 $node['_wpbc_v3_expanded'] = isset( $node['_wpbc_v3_expanded'] ) ? $node['_wpbc_v3_expanded'] : 'when_active';
978 unset( $node );
979 return true;
980 }
981 if ( ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] ) && $this->insert_source_child( $node['subtabs'], $parent_identity, $navigation_key, $child_node ) ) {
982 unset( $node );
983 return true;
984 }
985 }
986 unset( $node );
987
988 return false;
989 }
990
991 /**
992 * Sort compatibility placements while retaining source order for stable ties.
993 *
994 * @param array $nodes Navigation sibling map.
995 *
996 * @return array Sorted sibling map.
997 */
998 private function sort_compatibility_nodes( $nodes ) {
999 $sequence = 0;
1000 foreach ( $nodes as $node_key => $node ) {
1001 $nodes[ $node_key ]['_wpbc_v3_sort'] = isset( $node['_wpbc_v3_position'] ) ? absint( $node['_wpbc_v3_position'] ) : 10000 + $sequence;
1002 $nodes[ $node_key ]['_wpbc_v3_sequence'] = $sequence;
1003 ++$sequence;
1004 }
1005 uasort(
1006 $nodes,
1007 static function ( $first_node, $second_node ) {
1008 if ( $first_node['_wpbc_v3_sort'] === $second_node['_wpbc_v3_sort'] ) {
1009 return $first_node['_wpbc_v3_sequence'] === $second_node['_wpbc_v3_sequence'] ? 0 : ( $first_node['_wpbc_v3_sequence'] < $second_node['_wpbc_v3_sequence'] ? -1 : 1 );
1010 }
1011 return $first_node['_wpbc_v3_sort'] < $second_node['_wpbc_v3_sort'] ? -1 : 1;
1012 }
1013 );
1014 foreach ( $nodes as $node_key => $node ) {
1015 unset( $node['_wpbc_v3_position'], $node['_wpbc_v3_sort'], $node['_wpbc_v3_sequence'] );
1016 $nodes[ $node_key ] = $node;
1017 }
1018
1019 return $nodes;
1020 }
1021
1022 /**
1023 * Reapply source and explicit V3 constraints after the released tree filter.
1024 *
1025 * @param array $filtered Navigation returned by the public result filter.
1026 * @param array $allowed Source-authorized navigation before that filter.
1027 *
1028 * @return array Safe navigation preserving compatible labels/order/removals.
1029 */
1030 private function enforce_sidebar_source_constraints( $filtered, $allowed ) {
1031 $allowed_nodes = array();
1032 foreach ( $allowed as $root_nodes ) {
1033 if ( is_array( $root_nodes ) ) {
1034 $this->index_allowed_sidebar_nodes( $root_nodes, $allowed_nodes );
1035 }
1036 }
1037 $seen = array();
1038 $normalized = array();
1039 $remaining_nodes = WPBC_Booking_Modes_V3_Definition_Validator::MAX_SIDEBAR_NODES;
1040 foreach ( array_slice( $filtered, 0, WPBC_Booking_Modes_V3_Definition_Validator::MAX_COLLECTION_ENTRIES, true ) as $root_key => $root_nodes ) {
1041 if ( ! isset( $allowed[ $root_key ] ) || ! is_array( $root_nodes ) ) {
1042 continue;
1043 }
1044 $safe_nodes = $this->normalize_filtered_sidebar_nodes( $root_nodes, $allowed_nodes, $seen, 1, $remaining_nodes );
1045 if ( ! empty( $safe_nodes ) ) {
1046 $normalized[ $root_key ] = $safe_nodes;
1047 }
1048 }
1049
1050 return $normalized;
1051 }
1052
1053 /**
1054 * Add stable internal identities to source-less folders before filtering.
1055 *
1056 * The identity travels through the released result filter and permits label
1057 * and order changes without letting a callback introduce an arbitrary folder.
1058 *
1059 * @param array $nodes Navigation nodes passed by reference.
1060 * @param string $path Stable key path used by recursive calls.
1061 *
1062 * @return void
1063 */
1064 private function add_sidebar_filter_identities( &$nodes, $path = '' ) {
1065 foreach ( $nodes as $node_key => &$node ) {
1066 if ( ! is_array( $node ) ) {
1067 continue;
1068 }
1069 $node_path = $path . '/' . strlen( (string) $node_key ) . ':' . (string) $node_key;
1070 if ( ! empty( $node['_wpbc_v3_pure_folder'] ) ) {
1071 $node['_wpbc_v3_filter_identity'] = 'pure:' . $node_path;
1072 }
1073 if ( ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] ) ) {
1074 $this->add_sidebar_filter_identities( $node['subtabs'], $node_path );
1075 }
1076 }
1077 unset( $node );
1078 }
1079
1080 /**
1081 * Index source and pure-folder identities allowed before public filtering.
1082 *
1083 * @param array $nodes Allowed navigation nodes.
1084 * @param array $allowed_nodes Identity map passed by reference.
1085 *
1086 * @return void
1087 */
1088 private function index_allowed_sidebar_nodes( $nodes, &$allowed_nodes ) {
1089 foreach ( $nodes as $node ) {
1090 if ( ! is_array( $node ) ) {
1091 continue;
1092 }
1093 $identity = isset( $node['_wpbc_v3_source_identity'] ) ? 'source:' . $node['_wpbc_v3_source_identity'] : '';
1094 if ( '' === $identity && isset( $node['_wpbc_v3_filter_identity'] ) ) {
1095 $identity = $node['_wpbc_v3_filter_identity'];
1096 }
1097 if ( '' !== $identity ) {
1098 $allowed_nodes[ $identity ] = $node;
1099 }
1100 if ( ! empty( $node['subtabs'] ) && is_array( $node['subtabs'] ) ) {
1101 $this->index_allowed_sidebar_nodes( $node['subtabs'], $allowed_nodes );
1102 }
1103 }
1104 }
1105
1106 /**
1107 * Normalize filtered nodes against the source-authorized identity map.
1108 *
1109 * @param array $nodes Filtered navigation nodes.
1110 * @param array $allowed_nodes Source-authorized nodes by identity.
1111 * @param array $seen Seen identities passed by reference.
1112 * @param int $depth Current filtered tree depth.
1113 * @param int $remaining Remaining node budget passed by reference.
1114 *
1115 * @return array Safe filtered nodes.
1116 */
1117 private function normalize_filtered_sidebar_nodes( $nodes, $allowed_nodes, &$seen, $depth, &$remaining ) {
1118 $normalized = array();
1119 if ( $depth > WPBC_Booking_Modes_V3_Definition_Validator::MAX_SIDEBAR_DEPTH || $remaining <= 0 ) {
1120 return $normalized;
1121 }
1122 foreach ( array_slice( $nodes, 0, WPBC_Booking_Modes_V3_Definition_Validator::MAX_COLLECTION_ENTRIES, true ) as $node_key => $candidate ) {
1123 if ( $remaining <= 0 ) {
1124 break;
1125 }
1126 --$remaining;
1127 if ( ! is_array( $candidate ) ) {
1128 continue;
1129 }
1130 $identity = isset( $candidate['_wpbc_v3_source_identity'] ) ? 'source:' . $candidate['_wpbc_v3_source_identity'] : '';
1131 if ( '' === $identity && isset( $candidate['_wpbc_v3_filter_identity'] ) ) {
1132 $identity = $candidate['_wpbc_v3_filter_identity'];
1133 }
1134 if ( '' === $identity || isset( $seen[ $identity ] ) || ! isset( $allowed_nodes[ $identity ] ) ) {
1135 continue;
1136 }
1137
1138 $node = $allowed_nodes[ $identity ];
1139 foreach ( array( 'title', 'font_icon', 'font_icon_right', 'css_classes', 'folder_style', 'style', '_wpbc_v3_expanded' ) as $presentation_key ) {
1140 if ( array_key_exists( $presentation_key, $candidate ) && is_scalar( $candidate[ $presentation_key ] ) ) {
1141 $node[ $presentation_key ] = $candidate[ $presentation_key ];
1142 }
1143 }
1144 $seen[ $identity ] = true;
1145 $candidate_children = isset( $candidate['subtabs'] ) && is_array( $candidate['subtabs'] ) ? $candidate['subtabs'] : array();
1146 $node['subtabs'] = $this->normalize_filtered_sidebar_nodes( $candidate_children, $allowed_nodes, $seen, $depth + 1, $remaining );
1147 if ( ! empty( $node['_wpbc_v3_pure_folder'] ) && empty( $node['subtabs'] ) ) {
1148 continue;
1149 }
1150 $node['is_active'] = ! empty( $node['is_active'] ) || $this->has_active_descendant( $node['subtabs'] );
1151 $normalized[ $node_key ] = $node;
1152 }
1153
1154 return $normalized;
1155 }
1156
1157 /**
1158 * Restrict filtered root metadata to groups present in the safe sidebar.
1159 *
1160 * @param array $filtered Filtered root metadata.
1161 * @param array $sidebar Safe compiled sidebar.
1162 * @param array $definition Selected declaration.
1163 * @param array $legacy_roots Original root metadata.
1164 *
1165 * @return array Safe root metadata.
1166 */
1167 private function enforce_root_constraints( $filtered, $sidebar, $definition, $legacy_roots ) {
1168 $normalized = array();
1169 $compatibility_groups = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance()->get_groups( $definition['id'] );
1170 foreach ( array_slice( is_array( $filtered ) ? $filtered : array(), 0, WPBC_Booking_Modes_V3_Definition_Validator::MAX_COLLECTION_ENTRIES, true ) as $root_key => $candidate ) {
1171 if ( ! isset( $sidebar[ $root_key ] ) || ! is_array( $candidate ) ) {
1172 continue;
1173 }
1174 $root_options = isset( $definition['sidebar_menu'][ $root_key ] ) ? $definition['sidebar_menu'][ $root_key ] : array();
1175 $legacy_root = isset( $legacy_roots[ $root_key ] ) && is_array( $legacy_roots[ $root_key ] ) ? $legacy_roots[ $root_key ] : array();
1176 $compatibility_group = isset( $compatibility_groups[ $root_key ] ) ? $compatibility_groups[ $root_key ] : array();
1177 $source_root = $this->get_source_root_node( $root_options, $definition );
1178 $root_items = $sidebar[ $root_key ];
1179 $first_item = is_array( $root_items ) ? reset( $root_items ) : array();
1180 $presentation_sources = array( $root_options, $compatibility_group, $legacy_root, $source_root, $first_item );
1181 $title = $this->resolve_root_presentation_value( 'title', $presentation_sources, $root_key );
1182 $font_icon = $this->resolve_root_presentation_value( 'font_icon', $presentation_sources, '' );
1183 $base = array(
1184 'type' => 'menu',
1185 'title' => $title,
1186 'font_icon' => $font_icon,
1187 'mode_font_icon' => $font_icon,
1188 'mode_expanded' => isset( $root_options['expanded'] ) ? $root_options['expanded'] : 'On',
1189 'mode_standalone' => isset( $root_options['page_id'] ) && 1 === count( $root_items ) && is_array( $first_item ) && empty( $first_item['subtabs'] ),
1190 );
1191 foreach ( array( 'title', 'font_icon', 'mode_font_icon' ) as $presentation_key ) {
1192 if ( array_key_exists( $presentation_key, $candidate ) && is_scalar( $candidate[ $presentation_key ] ) ) {
1193 $base[ $presentation_key ] = $candidate[ $presentation_key ];
1194 }
1195 }
1196 $normalized[ $root_key ] = $base;
1197 }
1198
1199 return $normalized;
1200 }
1201
1202 /**
1203 * Resolve one root display value through declared compatibility precedence.
1204 *
1205 * @param string $presentation_key Presentation field name.
1206 * @param array $sources Ordered source records from highest to lowest priority.
1207 * @param string $fallback Value used when no source declares the field.
1208 *
1209 * @return string First declared scalar display value or the fallback.
1210 */
1211 private function resolve_root_presentation_value( $presentation_key, $sources, $fallback ) {
1212 foreach ( $sources as $source ) {
1213 if ( is_array( $source ) && array_key_exists( $presentation_key, $source ) && is_scalar( $source[ $presentation_key ] ) ) {
1214 return (string) $source[ $presentation_key ];
1215 }
1216 }
1217
1218 return $fallback;
1219 }
1220
1221 /**
1222 * Return original source metadata for a root linked directly to a page.
1223 *
1224 * @param array $root_options Root sidebar declaration.
1225 * @param array $definition Selected mode declaration.
1226 *
1227 * @return array<string,mixed> Source node or an empty array.
1228 */
1229 private function get_source_root_node( $root_options, $definition ) {
1230 if ( ! isset( $root_options['page_id'], $definition['pages'][ $root_options['page_id'] ] ) ) {
1231 return array();
1232 }
1233
1234 $record = WPBC_Booking_Modes_V3_Source_Index::get_instance()->get_record( $definition['pages'][ $root_options['page_id'] ]['route'] );
1235
1236 return is_array( $record ) && isset( $record['node'] ) && is_array( $record['node'] ) ? $record['node'] : array();
1237 }
1238
1239 /**
1240 * Return canonical IDs whose routes exist in the captured source index.
1241 *
1242 * @return array<int,string> Available canonical IDs.
1243 */
1244 private function get_available_canonical_page_ids() {
1245 $available = array();
1246 foreach ( WPBC_Booking_Modes_V3_Page_Registry::get_instance()->get_all() as $page_id => $page ) {
1247 if ( null !== WPBC_Booking_Modes_V3_Source_Index::get_instance()->get_record( $page ) ) {
1248 $available[] = $page_id;
1249 }
1250 }
1251
1252 return $available;
1253 }
1254
1255 /**
1256 * Find a local definition page for an exact current route.
1257 *
1258 * @param array $definition Selected mode.
1259 * @param string $page_slug Page slug.
1260 * @param string $tab_slug Tab slug.
1261 * @param string $subtab_slug Subtab slug.
1262 *
1263 * @return string Local page ID, or an empty string.
1264 */
1265 private function find_page_id_for_route( $definition, $page_slug, $tab_slug, $subtab_slug ) {
1266 foreach ( $definition['pages'] as $page_id => $page_definition ) {
1267 $route = $page_definition['route'];
1268 if ( $route['page'] === $page_slug && $route['tab'] === $tab_slug && ( '' === $route['subtab'] || $route['subtab'] === $subtab_slug ) ) {
1269 return $page_id;
1270 }
1271 }
1272
1273 return '';
1274 }
1275 }
1276