'query', 'wp.menus.get' => 'get', 'wp.menus.create' => 'create', 'wp.menus.update' => 'update', 'wp.menus.delete' => 'delete', ); private string $action; public function __construct( string $capability ) { $this->action = self::CAPABILITIES[ $capability ] ?? ''; } /** * Run one menus-family command. */ public function run( array $input, string $phase ): array { $validation = $this->validate_input( $input ); if ( isset( $validation['error'] ) ) { return $validation; } if ( 'validate' === $phase && 'update' !== $this->action ) { return array( 'data' => array( 'valid' => true ) ); } return match ( $this->action ) { 'query' => $this->query_items( $input ), 'get' => $this->get_item( $input ), 'create' => $this->create_item( $input ), 'update' => $this->update_item( $input, $phase ), 'delete' => $this->delete_item( $input ), default => $this->error( 'unsupported_capability', 'This menus capability is not supported.' ), }; } private function validate_input( array $input ): array { if ( isset( $input['select'] ) && ! $this->is_path_list( $input['select'] ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } if ( isset( $input['where'] ) ) { $where = $this->validate_where( $input['where'], array( 'input', 'where' ) ); if ( isset( $where['error'] ) ) { return $where; } } if ( isset( $input['order_by'] ) ) { $order_by = $this->validate_order_by( $input['order_by'] ); if ( isset( $order_by['error'] ) ) { return $order_by; } } if ( isset( $input['limit'] ) ) { if ( ! is_numeric( $input['limit'] ) || (int) $input['limit'] < 1 ) { return $this->error( 'invalid_value', 'limit must be a positive integer.', array( 'input', 'limit' ) ); } if ( (int) $input['limit'] > self::MAX_LIMIT ) { return $this->error( 'limit_exceeded', 'limit exceeds the menus query cap.', array( 'input', 'limit' ), array( 'max' => self::MAX_LIMIT ) ); } } if ( isset( $input['cursor'] ) && null !== $input['cursor'] && ! is_string( $input['cursor'] ) ) { return $this->error( 'invalid_value', 'cursor must be null or an opaque string.', array( 'input', 'cursor' ) ); } if ( 'query' === $this->action ) { $cursor = CommandPagination::validate( $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $cursor['error'] ) ) { return $cursor; } } if ( in_array( $this->action, array( 'get', 'update', 'delete' ), true ) && '' === $this->target_key( $input['target'] ?? array() ) ) { return $this->error( 'invalid_target', 'menus target requires id, slug, or name.', array( 'input', 'target' ) ); } if ( 'create' === $this->action ) { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values ) { return $this->error( 'invalid_command', 'menus create requires a values object.', array( 'input', 'values' ) ); } foreach ( array_keys( $values ) as $key ) { if ( 'name' !== (string) $key ) { return $this->error( 'invalid_command', 'menus create supports only values.name.', array( 'input', 'values', (string) $key ) ); } } $name = isset( $values['name'] ) && is_scalar( $values['name'] ) ? trim( (string) $values['name'] ) : ''; if ( '' === $name ) { return $this->error( 'invalid_value', 'menus create requires values.name.', array( 'input', 'values', 'name' ) ); } } if ( 'update' === $this->action && ( ! isset( $input['changes'] ) || ! is_array( $input['changes'] ) || array() === $input['changes'] ) ) { return $this->error( 'invalid_command', 'menus update requires a non-empty changes list.', array( 'input', 'changes' ) ); } return array( 'data' => array( 'valid' => true ) ); } private function query_items( array $input ): array { $documents = array_map( fn ( object $menu ): array => $this->menu_summary_document( $menu ), $this->menu_objects() ); $documents = $this->filter_documents( $documents, $input['where'] ?? null ); $documents = $this->sort_documents( $documents, $input['order_by'] ?? array() ); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LIMIT; $page = CommandPagination::page( $documents, $limit, $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $page['error'] ) ) { return $page; } return array( 'data' => array( 'resource' => 'menus', 'items' => array_map( fn ( array $document ): array => $this->item_result( $document, $input['select'] ?? null ), $page['items'] ), 'page' => array( 'next_cursor' => $page['next_cursor'], 'limit' => $limit, ), ), 'meta' => array( 'total' => count( $documents ), 'returned_count' => count( $page['items'] ), ), ); } private function cursor_context( array $input ): array { return array( 'resource' => 'menus', 'where' => $input['where'] ?? null, 'order_by' => $input['order_by'] ?? array(), ); } private function get_item( array $input ): array { $menu = $this->menu_object_for_target( $input['target'] ?? array() ); if ( null === $menu ) { return $this->error( 'not_found', 'No classic menu matched the requested target.', array( 'input', 'target' ) ); } $document = $this->menu_document( $menu ); return array( 'data' => array( 'resource' => 'menus', 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $input['select'] ?? null ), ), ); } private function create_item( array $input ): array { $name = trim( (string) $input['values']['name'] ); $id = function_exists( 'wp_create_nav_menu' ) ? wp_create_nav_menu( $name ) : 0; if ( function_exists( 'is_wp_error' ) && is_wp_error( $id ) ) { return $this->error( 'execution_failed', $id->get_error_message(), array( 'input', 'values', 'name' ) ); } if ( ! is_numeric( $id ) || (int) $id <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to create the menu.' ); } return array( 'data' => array( 'created' => true, 'target' => array( 'resource' => 'menus', 'id' => (int) $id, ), ), ); } private function update_item( array $input, string $phase ): array { $menu = $this->menu_object_for_target( $input['target'] ?? array() ); if ( null === $menu ) { return $this->error( 'not_found', 'No classic menu matched the requested target.', array( 'input', 'target' ) ); } $original = $this->menu_document( $menu ); $document = $original; foreach ( array_values( $input['changes'] ) as $index => $change ) { $result = $this->apply_change( $document, is_array( $change ) ? $change : array(), $index ); if ( isset( $result['error'] ) ) { return $result; } $document = $result['document']; } $document = $this->normalize_item_order( $document ); if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $persisted = $this->persist_menu_document( (int) $original['id'], $original, $document ); if ( isset( $persisted['error'] ) ) { return $persisted; } return array( 'data' => array( 'updated' => true, 'target' => array( 'resource' => 'menus', 'id' => (int) $original['id'], ), ), ); } private function delete_item( array $input ): array { $menu = $this->menu_object_for_target( $input['target'] ?? array() ); if ( null === $menu ) { return $this->error( 'not_found', 'No classic menu matched the requested target.', array( 'input', 'target' ) ); } $deleted = function_exists( 'wp_delete_nav_menu' ) ? wp_delete_nav_menu( $menu ) : true; if ( function_exists( 'is_wp_error' ) && is_wp_error( $deleted ) ) { return $this->error( 'execution_failed', $deleted->get_error_message() ); } if ( false === $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the menu.' ); } return array( 'data' => array( 'deleted' => (bool) $deleted, 'target' => array( 'resource' => 'menus', 'id' => (int) $menu->term_id, ), ), ); } private function menu_summary_document( object $menu ): array { $document = $this->menu_document_base( $menu ); $document['item_count'] = count( $this->menu_items( (int) $document['id'] ) ); return $document; } private function menu_document( object $menu ): array { $document = $this->menu_document_base( $menu ); $items = $this->menu_items( (int) $document['id'] ); $document['item_count'] = count( $items ); $document['items'] = array_map( fn ( object $item ): array => $this->item_document( $item ), $items ); return $document; } private function menu_document_base( object $menu ): array { $id = (int) ( $menu->term_id ?? $menu->term_taxonomy_id ?? 0 ); return array( 'id' => $id, 'name' => (string) ( $menu->name ?? '' ), 'slug' => (string) ( $menu->slug ?? '' ), 'locations' => $this->locations_for_menu( $id ), ); } private function item_document( object $item ): array { $object_type = (string) ( $item->object ?? '' ); $object_id = (int) ( $item->object_id ?? 0 ); return array( 'id' => (int) ( $item->ID ?? $item->db_id ?? 0 ), 'label' => (string) ( $item->title ?? $item->post_title ?? '' ), 'type' => (string) ( $item->type ?? 'custom' ), 'url' => (string) ( $item->url ?? '' ), 'object' => array( 'type' => $object_type, 'id' => $object_id, ), 'parent' => (int) ( $item->menu_item_parent ?? 0 ), 'order' => (int) ( $item->menu_order ?? 0 ), 'target' => (string) ( $item->target ?? '' ), 'classes' => isset( $item->classes ) && is_array( $item->classes ) ? array_values( array_map( 'strval', $item->classes ) ) : array(), ); } private function persist_menu_document( int $menu_id, array $original, array $document ): array { if ( $document['name'] !== $original['name'] || $document['slug'] !== $original['slug'] ) { $result = function_exists( 'wp_update_nav_menu_object' ) ? wp_update_nav_menu_object( $menu_id, array( 'menu-name' => (string) $document['name'], 'slug' => (string) $document['slug'], ) ) : $menu_id; if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { return $this->error( 'execution_failed', $result->get_error_message() ); } } if ( $document['locations'] !== $original['locations'] ) { $this->persist_locations( $menu_id, $document['locations'] ); } $original_ids = array(); foreach ( $original['items'] ?? array() as $item ) { $original_ids[ (int) ( $item['id'] ?? 0 ) ] = true; } foreach ( $document['items'] ?? array() as $index => $item ) { $id = isset( $item['id'] ) ? (int) $item['id'] : 0; unset( $original_ids[ $id ] ); $result = $this->persist_menu_item( $menu_id, $id, $item, $index + 1 ); if ( isset( $result['error'] ) ) { return $result; } } foreach ( array_keys( $original_ids ) as $removed_id ) { if ( $removed_id > 0 && function_exists( 'wp_delete_post' ) ) { $deleted = wp_delete_post( (int) $removed_id, true ); if ( false === $deleted || null === $deleted ) { return $this->error( 'execution_failed', 'WordPress failed to delete the removed menu item.' ); } } } return array( 'data' => array( 'valid' => true ) ); } private function persist_menu_item( int $menu_id, int $item_id, array $item, int $position ): array { $postarr = array( 'post_title' => (string) ( $item['label'] ?? '' ), 'post_status' => 'publish', 'post_type' => 'nav_menu_item', 'post_parent' => 0, 'menu_order' => $position, ); if ( function_exists( 'sanitize_title' ) ) { $postarr['post_name'] = sanitize_title( (string) ( $item['label'] ?? '' ) ); } if ( $item_id > 0 ) { $postarr['ID'] = $item_id; $result = function_exists( 'wp_update_post' ) ? wp_update_post( $postarr, true ) : $item_id; } else { $result = function_exists( 'wp_insert_post' ) ? wp_insert_post( $postarr, true ) : 0; } if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { return $this->error( 'execution_failed', $result->get_error_message() ); } if ( ! is_numeric( $result ) || (int) $result <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to update the menu item.' ); } $item_id = (int) $result; if ( function_exists( 'wp_set_object_terms' ) ) { $terms_result = wp_set_object_terms( $item_id, array( $menu_id ), 'nav_menu', false ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $terms_result ) ) { return $this->error( 'execution_failed', $terms_result->get_error_message() ); } } $this->persist_menu_item_meta( $item_id, $item ); return array( 'data' => array( 'id' => $item_id ) ); } private function persist_menu_item_meta( int $item_id, array $item ): void { if ( ! function_exists( 'update_post_meta' ) ) { return; } $type = (string) ( $item['type'] ?? 'custom' ); $object = isset( $item['object'] ) && is_array( $item['object'] ) ? $item['object'] : array(); update_post_meta( $item_id, '_menu_item_type', $type ); update_post_meta( $item_id, '_menu_item_menu_item_parent', (string) (int) ( $item['parent'] ?? 0 ) ); update_post_meta( $item_id, '_menu_item_object_id', (string) (int) ( $object['id'] ?? 0 ) ); update_post_meta( $item_id, '_menu_item_object', (string) ( $object['type'] ?? ( 'custom' === $type ? 'custom' : '' ) ) ); update_post_meta( $item_id, '_menu_item_target', (string) ( $item['target'] ?? '' ) ); update_post_meta( $item_id, '_menu_item_classes', isset( $item['classes'] ) && is_array( $item['classes'] ) ? array_values( array_map( 'strval', $item['classes'] ) ) : array() ); update_post_meta( $item_id, '_menu_item_xfn', '' ); update_post_meta( $item_id, '_menu_item_url', (string) ( $item['url'] ?? '' ) ); } private function persist_locations( int $menu_id, array $locations ): void { $current = function_exists( 'get_nav_menu_locations' ) ? get_nav_menu_locations() : array(); $current = is_array( $current ) ? $current : array(); foreach ( $current as $location => $assigned_menu_id ) { if ( (int) $assigned_menu_id === $menu_id ) { unset( $current[ $location ] ); } } foreach ( $locations as $location ) { if ( is_scalar( $location ) && '' !== (string) $location ) { $current[ (string) $location ] = $menu_id; } } if ( function_exists( 'set_theme_mod' ) ) { set_theme_mod( 'nav_menu_locations', $current ); } } private function normalize_item_order( array $document ): array { if ( ! isset( $document['items'] ) || ! is_array( $document['items'] ) ) { return $document; } $items = array_values( $document['items'] ); foreach ( $items as $index => $item ) { if ( is_array( $item ) ) { $item['order'] = $index + 1; $items[ $index ] = $item; } } $document['items'] = $items; return $document; } private function apply_change( array $document, array $change, int $index ): array { $op = isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : ''; $path = isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( ! in_array( $op, array( 'set', 'remove', 'insert' ), true ) || array() === $path || ! $this->is_path( $path ) ) { return $this->error( 'invalid_command', 'changes require op and path.', array( 'input', 'changes', $index ) ); } if ( in_array( $op, array( 'set', 'insert' ), true ) && ! array_key_exists( 'value', $change ) ) { return $this->error( 'invalid_value', 'set and insert changes require value.', array( 'input', 'changes', $index, 'value' ) ); } if ( 'insert' === $op ) { if ( 2 !== count( $path ) || 'items' !== (string) $path[0] || ! $this->is_insert_index( $path[1], count( $document['items'] ?? array() ) ) ) { return $this->error( 'invalid_path', 'insert is supported only for items list paths.', array( 'input', 'changes', $index, 'path' ) ); } $valid = $this->validate_item_document( $change['value'], array( 'input', 'changes', $index, 'value' ), $document, null, false, true ); if ( isset( $valid['error'] ) ) { return $valid; } return array( 'document' => $this->path_insert( $document, $path, $change['value'] ) ); } if ( 'remove' === $op ) { if ( 2 !== count( $path ) || 'items' !== (string) $path[0] || ! $this->is_existing_item_index( $path[1], $document ) ) { return $this->error( 'invalid_path', 'remove is supported only for menu item paths.', array( 'input', 'changes', $index, 'path' ) ); } return array( 'document' => $this->path_remove( $document, $path ) ); } if ( ! $this->writable_path( $path ) ) { return $this->error( 'invalid_path', 'Path is not writable for wp.menus.', array( 'input', 'changes', $index, 'path' ) ); } $valid = $this->validate_set_value( $document, $path, $change['value'], array( 'input', 'changes', $index, 'value' ) ); if ( isset( $valid['error'] ) ) { return $valid; } return array( 'document' => $this->path_set( $document, $path, $change['value'] ) ); } private function writable_path( array $path ): bool { $key = implode( '.', array_map( 'strval', $path ) ); if ( 1 === count( $path ) ) { return in_array( $key, self::WRITABLE_TOP_LEVEL, true ); } if ( 2 === count( $path ) && 'items' === (string) $path[0] ) { return true; } return 3 === count( $path ) && 'items' === (string) $path[0] && in_array( (string) $path[2], self::WRITABLE_ITEM_FIELDS, true ); } private function filter_documents( array $documents, $where ): array { if ( ! is_array( $where ) || array() === $where ) { return $documents; } return array_values( array_filter( $documents, fn ( array $document ): bool => $this->matches_where( $document, $where ) ) ); } private function sort_documents( array $documents, array $order_by ): array { if ( array() === $order_by ) { return $documents; } usort( $documents, function ( array $a, array $b ) use ( $order_by ): int { foreach ( $order_by as $order ) { $path = array_values( $order['path'] ); $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; $cmp = $this->compare_values( $this->path_get( $a, $path ), $this->path_get( $b, $path ) ); if ( 0 !== $cmp ) { return 'desc' === $dir ? -$cmp : $cmp; } } return 0; } ); return $documents; } private function item_result( array $document, $select ): array { return array( 'target' => $this->target_for_document( $document ), 'document' => $this->project_document( $document, $select ), ); } private function project_document( array $document, $select ): array { if ( null === $select || array() === $select || ! is_array( $select ) ) { return $document; } $projected = array(); foreach ( $select as $path ) { if ( ! is_array( $path ) ) { continue; } $found = false; $projection = $this->projection_for_path( $document, array_values( $path ), $found ); if ( $found && is_array( $projection ) ) { $projected = $this->merge_projection( $projected, $projection ); } } return $projected; } private function projection_for_path( $source, array $segments, bool &$found ) { if ( array() === $segments ) { $found = true; return $source; } if ( ! is_array( $source ) ) { $found = false; return null; } $segment = (string) $segments[0]; $rest = array_slice( $segments, 1 ); if ( '*' === $segment ) { $list = array_values( $source ); $found = array() === $list; $out = array(); foreach ( $list as $index => $item ) { $child_found = false; $child = $this->projection_for_path( $item, $rest, $child_found ); if ( $child_found ) { $out[ $index ] = $child; $found = true; } } return array_values( $out ); } if ( ! array_key_exists( $segment, $source ) ) { $found = false; return null; } $child = $this->projection_for_path( $source[ $segment ], $rest, $found ); if ( ! $found ) { return null; } return array( $segment => $child ); } private function merge_projection( array $base, array $addition ): array { foreach ( $addition as $key => $value ) { if ( isset( $base[ $key ] ) && is_array( $base[ $key ] ) && is_array( $value ) ) { $base[ $key ] = $this->merge_projection( $base[ $key ], $value ); continue; } $base[ $key ] = $value; } return $base; } private function target_for_document( array $document ): array { return array( 'resource' => 'menus', 'id' => (int) ( $document['id'] ?? 0 ), ); } private function menu_object_for_target( array $target ): ?object { $key = $this->target_key( $target ); if ( '' === $key || ! function_exists( 'wp_get_nav_menu_object' ) ) { return null; } $menu = wp_get_nav_menu_object( $key ); return is_object( $menu ) ? $menu : null; } private function target_key( array $target ): string { foreach ( array( 'id', 'slug', 'name' ) as $key ) { if ( isset( $target[ $key ] ) && is_scalar( $target[ $key ] ) ) { return (string) $target[ $key ]; } } return ''; } private function menu_objects(): array { $menus = function_exists( 'wp_get_nav_menus' ) ? wp_get_nav_menus( array( 'hide_empty' => false ) ) : array(); return is_array( $menus ) ? array_values( array_filter( $menus, 'is_object' ) ) : array(); } private function menu_items( int $menu_id ): array { $items = function_exists( 'wp_get_nav_menu_items' ) ? wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'any' ) ) : array(); return is_array( $items ) ? array_values( array_filter( $items, 'is_object' ) ) : array(); } private function locations_for_menu( int $menu_id ): array { $locations = function_exists( 'get_nav_menu_locations' ) ? get_nav_menu_locations() : array(); if ( ! is_array( $locations ) ) { return array(); } $names = array(); foreach ( $locations as $location => $assigned_menu_id ) { if ( (int) $assigned_menu_id === $menu_id ) { $names[] = (string) $location; } } sort( $names ); return $names; } private function path_exists( $document, array $segments ): bool { $node = $document; foreach ( $segments as $segment ) { $key = is_scalar( $segment ) ? (string) $segment : ''; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return false; } $node = $node[ $key ]; } return true; } private function path_get( $document, array $segments ) { $node = $document; foreach ( $segments as $segment ) { $node = $node[ (string) $segment ]; } return $node; } private function path_set( array $document, array $segments, $value ): array { $key = (string) $segments[0]; if ( 1 === count( $segments ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, array_slice( $segments, 1 ), $value ); return $document; } private function path_insert( array $document, array $path, $value ): array { $list = isset( $document[ $path[0] ] ) && is_array( $document[ $path[0] ] ) ? array_values( $document[ $path[0] ] ) : array(); $index = $path[1]; if ( '-' === (string) $index ) { $list[] = $value; } else { array_splice( $list, max( 0, (int) $index ), 0, array( $value ) ); } $document[ (string) $path[0] ] = $list; return $document; } private function path_remove( array $document, array $path ): array { $list = isset( $document[ $path[0] ] ) && is_array( $document[ $path[0] ] ) ? array_values( $document[ $path[0] ] ) : array(); $index = (int) $path[1]; if ( isset( $list[ $index ] ) ) { array_splice( $list, $index, 1 ); } $document[ (string) $path[0] ] = $list; return $document; } private function is_path_list( $select ): bool { if ( ! is_array( $select ) ) { return false; } foreach ( $select as $path ) { if ( ! is_array( $path ) || array() === $path ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } } return true; } private function is_path( array $path ): bool { foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } return true; } private function validate_where( $where, array $path ): array { if ( ! is_array( $where ) ) { return $this->error( 'invalid_command', 'where must be a predicate group.', $path ); } if ( isset( $where['all'] ) || isset( $where['any'] ) ) { $keys = array_values( array_intersect( array_keys( $where ), array( 'all', 'any' ) ) ); if ( 1 !== count( $keys ) || ! is_array( $where[ $keys[0] ] ) ) { return $this->error( 'invalid_command', 'Predicate groups must contain exactly one all or any list.', $path ); } foreach ( $where[ $keys[0] ] as $index => $child ) { $result = $this->validate_where( $child, array_merge( $path, array( $keys[0], $index ) ) ); if ( isset( $result['error'] ) ) { return $result; } } return array( 'data' => array( 'valid' => true ) ); } if ( ! isset( $where['path'], $where['op'] ) || ! is_array( $where['path'] ) || ! $this->is_path( $where['path'] ) ) { return $this->error( 'invalid_command', 'Predicates require path and op fields.', $path ); } $key = implode( '.', array_map( 'strval', $where['path'] ) ); if ( ! in_array( $key, self::QUERYABLE_PATHS, true ) ) { return $this->error( 'invalid_path', 'Requested predicate path is not queryable for menus.', array_merge( $path, array( 'path' ) ) ); } $op = is_scalar( $where['op'] ) ? (string) $where['op'] : ''; if ( ! in_array( $op, self::QUERY_OPS, true ) ) { return $this->error( 'invalid_value', 'Predicate op is not part of the query grammar.', array_merge( $path, array( 'op' ) ) ); } if ( ! array_key_exists( 'value', $where ) ) { return $this->error( 'invalid_command', 'Predicates require a value field.', array_merge( $path, array( 'value' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_order_by( $order_by ): array { if ( ! is_array( $order_by ) ) { return $this->error( 'invalid_command', 'order_by must be a list of path sort objects.', array( 'input', 'order_by' ) ); } foreach ( $order_by as $index => $order ) { if ( ! is_array( $order ) || ! isset( $order['path'] ) || ! is_array( $order['path'] ) || ! $this->is_path( $order['path'] ) ) { return $this->error( 'invalid_command', 'order_by entries require an array-segment path.', array( 'input', 'order_by', $index ) ); } $key = implode( '.', array_map( 'strval', $order['path'] ) ); if ( ! in_array( $key, self::ORDERABLE_PATHS, true ) ) { return $this->error( 'invalid_path', 'Requested order_by path is not orderable for menus.', array( 'input', 'order_by', $index, 'path' ) ); } $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; if ( ! in_array( $dir, array( 'asc', 'desc' ), true ) ) { return $this->error( 'invalid_command', 'order_by dir must be asc or desc.', array( 'input', 'order_by', $index, 'dir' ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function matches_where( array $document, array $where ): bool { if ( isset( $where['all'] ) && is_array( $where['all'] ) ) { foreach ( $where['all'] as $predicate ) { if ( ! is_array( $predicate ) || ! $this->matches_where( $document, $predicate ) ) { return false; } } return true; } if ( isset( $where['any'] ) && is_array( $where['any'] ) ) { foreach ( $where['any'] as $predicate ) { if ( is_array( $predicate ) && $this->matches_where( $document, $predicate ) ) { return true; } } return false; } $path = array_values( $where['path'] ); $actual = $this->path_get( $document, $path ); $value = $where['value'] ?? null; return match ( (string) $where['op'] ) { 'exists' => (bool) $value, 'eq' => $this->value_equals( $actual, $value ), 'neq' => ! $this->value_equals( $actual, $value ), 'in' => is_array( $value ) && $this->value_in( $actual, $value ), 'not_in' => is_array( $value ) && ! $this->value_in( $actual, $value ), 'contains' => $this->value_contains( $actual, $value ), 'lt', 'lte', 'gt', 'gte' => $this->ordered_compare( $actual, $value, (string) $where['op'] ), default => false, }; } private function validate_set_value( array $document, array $path, $value, array $error_path ): array { if ( 1 === count( $path ) ) { return match ( (string) $path[0] ) { 'name', 'slug' => is_string( $value ) ? array( 'data' => array( 'valid' => true ) ) : $this->error( 'invalid_value', 'Menu field must be a string.', $error_path ), 'locations' => $this->validate_string_list( $value, $error_path ), 'items' => $this->validate_item_list( $value, $error_path, $document ), default => $this->error( 'invalid_path', 'Path is not writable for wp.menus.', $error_path ), }; } if ( 'items' !== (string) $path[0] || ! $this->is_existing_item_index( $path[1], $document ) ) { return $this->error( 'invalid_path', 'Menu item path does not reference an existing item.', $error_path ); } if ( 2 === count( $path ) ) { return $this->validate_item_document( $value, $error_path, $document, (int) $path[1], true, false ); } return $this->validate_item_field( (string) $path[2], $value, $error_path ); } private function validate_item_list( $value, array $error_path, array $document ): array { if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'items must be a list.', $error_path ); } $ids = array(); foreach ( $value as $index => $item ) { $valid = $this->validate_item_document( $item, array_merge( $error_path, array( $index ) ), array( 'items' => $value ), null, true, false ); if ( isset( $valid['error'] ) ) { return $valid; } if ( isset( $item['id'] ) && (int) $item['id'] > 0 ) { if ( isset( $ids[ (int) $item['id'] ] ) ) { return $this->error( 'invalid_value', 'Menu item ids must be unique.', array_merge( $error_path, array( $index, 'id' ) ) ); } if ( ! $this->item_id_exists( (int) $item['id'], $document ) ) { return $this->error( 'invalid_value', 'Menu item id must belong to the current menu.', array_merge( $error_path, array( $index, 'id' ) ) ); } $ids[ (int) $item['id'] ] = true; } } return array( 'data' => array( 'valid' => true ) ); } private function validate_item_document( $value, array $error_path, array $document, ?int $current_index = null, bool $allow_id = false, bool $is_insert = false ): array { if ( ! is_array( $value ) ) { return $this->error( 'invalid_value', 'Menu item value must be an object.', $error_path ); } foreach ( $value as $field => $field_value ) { $field = (string) $field; if ( ! in_array( $field, self::ITEM_DOCUMENT_FIELDS, true ) ) { return $this->error( 'invalid_path', 'Menu item field is not part of the wp.menus item model.', array_merge( $error_path, array( $field ) ) ); } if ( 'id' === $field ) { if ( ! $allow_id ) { return $this->error( 'invalid_path', 'Menu item id is not writable.', array_merge( $error_path, array( 'id' ) ) ); } if ( ! is_int( $field_value ) || $field_value <= 0 ) { return $this->error( 'invalid_value', 'Menu item id must be a positive integer.', array_merge( $error_path, array( 'id' ) ) ); } if ( null !== $current_index && (int) ( $document['items'][ $current_index ]['id'] ?? 0 ) !== $field_value ) { return $this->error( 'invalid_value', 'Menu item id must match the item being replaced.', array_merge( $error_path, array( 'id' ) ) ); } continue; } if ( 'order' === $field ) { if ( ! is_int( $field_value ) || $field_value < 0 ) { return $this->error( 'invalid_value', 'Menu item order must be a non-negative integer.', array_merge( $error_path, array( 'order' ) ) ); } continue; } $valid = $this->validate_item_field( $field, $field_value, array_merge( $error_path, array( $field ) ) ); if ( isset( $valid['error'] ) ) { return $valid; } } $semantics = $this->validate_item_semantics( $value, $document, $error_path, $is_insert ); if ( isset( $semantics['error'] ) ) { return $semantics; } return array( 'data' => array( 'valid' => true ) ); } private function validate_item_field( string $field, $value, array $error_path ): array { if ( ! in_array( $field, self::WRITABLE_ITEM_FIELDS, true ) ) { return $this->error( 'invalid_path', 'Menu item field is not writable.', $error_path ); } if ( in_array( $field, array( 'label', 'url', 'target' ), true ) ) { return is_string( $value ) ? array( 'data' => array( 'valid' => true ) ) : $this->error( 'invalid_value', 'Menu item field must be a string.', $error_path ); } if ( 'type' === $field ) { return is_string( $value ) && in_array( $value, self::ITEM_TYPES, true ) ? array( 'data' => array( 'valid' => true ) ) : $this->error( 'invalid_value', 'Menu item type is not allowed.', $error_path ); } if ( 'parent' === $field ) { return is_int( $value ) && $value >= 0 ? array( 'data' => array( 'valid' => true ) ) : $this->error( 'invalid_value', 'Menu item numeric field must be a non-negative integer.', $error_path ); } if ( 'classes' === $field ) { return $this->validate_string_list( $value, $error_path ); } if ( 'object' === $field ) { if ( ! is_array( $value ) ) { return $this->error( 'invalid_value', 'Menu item object must be an object.', $error_path ); } if ( isset( $value['type'] ) && ! is_string( $value['type'] ) ) { return $this->error( 'invalid_value', 'Menu item object.type must be a string.', array_merge( $error_path, array( 'type' ) ) ); } if ( isset( $value['id'] ) && ! is_int( $value['id'] ) ) { return $this->error( 'invalid_value', 'Menu item object.id must be an integer.', array_merge( $error_path, array( 'id' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_item_semantics( array $item, array $document, array $error_path, bool $is_insert ): array { $type = isset( $item['type'] ) && is_string( $item['type'] ) ? $item['type'] : ''; if ( '' === $type ) { return $this->error( 'invalid_value', 'Menu item values require type.', array_merge( $error_path, array( 'type' ) ) ); } if ( ! isset( $item['label'] ) || ! is_string( $item['label'] ) || '' === trim( $item['label'] ) ) { return $this->error( 'invalid_value', 'Menu item values require a non-empty label.', array_merge( $error_path, array( 'label' ) ) ); } if ( $is_insert && isset( $item['id'] ) ) { return $this->error( 'invalid_path', 'Inserted menu items must not provide an existing id.', array_merge( $error_path, array( 'id' ) ) ); } $parent = isset( $item['parent'] ) ? (int) $item['parent'] : 0; if ( $parent > 0 && ! $this->item_id_exists( $parent, $document ) ) { return $this->error( 'invalid_value', 'Menu item parent must reference an item in the same menu.', array_merge( $error_path, array( 'parent' ) ) ); } if ( 'custom' === $type ) { if ( ! isset( $item['url'] ) || ! is_string( $item['url'] ) || '' === trim( $item['url'] ) ) { return $this->error( 'invalid_value', 'Custom menu items require a non-empty url.', array_merge( $error_path, array( 'url' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } $object = isset( $item['object'] ) && is_array( $item['object'] ) ? $item['object'] : array(); if ( ! isset( $object['type'], $object['id'] ) || ! is_string( $object['type'] ) || ! is_int( $object['id'] ) || $object['id'] <= 0 ) { return $this->error( 'invalid_value', 'Object-backed menu items require object.type and positive object.id.', array_merge( $error_path, array( 'object' ) ) ); } if ( 'post_type' === $type ) { return $this->validate_post_type_menu_object( $object, array_merge( $error_path, array( 'object' ) ) ); } if ( 'taxonomy' === $type ) { return $this->validate_taxonomy_menu_object( $object, array_merge( $error_path, array( 'object' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_post_type_menu_object( array $menu_object, array $error_path ): array { if ( ! function_exists( 'get_post' ) ) { return array( 'data' => array( 'valid' => true ) ); } $post = get_post( (int) $menu_object['id'] ); if ( ! is_object( $post ) ) { return $this->error( 'invalid_value', 'Menu item object.id must reference an existing post.', array_merge( $error_path, array( 'id' ) ) ); } if ( isset( $post->post_type ) && (string) $post->post_type !== (string) $menu_object['type'] ) { return $this->error( 'invalid_value', 'Menu item object.type must match the referenced post type.', array_merge( $error_path, array( 'type' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } private function validate_taxonomy_menu_object( array $menu_object, array $error_path ): array { if ( ! function_exists( 'get_term' ) ) { return array( 'data' => array( 'valid' => true ) ); } $term = get_term( (int) $menu_object['id'], (string) $menu_object['type'] ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $term ) ) { return $this->error( 'invalid_value', $term->get_error_message(), $error_path ); } if ( ! is_object( $term ) ) { return $this->error( 'invalid_value', 'Menu item object.id must reference an existing term.', array_merge( $error_path, array( 'id' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } private function item_id_exists( int $item_id, array $document ): bool { foreach ( $document['items'] ?? array() as $item ) { if ( (int) ( $item['id'] ?? 0 ) === $item_id ) { return true; } } return false; } private function validate_string_list( $value, array $error_path ): array { if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'Value must be a list of strings.', $error_path ); } foreach ( $value as $index => $item ) { if ( ! is_string( $item ) ) { return $this->error( 'invalid_value', 'Value must be a list of strings.', array_merge( $error_path, array( $index ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } private function is_insert_index( $index, int $count ): bool { if ( '-' === (string) $index ) { return true; } return is_numeric( $index ) && (string) (int) $index === (string) $index && (int) $index >= 0 && (int) $index <= $count; } private function is_existing_item_index( $index, array $document ): bool { $count = isset( $document['items'] ) && is_array( $document['items'] ) ? count( $document['items'] ) : 0; return is_numeric( $index ) && (string) (int) $index === (string) $index && (int) $index >= 0 && (int) $index < $count; } private function value_equals( $actual, $expected ): bool { if ( is_array( $actual ) ) { foreach ( $actual as $item ) { if ( $this->value_equals( $item, $expected ) ) { return true; } } return false; } return (string) $actual === (string) $expected; } private function value_in( $actual, array $expected ): bool { foreach ( $expected as $value ) { if ( $this->value_equals( $actual, $value ) ) { return true; } } return false; } private function value_contains( $actual, $expected ): bool { if ( is_array( $actual ) ) { return $this->value_in( $expected, $actual ); } return false !== strpos( strtolower( (string) $actual ), strtolower( (string) $expected ) ); } private function ordered_compare( $actual, $expected, string $op ): bool { $cmp = $this->compare_values( $actual, $expected ); return match ( $op ) { 'lt' => $cmp < 0, 'lte' => $cmp <= 0, 'gt' => $cmp > 0, 'gte' => $cmp >= 0, default => false, }; } private function compare_values( $left, $right ): int { if ( is_numeric( $left ) && is_numeric( $right ) ) { return (float) $left <=> (float) $right; } return strnatcasecmp( (string) $left, (string) $right ); } private function error( string $code, string $message, ?array $path = null, array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }