Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.13% covered (success)
97.13%
508 / 523
82.61% covered (warning)
82.61%
19 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Admin_Menu
97.69% covered (success)
97.69%
508 / 520
82.61% covered (warning)
82.61%
19 / 23
162
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 get_item_permissions_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_item
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 prepare_menu_for_response
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 maybe_build_sidebar_nav_index
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
25
 order_sidebar_grouped_items
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
13
 get_sidebar_nav_entry
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_sidebar_nav_index_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 map_signal_from_nav_entry
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 attach_sidebar_fields
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
11
 prepare_groups_for_response
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 get_sidebar_layout_delta
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 get_sidebar_layout_storage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 normalize_sidebar_layout_delta
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 empty_sidebar_layout_delta
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_item_schema
100.00% covered (success)
100.00%
198 / 198
100.00% covered (success)
100.00%
1 / 1
1
 prepare_menu_item
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
15
 prepare_submenu_item
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 prepare_menu_item_icon
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 prepare_dashicon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 prepare_menu_item_url
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
19
 parse_menu_item
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
19.86
1<?php
2/**
3 * REST API endpoint for admin menus.
4 *
5 * @package automattic/jetpack
6 * @since 9.1.0
7 */
8
9use Automattic\Jetpack\Status\Host;
10
11if ( ! defined( 'ABSPATH' ) ) {
12    exit( 0 );
13}
14
15/**
16 * Class WPCOM_REST_API_V2_Endpoint_Admin_Menu
17 */
18class WPCOM_REST_API_V2_Endpoint_Admin_Menu extends WP_REST_Controller {
19
20    /**
21     * Namespace prefix.
22     *
23     * @var string
24     */
25    public $namespace = 'wpcom/v2';
26
27    /**
28     * Endpoint base route.
29     *
30     * @var string
31     */
32    public $rest_base = 'admin-menu';
33
34    /**
35     *
36     * Set of core dashicons.
37     *
38     * @var array
39     */
40    private $dashicon_list;
41
42    /**
43     * Lazily-built map of `menu_slug => nav-model entry`, populated from the
44     * `Sidebar_Classifier` nav model when the public `wp-admin-sidebar` plugin
45     * is loaded on the host. `null` means we have not yet attempted to build
46     * the index for this request; an empty array means the classifier ran but
47     * produced no items (e.g., gating denied).
48     *
49     * @var array<string, array>|null
50     */
51    private $sidebar_nav_index = null;
52
53    /**
54     * Group rows from the classifier nav model. Sibling to
55     * `$sidebar_nav_index`; `null` when not built, empty array when no groups.
56     *
57     * @var array<string, array>|null
58     */
59    private $sidebar_nav_groups = null;
60
61    /**
62     * WPCOM_REST_API_V2_Endpoint_Admin_Menu constructor.
63     */
64    public function __construct() {
65        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
66    }
67
68    /**
69     * Register routes.
70     */
71    public function register_routes() {
72        register_rest_route(
73            $this->namespace,
74            $this->rest_base . '/',
75            array(
76                array(
77                    'methods'             => WP_REST_Server::READABLE,
78                    'callback'            => array( $this, 'get_item' ),
79                    'permission_callback' => array( $this, 'get_item_permissions_check' ),
80                ),
81                'schema' => array( $this, 'get_public_item_schema' ),
82            )
83        );
84    }
85
86    /**
87     * Checks if a given request has access to admin menus.
88     *
89     * @param WP_REST_Request $request Full details about the request.
90     * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
91     */
92    public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
93        if ( ! current_user_can( 'read' ) ) {
94            return new WP_Error(
95                'rest_forbidden',
96                __( 'Sorry, you are not allowed to view menus on this site.', 'jetpack' ),
97                array( 'status' => rest_authorization_required_code() )
98            );
99        }
100
101        return true;
102    }
103
104    /**
105     * Retrieves the admin menu.
106     *
107     * @param WP_REST_Request $request Full details about the request.
108     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
109     */
110    public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
111
112        /*
113         * Load the `Jetpack_Admin` class, since it's only loaded on admin requests (not on API requests), and this is where
114         * many Jetpack menus are registered. We don't need to run this on WPCOM because we replicate an admin request there.
115         *
116         * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/jetpack/load-jetpack.php#L61-L64
117         * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/wpcomsh/feature-plugins/masterbar.php#L29
118         */
119        if ( ! ( new Host() )->is_wpcom_platform() ) {
120            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
121        }
122
123        // All globals need to be declared for menu items to properly register.
124        global $admin_page_hooks, $menu, $menu_order, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
125
126        require_once ABSPATH . 'wp-admin/includes/admin.php';
127        require_once ABSPATH . 'wp-admin/menu.php';
128
129        // REST requests do not run admin_head, where hidden sidebar entries normally leave the menu.
130        if ( method_exists( '\Automattic\Jetpack\Admin_UI\Admin_Menu', 'remove_hidden_menu_items' ) ) {
131            \Automattic\Jetpack\Admin_UI\Admin_Menu::remove_hidden_menu_items();
132        }
133
134        return rest_ensure_response( $this->prepare_menu_for_response( $menu ) );
135    }
136
137    /**
138     * Prepares the admin menu for the REST response.
139     *
140     * @param array $menu Admin menu.
141     * @return array Admin menu
142     */
143    public function prepare_menu_for_response( array $menu ) {
144        global $submenu;
145
146        // Best-effort hydration of the classifier nav model. Safe no-op when the
147        // public `wp-admin-sidebar` plugin is not installed (non-WPCOM Jetpack).
148        $this->maybe_build_sidebar_nav_index();
149
150        $data = array();
151
152        /**
153         * Note: if the shape of the API endpoint data changes it is important to also update
154         * the corresponding schema.js file.
155         * See: https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
156         */
157        foreach ( $menu as $menu_item ) {
158            $item = $this->prepare_menu_item( $menu_item );
159
160            // Are there submenu items to process?
161            if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
162                $submenu_items = array_values( $submenu[ $menu_item[2] ] );
163
164                // Add submenu items.
165                foreach ( $submenu_items as $submenu_item ) {
166                    // As $submenu_item can be null or false due to combination of plugins/themes, its value
167                    // must be checked before passing it to the prepare_submenu_item method. It may be related
168                    // to the common usage of null as a "hidden" submenu item like was fixed in CRM in #29945.
169                    if ( ! is_array( $submenu_item ) ) {
170                        continue;
171                    }
172                    $submenu_item = $this->prepare_submenu_item( $submenu_item, $menu_item );
173                    if ( ! empty( $submenu_item ) ) {
174                        $item['children'][] = $submenu_item;
175                    }
176                }
177            }
178
179            if ( ! empty( $item ) ) {
180                $data[] = $item;
181            }
182        }
183
184        $data = array_values( array_filter( $data ) );
185
186        // When the public `wp-admin-sidebar` plugin is loaded (WPCOM and any
187        // host that opts in), the response carries a sibling `groups[]` array
188        // describing the synthetic group rows the classifier emitted. Wrapping
189        // only happens when classifier data is available so non-WPCOM Jetpack
190        // installs continue to receive the legacy flat-array shape.
191        if ( null !== $this->sidebar_nav_groups ) {
192            $data     = $this->order_sidebar_grouped_items( $data );
193            $response = array(
194                'menu'   => $data,
195                'groups' => $this->prepare_groups_for_response(),
196            );
197
198            $layout_delta = $this->get_sidebar_layout_delta();
199            if ( null !== $layout_delta ) {
200                $response['layoutDelta'] = $layout_delta;
201            }
202
203            return $response;
204        }
205
206        return $data;
207    }
208
209    /**
210     * Hydrate the classifier-driven nav index from the public
211     * `wp-admin-sidebar` plugin. No-op when the plugin is not installed or
212     * gating denied building a model on this request.
213     */
214    private function maybe_build_sidebar_nav_index() {
215        if ( null !== $this->sidebar_nav_index ) {
216            return;
217        }
218
219        if ( ! class_exists( 'Sidebar_Classifier' ) || ! class_exists( 'Sidebar_Signals' ) ) {
220            return;
221        }
222
223        // Trigger a build if the classifier hasn't run yet on this request.
224        // The classifier short-circuits when `wp_admin_sidebar_enabled` is
225        // false, which is the default for any host that hasn't opted in.
226        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
227        if ( null === Sidebar_Classifier::get_nav_model() ) {
228            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
229            Sidebar_Classifier::build_nav_model();
230        }
231
232        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
233        $nav_model = Sidebar_Classifier::get_nav_model();
234        if ( ! is_array( $nav_model ) ) {
235            return;
236        }
237
238        $index   = array();
239        $collect = function ( array $items ) use ( &$index, &$collect ) {
240            foreach ( $items as $position => $item ) {
241                if ( isset( $item['menuSlug'] ) && '' !== $item['menuSlug'] ) {
242                    if ( ! isset( $item['default_weight'] ) && ! isset( $item['_weight'] ) ) {
243                        $item['default_weight'] = (int) $position;
244                    }
245                    $parent_slug = array_key_exists( 'parent', $item ) && null !== $item['parent'] ? (string) $item['parent'] : null;
246                    $index[ $this->get_sidebar_nav_index_key( $item['menuSlug'], $parent_slug ) ] = $item;
247                    if ( ! isset( $index[ $item['menuSlug'] ] ) ) {
248                        $index[ $item['menuSlug'] ] = $item;
249                    }
250                }
251                if ( ! empty( $item['children'] ) && is_array( $item['children'] ) ) {
252                    $collect( $item['children'] );
253                }
254            }
255        };
256
257        if ( ! empty( $nav_model['top_level'] ) && is_array( $nav_model['top_level'] ) ) {
258            $collect( $nav_model['top_level'] );
259        }
260        if ( ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] ) ) {
261            foreach ( $nav_model['groups'] as $group ) {
262                if ( ! empty( $group['children'] ) && is_array( $group['children'] ) ) {
263                    $collect( $group['children'] );
264                }
265            }
266        }
267
268        $this->sidebar_nav_index  = $index;
269        $this->sidebar_nav_groups = ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] )
270            ? $nav_model['groups']
271            : array();
272    }
273
274    /**
275     * Keep grouped items in the classifier's child order while preserving the
276     * relative position of ungrouped top-level rows. The public classifier
277     * sorts each group before exposing the nav model, but Calypso receives a
278     * flat `menu` array and buckets grouped items in response order.
279     *
280     * @param array $items Prepared top-level menu items.
281     * @return array Prepared items with grouped rows sorted per group.
282     */
283    private function order_sidebar_grouped_items( array $items ) {
284        $grouped_items = array();
285        foreach ( $items as $item ) {
286            if ( isset( $item['group_id'] ) && is_string( $item['group_id'] ) && '' !== $item['group_id'] ) {
287                $grouped_items[ $item['group_id'] ][] = $item;
288            }
289        }
290
291        if ( empty( $grouped_items ) ) {
292            return $items;
293        }
294
295        foreach ( $grouped_items as &$group_items ) {
296            usort(
297                $group_items,
298                static function ( $a, $b ) {
299                    $wa = (int) ( $a['default_weight'] ?? PHP_INT_MAX );
300                    $wb = (int) ( $b['default_weight'] ?? PHP_INT_MAX );
301                    if ( $wa === $wb ) {
302                        return strcmp( (string) ( $a['itemId'] ?? $a['slug'] ?? '' ), (string) ( $b['itemId'] ?? $b['slug'] ?? '' ) );
303                    }
304                    return $wa <=> $wb;
305                }
306            );
307        }
308        unset( $group_items );
309
310        $group_offsets = array();
311        foreach ( $items as $index => $item ) {
312            if ( ! isset( $item['group_id'] ) || ! is_string( $item['group_id'] ) || '' === $item['group_id'] ) {
313                continue;
314            }
315
316            $group_id = $item['group_id'];
317            $offset   = $group_offsets[ $group_id ] ?? 0;
318            if ( isset( $grouped_items[ $group_id ][ $offset ] ) ) {
319                $items[ $index ] = $grouped_items[ $group_id ][ $offset ];
320            }
321            $group_offsets[ $group_id ] = $offset + 1;
322        }
323
324        return $items;
325    }
326
327    /**
328     * Look up the classifier nav-model entry for a given menu slug.
329     *
330     * @param string      $menu_slug   Menu slug as registered in `$menu` / `$submenu`.
331     * @param string|null $parent_slug Parent menu slug, or null for top-level items.
332     * @return array|null Nav-model entry or null if the slug is not in the index.
333     */
334    private function get_sidebar_nav_entry( $menu_slug, $parent_slug = null ) {
335        if ( null === $this->sidebar_nav_index || '' === $menu_slug ) {
336            return null;
337        }
338        $key = $this->get_sidebar_nav_index_key( $menu_slug, $parent_slug );
339        if ( isset( $this->sidebar_nav_index[ $key ] ) ) {
340            return $this->sidebar_nav_index[ $key ];
341        }
342        return $this->sidebar_nav_index[ $menu_slug ] ?? null;
343    }
344
345    /**
346     * Compose an index key that distinguishes top-level rows from submenu rows
347     * sharing the same raw menu slug.
348     *
349     * @param string      $menu_slug   Menu slug as registered in `$menu` / `$submenu`.
350     * @param string|null $parent_slug Parent menu slug, or null for top-level items.
351     * @return string Index key.
352     */
353    private function get_sidebar_nav_index_key( $menu_slug, $parent_slug = null ) {
354        return ( null === $parent_slug ? '' : (string) $parent_slug ) . "\0" . (string) $menu_slug;
355    }
356
357    /**
358     * Build the schema-shaped `signal` subobject for an item from a classifier
359     * nav-model entry. Returns null when the entry has no signal data.
360     *
361     * The returned shape is fixed (all keys present, missing fields null) so
362     * Calypso can read it without optional-chaining gymnastics.
363     *
364     * @param array $nav_entry Classifier nav-model entry.
365     * @return array|null
366     */
367    private function map_signal_from_nav_entry( array $nav_entry ) {
368        if ( empty( $nav_entry['signal'] ) || ! is_array( $nav_entry['signal'] ) ) {
369            return null;
370        }
371        $signal = $nav_entry['signal'];
372        return array(
373            'count'         => $signal['count'] ?? null,
374            'numeric_badge' => $signal['numeric_badge'] ?? null,
375            'badge'         => $signal['badge'] ?? null,
376            'inline_text'   => $signal['inline_text'] ?? null,
377            'inline_icon'   => $signal['inline_icon'] ?? null,
378            'attention'     => ! empty( $signal['attention'] ),
379        );
380    }
381
382    /**
383     * Attach classifier-derived `group_id` + `signal` fields to a prepared item.
384     * No-op when the classifier index is not available or the slug isn't found.
385     *
386     * @param array       $item        Prepared item (top-level or submenu).
387     * @param string      $menu_slug   Raw menu slug used to look up the nav entry.
388     * @param string|null $parent_slug Parent menu slug, or null for top-level items.
389     * @return array Item with fields possibly added.
390     */
391    private function attach_sidebar_fields( array $item, $menu_slug, $parent_slug = null ) {
392        $nav_entry = $this->get_sidebar_nav_entry( $menu_slug, $parent_slug );
393        if ( null === $nav_entry ) {
394            return $item;
395        }
396
397        // `default_group` in the classifier maps to `group_id` on the wire.
398        $default_group    = $nav_entry['default_group'] ?? null;
399        $item['group_id'] = is_string( $default_group ) && '' !== $default_group ? $default_group : null;
400        $item['signal']   = $this->map_signal_from_nav_entry( $nav_entry );
401
402        if ( isset( $nav_entry['itemId'] ) && '' !== $nav_entry['itemId'] ) {
403            $item['itemId'] = (string) $nav_entry['itemId'];
404        }
405        if ( isset( $nav_entry['source'] ) && '' !== $nav_entry['source'] ) {
406            $item['source'] = (string) $nav_entry['source'];
407        }
408        if ( isset( $nav_entry['reassignable'] ) ) {
409            $item['reassignable'] = (bool) $nav_entry['reassignable'];
410        }
411        if ( isset( $nav_entry['default_weight'] ) ) {
412            $item['default_weight'] = (int) $nav_entry['default_weight'];
413        } elseif ( isset( $nav_entry['_weight'] ) ) {
414            $item['default_weight'] = (int) $nav_entry['_weight'];
415        }
416
417        return $item;
418    }
419
420    /**
421     * Build the response-shape `groups[]` array from the cached classifier rows.
422     *
423     * Each element carries the group's stable id, label, default collapsed
424     * state and the aggregated signal that the classifier already computed.
425     *
426     * @return array
427     */
428    private function prepare_groups_for_response() {
429        if ( empty( $this->sidebar_nav_groups ) ) {
430            return array();
431        }
432
433        $groups = array();
434        foreach ( $this->sidebar_nav_groups as $group ) {
435            if ( empty( $group['id'] ) ) {
436                continue;
437            }
438            $signal   = isset( $group['signal'] ) && is_array( $group['signal'] ) ? $group['signal'] : array();
439            $groups[] = array(
440                'id'               => (string) $group['id'],
441                'label'            => isset( $group['title'] ) ? (string) $group['title'] : '',
442                'default_expanded' => false,
443                'signal'           => array(
444                    'attention' => ! empty( $signal['attention'] ),
445                    'count'     => isset( $signal['count'] ) ? (int) $signal['count'] : 0,
446                ),
447            );
448        }
449        return $groups;
450    }
451
452    /**
453     * Read the saved Calypso-compatible layout delta from the public plugin's
454     * storage adapter. Returns null when the public plugin storage API is not
455     * loaded, which keeps non-redesigned hosts on the legacy contract.
456     *
457     * @return array|null
458     */
459    private function get_sidebar_layout_delta() {
460        if ( ! interface_exists( 'Sidebar_Layout_Storage' ) || ! class_exists( 'WP_User_Meta_Storage' ) ) {
461            return null;
462        }
463
464        $user_id = get_current_user_id();
465        if ( ! $user_id ) {
466            return null;
467        }
468
469        $site_id = (int) get_current_blog_id();
470        $storage = $this->get_sidebar_layout_storage();
471        $layouts = $storage->get_layouts( $user_id );
472
473        if ( isset( $layouts[ $site_id ] ) && is_array( $layouts[ $site_id ] ) ) {
474            return $this->normalize_sidebar_layout_delta( $layouts[ $site_id ] );
475        }
476
477        return $this->empty_sidebar_layout_delta();
478    }
479
480    /**
481     * Resolve the public plugin storage adapter. Mirrors Sidebar_Data_Planner.
482     *
483     * @return object
484     */
485    private function get_sidebar_layout_storage() {
486        // @phan-suppress-next-line PhanUndeclaredClassMethod -- WP_User_Meta_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
487        $default = new WP_User_Meta_Storage();
488        $bound   = apply_filters( 'wp_admin_sidebar_storage', $default );
489        $bound   = apply_filters_deprecated(
490            'wpcom_admin_sidebar_storage',
491            array( $bound ),
492            '0.1.0',
493            'wp_admin_sidebar_storage'
494        );
495        // @phan-suppress-next-line PhanUndeclaredClassInstanceof -- Sidebar_Layout_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by interface_exists() above.
496        return $bound instanceof Sidebar_Layout_Storage ? $bound : $default;
497    }
498
499    /**
500     * Normalize a stored LayoutDelta into the response shape Calypso expects.
501     *
502     * @param array $delta Stored layout delta.
503     * @return array
504     */
505    private function normalize_sidebar_layout_delta( array $delta ) {
506        if ( ! isset( $delta['overrides'] ) || ! is_array( $delta['overrides'] ) ) {
507            return $this->empty_sidebar_layout_delta();
508        }
509
510        return array(
511            'version'    => isset( $delta['version'] ) ? (int) $delta['version'] : 1,
512            'updated_at' => isset( $delta['updated_at'] ) ? (int) $delta['updated_at'] : 0,
513            'overrides'  => array_values( $delta['overrides'] ),
514        );
515    }
516
517    /**
518     * Empty-delta shape used when a redesigned host has no saved layout yet.
519     *
520     * @return array
521     */
522    private function empty_sidebar_layout_delta() {
523        return array(
524            'version'    => 1,
525            'updated_at' => 0,
526            'overrides'  => array(),
527        );
528    }
529
530    /**
531     * Retrieves the admin menu's schema, conforming to JSON Schema.
532     *
533     * Note: if the shape of the API endpoint data changes it is important to also update
534     * the corresponding schema.js file.
535     *
536     * @see https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
537     *
538     * @return array Item schema data.
539     */
540    public function get_item_schema() {
541        // Shape of the optional `signal` subobject attached to each item by the
542        // public `wp-admin-sidebar` classifier (when loaded). Mirrors the snake
543        // case shape produced by `Sidebar_Signals::map_to_nav` so Calypso can
544        // consume one canonical field set end-to-end. All keys are present and
545        // nullable; consumers don't need optional-chaining.
546        $signal_schema = array(
547            'description' => 'Per-item attention/count/badge data emitted by the wp-admin-sidebar classifier. Null when no signal data was extracted.',
548            'type'        => array( 'object', 'null' ),
549            'properties'  => array(
550                'count'         => array( 'type' => array( 'integer', 'null' ) ),
551                'numeric_badge' => array( 'type' => array( 'integer', 'null' ) ),
552                'badge'         => array( 'type' => array( 'string', 'null' ) ),
553                'inline_text'   => array( 'type' => array( 'string', 'null' ) ),
554                'inline_icon'   => array( 'type' => array( 'string', 'null' ) ),
555                'attention'     => array( 'type' => 'boolean' ),
556            ),
557        );
558
559        $submenu_item_schema = array(
560            'type'       => 'object',
561            'properties' => array(
562                'count'          => array(
563                    'description' => 'Core/Plugin/Theme update count or unread comments count.',
564                    'type'        => 'integer',
565                ),
566                'parent'         => array(
567                    'type' => 'string',
568                ),
569                'slug'           => array(
570                    'type' => 'string',
571                ),
572                'title'          => array(
573                    'type' => 'string',
574                ),
575                'type'           => array(
576                    'enum' => array( 'submenu-item' ),
577                    'type' => 'string',
578                ),
579                'url'            => array(
580                    'format' => 'uri',
581                    'type'   => 'string',
582                ),
583                'itemId'         => array(
584                    'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
585                    'type'        => 'string',
586                ),
587                'source'         => array(
588                    'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
589                    'type'        => 'string',
590                ),
591                'default_weight' => array(
592                    'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
593                    'type'        => 'integer',
594                ),
595                'reassignable'   => array(
596                    'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
597                    'type'        => 'boolean',
598                ),
599                'group_id'       => array(
600                    'description' => 'Group this submenu belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
601                    'type'        => array( 'string', 'null' ),
602                ),
603                'signal'         => $signal_schema,
604            ),
605        );
606
607        $menu_item_schema = array(
608            'type'       => 'object',
609            'properties' => array(
610                'count'          => array(
611                    'description' => 'Core/Plugin/Theme update count or unread comments count.',
612                    'type'        => 'integer',
613                ),
614                'icon'           => array(
615                    'description' => 'Menu item icon. Dashicon slug or base64-encoded SVG.',
616                    'type'        => 'string',
617                ),
618                'inlineText'     => array(
619                    'description' => 'Additional text to be added inline with the menu title.',
620                    'type'        => 'string',
621                ),
622                'inlineIcon'     => array(
623                    'description' => 'Dashicon slug to be displayed inline with the menu title.',
624                    'type'        => 'string',
625                ),
626                'badge'          => array(
627                    'description' => 'Badge to be added inline with the menu title.',
628                    'type'        => 'string',
629                ),
630                'slug'           => array(
631                    'type' => 'string',
632                ),
633                'children'       => array(
634                    'type'  => 'array',
635                    'items' => $submenu_item_schema,
636                ),
637                'title'          => array(
638                    'type' => 'string',
639                ),
640                'type'           => array(
641                    'enum' => array( 'separator', 'menu-item' ),
642                    'type' => 'string',
643                ),
644                'url'            => array(
645                    'format' => 'uri',
646                    'type'   => 'string',
647                ),
648                'itemId'         => array(
649                    'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
650                    'type'        => 'string',
651                ),
652                'source'         => array(
653                    'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
654                    'type'        => 'string',
655                ),
656                'default_weight' => array(
657                    'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
658                    'type'        => 'integer',
659                ),
660                'reassignable'   => array(
661                    'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
662                    'type'        => 'boolean',
663                ),
664                'group_id'       => array(
665                    'description' => 'Group this top-level item belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
666                    'type'        => array( 'string', 'null' ),
667                ),
668                'signal'         => $signal_schema,
669            ),
670        );
671
672        $group_schema = array(
673            'type'       => 'object',
674            'properties' => array(
675                'id'               => array( 'type' => 'string' ),
676                'label'            => array( 'type' => 'string' ),
677                'default_expanded' => array( 'type' => 'boolean' ),
678                'signal'           => array(
679                    'type'       => 'object',
680                    'properties' => array(
681                        'attention' => array( 'type' => 'boolean' ),
682                        'count'     => array( 'type' => 'integer' ),
683                    ),
684                ),
685            ),
686        );
687
688        $layout_delta_schema = array(
689            'description' => 'Saved per-user sidebar layout delta. Only present when the wp-admin-sidebar storage API is loaded.',
690            'type'        => array( 'object', 'null' ),
691            'properties'  => array(
692                'version'    => array( 'type' => 'integer' ),
693                'updated_at' => array( 'type' => 'integer' ),
694                'overrides'  => array(
695                    'type'  => 'array',
696                    'items' => array(
697                        'type'       => 'object',
698                        'properties' => array(
699                            'itemId'   => array( 'type' => 'string' ),
700                            'position' => array(
701                                'type'       => 'object',
702                                'properties' => array(
703                                    'kind'     => array(
704                                        'type' => 'string',
705                                        'enum' => array( 'top_level', 'in_group' ),
706                                    ),
707                                    'group_id' => array( 'type' => 'string' ),
708                                    'index'    => array( 'type' => 'integer' ),
709                                ),
710                            ),
711                        ),
712                    ),
713                ),
714            ),
715        );
716
717        $legacy_response_schema = array(
718            'description' => 'Legacy flat admin menu response used when the wp-admin-sidebar classifier is not loaded or not enabled.',
719            'type'        => 'array',
720            'items'       => $menu_item_schema,
721        );
722
723        $redesigned_response_schema = array(
724            'description' => 'Wrapped admin menu response used when the wp-admin-sidebar classifier is loaded and enabled.',
725            'type'        => 'object',
726            'required'    => array( 'menu', 'groups' ),
727            'properties'  => array(
728                'menu'        => array(
729                    'description' => 'Top-level menu items.',
730                    'type'        => 'array',
731                    'items'       => $menu_item_schema,
732                ),
733                'groups'      => array(
734                    'description' => 'Synthetic group rows describing the sidebar grouping shape. Empty array if no plugin items grouped.',
735                    'type'        => 'array',
736                    'items'       => $group_schema,
737                ),
738                'layoutDelta' => $layout_delta_schema,
739            ),
740        );
741
742        return array(
743            '$schema' => 'http://json-schema.org/draft-04/schema#',
744            'title'   => 'Admin Menu',
745            'type'    => array( 'array', 'object' ),
746            'anyOf'   => array(
747                $legacy_response_schema,
748                $redesigned_response_schema,
749            ),
750        );
751    }
752
753    /**
754     * Sets up a menu item for consumption by Calypso.
755     *
756     * @param array $menu_item Menu item.
757     * @return array Prepared menu item.
758     */
759    private function prepare_menu_item( array $menu_item ) {
760        global $submenu;
761
762        $current_user_can_access_menu = current_user_can( $menu_item[1] );
763        $submenu_items                = isset( $submenu[ $menu_item[2] ] ) ? array_values( $submenu[ $menu_item[2] ] ) : array();
764        $has_first_menu_item          = isset( $submenu_items[0] );
765
766        // Exclude unauthorized menu items when the user does not have access to the menu and the first submenu item.
767        if ( ! $current_user_can_access_menu && $has_first_menu_item && ! current_user_can( $submenu_items[0][1] ) ) {
768            return array();
769        }
770
771        // Exclude unauthorized menu items that don't have submenus.
772        if ( ! $current_user_can_access_menu && ! $has_first_menu_item ) {
773            return array();
774        }
775
776        // Exclude hidden menu items.
777        if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
778            // Exclude submenu items as well.
779            if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
780                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
781                $submenu[ $menu_item[2] ] = array();
782            }
783            return array();
784        }
785
786        // Handle menu separators.
787        if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
788            return array(
789                'type' => 'separator',
790            );
791        }
792
793        $url         = $menu_item[2];
794        $parent_slug = '';
795
796        // If there are submenus, the parent menu should always link to the first submenu.
797        // @see https://core.trac.wordpress.org/browser/trunk/src/wp-admin/menu-header.php?rev=49193#L152.
798        if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
799            $parent_slug        = $url;
800            $first_submenu_item = reset( $submenu[ $menu_item[2] ] );
801            $url                = $first_submenu_item[2];
802        }
803
804        $item = array(
805            'icon'  => $this->prepare_menu_item_icon( $menu_item[6] ),
806            'slug'  => sanitize_title_with_dashes( $menu_item[2] ),
807            'title' => $menu_item[0],
808            'type'  => 'menu-item',
809            'url'   => $this->prepare_menu_item_url( $url, $parent_slug ),
810        );
811
812        $parsed_item = $this->parse_menu_item( $item['title'] );
813        if ( ! empty( $parsed_item ) ) {
814            $item = array_merge( $item, $parsed_item );
815        }
816
817        // Additive: authoritative top-level total from the central menu-badges
818        // registry, when the package is loaded. Overlays any count parsed from
819        // title markup above.
820        if ( 'jetpack' === $menu_item[2] && class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
821            $registry_total = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_total();
822            if ( $registry_total > 0 ) {
823                $item['count'] = $registry_total;
824            }
825        }
826
827        // Additive: classifier-derived `group_id` + `signal` for the redesigned
828        // sidebar. Match key is the raw menu slug (`$menu_item[2]`), which is
829        // what the public plugin's `Sidebar_Classifier` keys nav items by.
830        $item = $this->attach_sidebar_fields( $item, $menu_item[2] );
831
832        return $item;
833    }
834
835    /**
836     * Sets up a submenu item for consumption by Calypso.
837     *
838     * @param array $submenu_item Submenu item.
839     * @param array $menu_item    Menu item.
840     * @return array Prepared submenu item.
841     */
842    private function prepare_submenu_item( array $submenu_item, array $menu_item ) {
843        // Exclude unauthorized submenu items.
844        if ( ! current_user_can( $submenu_item[1] ) ) {
845            return array();
846        }
847
848        // Exclude hidden submenu items.
849        if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
850            return array();
851        }
852
853        $item = array(
854            'parent' => sanitize_title_with_dashes( $menu_item[2] ),
855            'slug'   => sanitize_title_with_dashes( $submenu_item[2] ),
856            'title'  => $submenu_item[0],
857            'type'   => 'submenu-item',
858            'url'    => $this->prepare_menu_item_url( $submenu_item[2], $menu_item[2] ),
859        );
860
861        $parsed_item = $this->parse_menu_item( $item['title'] );
862        if ( ! empty( $parsed_item ) ) {
863            $item = array_merge( $item, $parsed_item );
864        }
865
866        // Additive: authoritative count from the central menu-badges registry,
867        // when the package is loaded. Overlays any count parsed from title
868        // markup above.
869        if ( class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
870            $registry_count = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_for_menu( $submenu_item[2] );
871            if ( $registry_count > 0 ) {
872                $item['count'] = $registry_count;
873            }
874        }
875
876        // Additive: classifier-derived fields. Same lookup contract as the
877        // top-level branch above; submenu rows live under their own `menuSlug`
878        // in the nav model.
879        $item = $this->attach_sidebar_fields( $item, $submenu_item[2], $menu_item[2] );
880
881        return $item;
882    }
883
884    /**
885     * Prepares a menu icon for consumption by Calypso.
886     *
887     * @param string $icon Menu icon.
888     * @return string
889     */
890    private function prepare_menu_item_icon( $icon ) {
891        $img = 'dashicons-admin-generic';
892
893        if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
894            $img = esc_url( $icon );
895
896            if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
897                $img = $icon;
898            } elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
899                $img = $this->prepare_dashicon( $icon );
900            }
901        }
902
903        return $img;
904    }
905
906    /**
907     * Prepares the dashicon for consumption by Calypso. If the dashicon isn't found in a list of known icons
908     * we will return the default dashicon.
909     *
910     * @param string $icon The dashicon string to check.
911     *
912     * @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
913     */
914    private function prepare_dashicon( $icon ) {
915        if ( empty( $this->dashicon_set ) ) {
916            $this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
917        }
918
919        if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
920            return $icon;
921        }
922
923        return 'dashicons-admin-generic';
924    }
925
926    /**
927     * Prepares a menu item url for consumption by Calypso.
928     *
929     * @param string $url         Menu slug.
930     * @param string $parent_slug Optional. Parent menu item slug. Default empty string.
931     * @return string
932     */
933    private function prepare_menu_item_url( $url, $parent_slug = '' ) {
934        // External URLS.
935        if ( preg_match( '/^https?:\/\//', $url ) ) {
936            // Allow URLs pointing to WordPress.com.
937            if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
938                // Calypso needs the domain removed so they're not interpreted as external links.
939                $url = str_replace( 'https://wordpress.com', '', $url );
940                // Replace special characters with their correct entities e.g. &amp; to &.
941                return wp_specialchars_decode( esc_url_raw( $url ) );
942            }
943
944            // Allow URLs pointing to Jetpack.com.
945            if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
946                // Replace special characters with their correct entities e.g. &amp; to &.
947                return wp_specialchars_decode( esc_url_raw( $url ) );
948            }
949
950            // Disallow other external URLs.
951            if ( ! str_starts_with( $url, get_site_url() ) ) {
952                return '';
953            }
954            // The URL matches that of the site, treat it as an internal URL.
955        }
956
957        // Internal URLs.
958        $menu_hook   = get_plugin_page_hook( $url, $parent_slug );
959        $menu_file   = wp_parse_url( $url, PHP_URL_PATH ); // Removes query args to get a file name.
960        $parent_file = wp_parse_url( $parent_slug, PHP_URL_PATH );
961
962        if (
963            ! empty( $menu_hook ) ||
964            (
965                'index.php' !== $url &&
966                file_exists( WP_PLUGIN_DIR . "/$menu_file" ) &&
967                ! file_exists( ABSPATH . "/wp-admin/$menu_file" )
968            )
969        ) {
970            $admin_is_parent = false;
971            if ( ! empty( $parent_slug ) ) {
972                $menu_hook       = get_plugin_page_hook( $parent_slug, 'admin.php' );
973                $admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
974            }
975
976            if (
977                ( ! $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
978                ( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
979            ) {
980                $url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
981            } else {
982                $url = add_query_arg( array( 'page' => $url ), admin_url( 'admin.php' ) );
983            }
984        } elseif ( file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) {
985            $url = admin_url( $url );
986        }
987
988        return wp_specialchars_decode( esc_url_raw( $url ) );
989    }
990
991    /**
992     * "Plugins", "Comments", "Updates" menu items have a count badge when there are updates available.
993     * This method parses that information, removes the associated markup and adds it to the response.
994     *
995     * Also sanitizes the titles from remaining unexpected markup.
996     *
997     * @param string $title Title to parse.
998     * @return array
999     */
1000    private function parse_menu_item( $title ) {
1001        // Handle non-string input
1002        if ( ! is_string( $title ) ) {
1003            return array();
1004        }
1005
1006        $item = array();
1007
1008        if (
1009            str_contains( $title, 'count-' )
1010            && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
1011        ) {
1012
1013            $count = (int) ( $matches[1] );
1014            if ( $count > 0 ) {
1015                // Keep the counter in the item array.
1016                $item['count'] = $count;
1017            }
1018
1019            // Finally remove the markup.
1020            $title = trim( str_replace( $matches[0], '', $title ) );
1021        }
1022
1023        if (
1024            str_contains( $title, 'inline-text' )
1025            && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
1026        ) {
1027
1028            $text = $matches[1];
1029            if ( $text ) {
1030                // Keep the text in the item array.
1031                $item['inlineText'] = $text;
1032            }
1033
1034            // Finally remove the markup.
1035            $title = trim( str_replace( $matches[0], '', $title ) );
1036        }
1037
1038        if (
1039            str_contains( $title, 'inline-icon' )
1040            && preg_match( '/<span class="inline-icon dashicons (dashicons-[^"]+)"[^>]*><\/span>/', $title, $matches )
1041        ) {
1042
1043            $icon = $matches[1];
1044            if ( $icon ) {
1045                // Keep the dashicon slug in the item array.
1046                $item['inlineIcon'] = $icon;
1047            }
1048
1049            // Finally remove the markup.
1050            $title = trim( str_replace( $matches[0], '', $title ) );
1051        }
1052
1053        if (
1054            str_contains( $title, 'awaiting-mod' )
1055            && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
1056        ) {
1057
1058            $text = $matches[1];
1059            if ( $text ) {
1060                // Keep the text in the item array.
1061                $item['badge'] = $text;
1062            }
1063
1064            // Finally remove the markup.
1065            $title = trim( str_replace( $matches[0], '', $title ) );
1066        }
1067
1068        // It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
1069        // We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
1070        $item['title'] = ucfirst( wp_strip_all_tags( $title ) );
1071
1072        return $item;
1073    }
1074}
1075
1076wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Menu' );