From 7a8d2ad2bbedcccc386551976e34e1d16547f2c6 Mon Sep 17 00:00:00 2001 From: Mrshahidali420 <175938180+Mrshahidali420@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:54:30 +0000 Subject: [PATCH] feat(nav-menu): menu-read/menu-write dispatchers + [emcp_menu] shortcode Adds WordPress nav-menu management as two dispatcher tools (menu-read / menu-write, 13 operations) mirroring the ACF pattern, an [emcp_menu] shortcode for live menus in custom HTML headers, and a self-contained PHPUnit test. Wired always-on in the registrar + bootstrap; CHANGELOG under Unreleased. --- CHANGELOG.md | 5 + .../abilities/class-ability-registrar.php | 5 + .../abilities/class-nav-menu-abilities.php | 897 ++++++++++++++++++ includes/class-bootstrap.php | 3 + includes/class-nav-menu-shortcode.php | 114 +++ tests/NavMenuAbilitiesTest.php | 447 +++++++++ 6 files changed, 1471 insertions(+) create mode 100644 includes/abilities/class-nav-menu-abilities.php create mode 100644 includes/class-nav-menu-shortcode.php create mode 100644 tests/NavMenuAbilitiesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d86362..46977c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to MCP Tools for Elementor are documented in this file. +## [Unreleased] + +### Added +- **WordPress nav-menu management (2 dispatcher tools, 13 operations).** Two tools — `menu-read` and `menu-write` — expose full nav-menu control following the ACF dispatcher pattern (call either with no `operation` to list what it can do). `menu-read`: `list-menus`, `get-menu` (nested item tree), `list-locations`, and `render` (`wp_nav_menu` HTML, for embedding a live menu in a custom header). `menu-write`: create/rename/delete menus, assign/unassign theme locations, and add/update/delete/reorder items (custom links, pages, posts, CPTs, categories, taxonomies) — with update/reorder preserving unspecified fields. Also adds an optional `[emcp_menu]` shortcode so a custom HTML header can render a live menu. Fills the gap where nav menus were otherwise only reachable via the Elementor Pro widget. + ## [3.2.1] > A feature + fix release. **ACF / ACF PRO** becomes a first-class integration — exposed as **two dispatcher tools** (`acf-read` / `acf-write`) behind a new **Plugins** tab — letting an agent build a whole content structure (custom post type → taxonomy → field group → posts with values) end to end. Atomic-element writes get three fixes so v4 pages actually persist. New tools round out AI publishing: **`set-social-image`** fixes wrong social link-previews, and a **`full_bleed`** container preset kills the white strips on Canvas pages. Plus a new **`emcp-plugins`** agent skill, a couple of content-query fixes, and admin polish. diff --git a/includes/abilities/class-ability-registrar.php b/includes/abilities/class-ability-registrar.php index 5b84395..e70f673 100644 --- a/includes/abilities/class-ability-registrar.php +++ b/includes/abilities/class-ability-registrar.php @@ -162,6 +162,11 @@ class EMCP_Tools_Ability_Registrar { $users->register(); $this->ability_names = array_merge( $this->ability_names, $users->get_ability_names() ); + // WordPress Nav Menu abilities (menus, items, theme locations, render). + $nav_menus = new EMCP_Tools_Nav_Menu_Abilities(); + $nav_menus->register(); + $this->ability_names = array_merge( $this->ability_names, $nav_menus->get_ability_names() ); + // ACF abilities — only when Advanced Custom Fields (free or Pro) is active. if ( class_exists( 'EMCP_Tools_ACF_Abilities' ) && EMCP_Tools_ACF_Abilities::acf_active() ) { $acf = new EMCP_Tools_ACF_Abilities(); diff --git a/includes/abilities/class-nav-menu-abilities.php b/includes/abilities/class-nav-menu-abilities.php new file mode 100644 index 0000000..485770b --- /dev/null +++ b/includes/abilities/class-nav-menu-abilities.php @@ -0,0 +1,897 @@ +ability_names; + } + + /** + * Registers the read and write dispatchers. + * + * @since 3.3.0 + */ + public function register(): void { + $this->register_read_dispatcher(); + $this->register_write_dispatcher(); + } + + /** + * The operation catalog: name => { mode, run, desc }. + * + * @since 3.3.0 + * @return array + */ + private function operations(): array { + return array( + 'list-menus' => array( 'mode' => 'read', 'run' => 'op_list_menus', 'desc' => __( 'List all nav menus with id, name, slug, item count, and assigned theme locations. arguments: {}.', 'emcp-tools' ) ), + 'get-menu' => array( 'mode' => 'read', 'run' => 'op_get_menu', 'desc' => __( 'Get one menu and its nested item tree. arguments: { menu: id|slug|name }.', 'emcp-tools' ) ), + 'list-locations' => array( 'mode' => 'read', 'run' => 'op_list_locations', 'desc' => __( 'List the theme\'s registered menu locations and which menu (if any) is assigned. arguments: {}.', 'emcp-tools' ) ), + 'render' => array( 'mode' => 'read', 'run' => 'op_render', 'desc' => __( 'Render a menu to HTML via wp_nav_menu, for embedding in a custom header. arguments: { menu | location, depth?, container?, container_class?, menu_class?, menu_id? }.', 'emcp-tools' ) ), + 'create-menu' => array( 'mode' => 'write', 'run' => 'op_create_menu', 'desc' => __( 'Create a nav menu. arguments: { name }.', 'emcp-tools' ) ), + 'rename-menu' => array( 'mode' => 'write', 'run' => 'op_rename_menu', 'desc' => __( 'Rename a menu. arguments: { menu, name }.', 'emcp-tools' ) ), + 'delete-menu' => array( 'mode' => 'write', 'run' => 'op_delete_menu', 'desc' => __( 'Delete a menu. arguments: { menu }.', 'emcp-tools' ) ), + 'assign-location' => array( 'mode' => 'write', 'run' => 'op_assign_location', 'desc' => __( 'Assign a menu to a registered theme location. arguments: { menu, location }.', 'emcp-tools' ) ), + 'unassign-location' => array( 'mode' => 'write', 'run' => 'op_unassign_location', 'desc' => __( 'Clear a theme location assignment. arguments: { location }.', 'emcp-tools' ) ), + 'add-item' => array( 'mode' => 'write', 'run' => 'op_add_item', 'desc' => __( 'Add a menu item. arguments: { menu, type: custom|page|post||category|taxonomy, object_id?, object?, url?, title?, parent?, position?, target?, classes?, description?, xfn? }.', 'emcp-tools' ) ), + 'update-item' => array( 'mode' => 'write', 'run' => 'op_update_item', 'desc' => __( 'Update a menu item; unspecified fields are preserved. arguments: { item, title?, url?, parent?, position?, target?, classes?, description?, xfn? }.', 'emcp-tools' ) ), + 'delete-item' => array( 'mode' => 'write', 'run' => 'op_delete_item', 'desc' => __( 'Delete a menu item. arguments: { item }.', 'emcp-tools' ) ), + 'reorder-items' => array( 'mode' => 'write', 'run' => 'op_reorder_items', 'desc' => __( 'Reorder / re-parent items in one call; other fields preserved. arguments: { menu, items: [ { id, parent?, position } ] }.', 'emcp-tools' ) ), + ); + } + + /** + * Registers the read dispatcher. + * + * @since 3.3.0 + */ + private function register_read_dispatcher(): void { + $this->ability_names[] = 'emcp-tools/menu-read'; + emcp_tools_register_ability( + 'emcp-tools/menu-read', + array( + 'label' => __( 'Menu Read', 'emcp-tools' ), + 'description' => __( 'Read WordPress nav menus: list menus, get a menu\'s nested item tree, list theme locations, and render a menu to HTML. Call with no "operation" to list the available read operations and their arguments, then call again with { operation, arguments }.', 'emcp-tools' ), + 'category' => 'emcp-tools', + 'execute_callback' => array( $this, 'run_menu_read' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( 'type' => 'string', 'description' => __( 'The read operation to run. Omit to list operations. One of: list-menus, get-menu, list-locations, render.', 'emcp-tools' ) ), + 'arguments' => array( 'type' => 'object', 'description' => __( 'Arguments for the chosen operation (see the catalog returned when operation is omitted).', 'emcp-tools' ) ), + ), + ), + 'meta' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Registers the write dispatcher. + * + * @since 3.3.0 + */ + private function register_write_dispatcher(): void { + $this->ability_names[] = 'emcp-tools/menu-write'; + emcp_tools_register_ability( + 'emcp-tools/menu-write', + array( + 'label' => __( 'Menu Write', 'emcp-tools' ), + 'description' => __( 'Manage WordPress nav menus: create/rename/delete menus, assign theme locations, and add/update/delete/reorder items. Call with no "operation" to list the available write operations and their arguments, then call again with { operation, arguments }.', 'emcp-tools' ), + 'category' => 'emcp-tools', + 'execute_callback' => array( $this, 'run_menu_write' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( 'type' => 'string', 'description' => __( 'The write operation to run. Omit to list operations. One of: create-menu, rename-menu, delete-menu, assign-location, unassign-location, add-item, update-item, delete-item, reorder-items.', 'emcp-tools' ) ), + 'arguments' => array( 'type' => 'object', 'description' => __( 'Arguments for the chosen operation (see the catalog returned when operation is omitted).', 'emcp-tools' ) ), + ), + ), + 'meta' => array( + 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Execute callback for the read dispatcher. + * + * @since 3.3.0 + * @param mixed $input Dispatcher input. + * @return mixed + */ + public function run_menu_read( $input ) { + return $this->dispatch( 'read', $input ); + } + + /** + * Execute callback for the write dispatcher. + * + * @since 3.3.0 + * @param mixed $input Dispatcher input. + * @return mixed + */ + public function run_menu_write( $input ) { + return $this->dispatch( 'write', $input ); + } + + /** + * Both dispatchers require the WordPress menu-management capability. + * + * @since 3.3.0 + * @return bool + */ + public function check_permission(): bool { + return current_user_can( 'edit_theme_options' ); + } + + /** + * Routes an operation to its executor. An empty operation returns the catalog. + * + * @since 3.3.0 + * @param string $mode 'read' or 'write'. + * @param mixed $input Dispatcher input ({ operation, arguments }). + * @return mixed + */ + private function dispatch( string $mode, $input ) { + $operation = isset( $input['operation'] ) ? str_replace( '_', '-', sanitize_key( (string) $input['operation'] ) ) : ''; + if ( '' === $operation ) { + return $this->operations_catalog( $mode ); + } + + $ops = $this->operations(); + if ( ! isset( $ops[ $operation ] ) || $ops[ $operation ]['mode'] !== $mode ) { + return new \WP_Error( + 'unknown_operation', + sprintf( + /* translators: 1: mode (read/write), 2: operation name */ + __( 'Unknown menu %1$s operation "%2$s". Call menu-%1$s with no operation to list the available operations.', 'emcp-tools' ), + $mode, + $operation + ) + ); + } + + $args = ( isset( $input['arguments'] ) && is_array( $input['arguments'] ) ) ? $input['arguments'] : array(); + $run = $ops[ $operation ]['run']; + return $this->$run( $args ); + } + + /** + * Builds the discovery catalog for a mode. + * + * @since 3.3.0 + * @param string $mode 'read' or 'write'. + * @return array + */ + private function operations_catalog( string $mode ): array { + $list = array(); + foreach ( $this->operations() as $name => $op ) { + if ( $op['mode'] !== $mode ) { + continue; + } + $list[] = array( + 'operation' => $name, + 'description' => $op['desc'], + ); + } + return array( + 'mode' => $mode, + 'operations' => $list, + 'usage' => sprintf( + /* translators: %s: mode (read/write) */ + __( 'Call menu-%s again with { "operation": "", "arguments": { ... } }.', 'emcp-tools' ), + $mode + ), + ); + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + /** + * Resolves a menu reference (id, slug, or name) to a menu object. + * + * @since 3.3.0 + * @param mixed $ref Menu id, slug, or name. + * @return \WP_Term|\WP_Error + */ + private function resolve_menu( $ref ) { + if ( is_numeric( $ref ) ) { + $ref = (int) $ref; + } + $menu = wp_get_nav_menu_object( $ref ); + if ( ! $menu ) { + return new \WP_Error( 'menu_not_found', __( 'No nav menu matches the given id, slug, or name.', 'emcp-tools' ) ); + } + return $menu; + } + + /** + * Theme-location slugs a menu is assigned to. + * + * @since 3.3.0 + * @param int $menu_id Menu term id. + * @param array|null $locations Optional pre-fetched location map. + * @return string[] + */ + private function locations_for_menu( $menu_id, $locations = null ) { + if ( null === $locations ) { + $locations = get_nav_menu_locations(); + } + $slugs = array(); + foreach ( (array) $locations as $slug => $id ) { + if ( (int) $id === (int) $menu_id ) { + $slugs[] = (string) $slug; + } + } + return $slugs; + } + + /** + * Resolves the menu id that owns a menu item. + * + * @since 3.3.0 + * @param int $item_id Menu item (post) id. + * @return int|\WP_Error + */ + private function menu_id_for_item( $item_id ) { + $terms = wp_get_object_terms( (int) $item_id, 'nav_menu' ); + if ( is_wp_error( $terms ) ) { + return $terms; + } + if ( empty( $terms ) ) { + return new \WP_Error( 'item_menu_not_found', __( 'The menu item is not attached to a menu.', 'emcp-tools' ) ); + } + return (int) $terms[0]->term_id; + } + + /** + * Validates a parent item id against a menu. + * + * @since 3.3.0 + * @param mixed $parent_id Proposed parent item id (0 = top level). + * @param int $menu_id Menu the item belongs to. + * @return int|\WP_Error The sanitized parent id, or WP_Error if invalid. + */ + private function validate_parent( $parent_id, $menu_id ) { + $parent_id = absint( $parent_id ); + if ( 0 === $parent_id ) { + return 0; + } + if ( ! is_nav_menu_item( $parent_id ) ) { + return new \WP_Error( 'invalid_parent', __( 'The "parent" is not a menu item.', 'emcp-tools' ) ); + } + $owner = $this->menu_id_for_item( $parent_id ); + if ( is_wp_error( $owner ) || (int) $owner !== (int) $menu_id ) { + return new \WP_Error( 'invalid_parent', __( 'The "parent" must be an item in the same menu.', 'emcp-tools' ) ); + } + return $parent_id; + } + + /** + * Sanitizes a space-separated class list. + * + * @since 3.3.0 + * @param string $value Raw class string. + * @return string + */ + private function sanitize_class_list( $value ) { + $parts = preg_split( '/\s+/', trim( (string) $value ) ); + $clean = array(); + foreach ( (array) $parts as $part ) { + if ( '' === $part ) { + continue; + } + $class = sanitize_html_class( $part ); + if ( '' !== $class ) { + $clean[] = $class; + } + } + return implode( ' ', $clean ); + } + + /** + * Builds a nested item tree from a flat item list. + * + * @since 3.3.0 + * @param array $items Flat list from wp_get_nav_menu_items(). + * @return array + */ + private function build_item_tree( $items ) { + $by_parent = array(); + foreach ( (array) $items as $item ) { + $parent = (int) $item->menu_item_parent; + if ( ! isset( $by_parent[ $parent ] ) ) { + $by_parent[ $parent ] = array(); + } + $by_parent[ $parent ][] = $item; + } + return $this->build_item_branch( $by_parent, 0 ); + } + + /** + * Recursively builds one branch of the item tree. + * + * @since 3.3.0 + * @param array $by_parent Items grouped by parent id. + * @param int $parent_id Parent item id (0 for top level). + * @return array + */ + private function build_item_branch( $by_parent, $parent_id ) { + $branch = array(); + if ( empty( $by_parent[ $parent_id ] ) ) { + return $branch; + } + foreach ( $by_parent[ $parent_id ] as $item ) { + $branch[] = array( + 'id' => (int) $item->ID, + 'title' => (string) $item->title, + 'type' => (string) $item->type, + 'object' => (string) $item->object, + 'object_id' => (int) $item->object_id, + 'url' => (string) $item->url, + 'target' => (string) $item->target, + 'classes' => array_values( array_filter( (array) $item->classes ) ), + 'description' => (string) $item->description, + 'xfn' => (string) $item->xfn, + 'parent' => (int) $item->menu_item_parent, + 'position' => (int) $item->menu_order, + 'children' => $this->build_item_branch( $by_parent, (int) $item->ID ), + ); + } + return $branch; + } + + /** + * Resolves item type/object/object_id for add-item. + * + * @since 3.3.0 + * @param array $args Operation arguments. + * @return array|\WP_Error + */ + private function resolve_item_type( array $args ) { + $type = isset( $args['type'] ) ? sanitize_key( (string) $args['type'] ) : 'custom'; + + if ( 'custom' === $type ) { + return array( 'type' => 'custom', 'object' => 'custom', 'object_id' => 0 ); + } + + $object_id = isset( $args['object_id'] ) ? absint( $args['object_id'] ) : 0; + + if ( in_array( $type, array( 'category', 'tag', 'post_tag', 'taxonomy', 'term' ), true ) ) { + $taxonomy = $type; + if ( 'tag' === $type ) { + $taxonomy = 'post_tag'; + } + if ( 'taxonomy' === $type || 'term' === $type ) { + $taxonomy = isset( $args['object'] ) ? sanitize_key( (string) $args['object'] ) : ''; + } + if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) { + return new \WP_Error( 'invalid_taxonomy', __( 'A valid "object" taxonomy is required for taxonomy items.', 'emcp-tools' ) ); + } + if ( ! $object_id || ! get_term( $object_id, $taxonomy ) instanceof \WP_Term ) { + return new \WP_Error( 'invalid_object', __( 'A valid "object_id" (existing term) is required.', 'emcp-tools' ) ); + } + return array( 'type' => 'taxonomy', 'object' => $taxonomy, 'object_id' => $object_id ); + } + + $post_type = $type; + if ( 'post_type' === $type ) { + $post_type = isset( $args['object'] ) ? sanitize_key( (string) $args['object'] ) : ''; + } + if ( '' === $post_type || ! post_type_exists( $post_type ) ) { + return new \WP_Error( 'invalid_type', __( 'Unknown item "type". Use custom, page, post, a CPT slug, category, or taxonomy.', 'emcp-tools' ) ); + } + $post = $object_id ? get_post( $object_id ) : null; + if ( ! ( $post instanceof \WP_Post ) ) { + return new \WP_Error( 'invalid_object', __( 'A valid "object_id" (existing post/page) is required.', 'emcp-tools' ) ); + } + if ( (string) $post->post_type !== $post_type ) { + return new \WP_Error( 'type_mismatch', __( 'The "object_id" does not match the requested "type".', 'emcp-tools' ) ); + } + return array( 'type' => 'post_type', 'object' => $post_type, 'object_id' => (int) $object_id ); + } + + /** + * Collects optional item fields shared by add-item. + * + * @since 3.3.0 + * @param array $args Operation arguments. + * @return array + */ + private function optional_item_fields( array $args ) { + $out = array(); + if ( isset( $args['parent'] ) ) { + $out['menu-item-parent-id'] = absint( $args['parent'] ); + } + if ( isset( $args['position'] ) ) { + $out['menu-item-position'] = (int) $args['position']; + } + if ( isset( $args['target'] ) ) { + $out['menu-item-target'] = ( '_blank' === (string) $args['target'] ) ? '_blank' : ''; + } + if ( isset( $args['classes'] ) ) { + $raw = is_array( $args['classes'] ) ? implode( ' ', $args['classes'] ) : (string) $args['classes']; + $out['menu-item-classes'] = $this->sanitize_class_list( $raw ); + } + if ( isset( $args['description'] ) ) { + $out['menu-item-description'] = sanitize_text_field( (string) $args['description'] ); + } + if ( isset( $args['xfn'] ) ) { + $out['menu-item-xfn'] = sanitize_text_field( (string) $args['xfn'] ); + } + return $out; + } + + // ------------------------------------------------------------------- + // Read operations + // ------------------------------------------------------------------- + + /** + * @since 3.3.0 + * @param array $args Unused. + * @return array|\WP_Error + */ + private function op_list_menus( array $args ) { + $menus = wp_get_nav_menus(); + if ( is_wp_error( $menus ) ) { + return $menus; + } + $locations = get_nav_menu_locations(); + $rows = array(); + foreach ( $menus as $menu ) { + $rows[] = array( + 'id' => (int) $menu->term_id, + 'name' => (string) $menu->name, + 'slug' => (string) $menu->slug, + 'count' => (int) $menu->count, + 'locations' => $this->locations_for_menu( (int) $menu->term_id, $locations ), + ); + } + return array( 'menus' => $rows ); + } + + /** + * @since 3.3.0 + * @param array $args { menu }. + * @return array|\WP_Error + */ + private function op_get_menu( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument (id, slug, or name) is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $items = wp_get_nav_menu_items( $menu->term_id ); + if ( false === $items ) { + $items = array(); + } + return array( + 'id' => (int) $menu->term_id, + 'name' => (string) $menu->name, + 'slug' => (string) $menu->slug, + 'locations' => $this->locations_for_menu( (int) $menu->term_id ), + 'items' => $this->build_item_tree( $items ), + ); + } + + /** + * @since 3.3.0 + * @param array $args Unused. + * @return array + */ + private function op_list_locations( array $args ) { + $registered = get_registered_nav_menus(); + $assigned = get_nav_menu_locations(); + $rows = array(); + foreach ( (array) $registered as $slug => $label ) { + $menu_id = isset( $assigned[ $slug ] ) ? (int) $assigned[ $slug ] : 0; + $menu_name = null; + if ( $menu_id ) { + $obj = wp_get_nav_menu_object( $menu_id ); + if ( $obj ) { + $menu_name = (string) $obj->name; + } + } + $rows[] = array( + 'location' => (string) $slug, + 'description' => (string) $label, + 'menu_id' => $menu_id ? $menu_id : null, + 'menu_name' => $menu_name, + ); + } + return array( 'locations' => $rows ); + } + + /** + * @since 3.3.0 + * @param array $args { menu | location, depth?, container?, container_class?, menu_class?, menu_id? }. + * @return array|\WP_Error + */ + private function op_render( array $args ) { + $wp_args = array( + 'echo' => false, + 'fallback_cb' => false, + ); + if ( isset( $args['menu'] ) && '' !== $args['menu'] ) { + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $wp_args['menu'] = (int) $menu->term_id; + } elseif ( isset( $args['location'] ) && '' !== $args['location'] ) { + $wp_args['theme_location'] = sanitize_key( (string) $args['location'] ); + } else { + return new \WP_Error( 'missing_target', __( 'Provide either "menu" (id/slug/name) or "location".', 'emcp-tools' ) ); + } + if ( isset( $args['depth'] ) ) { + $wp_args['depth'] = (int) $args['depth']; + } + if ( array_key_exists( 'container', $args ) ) { + $container = strtolower( trim( (string) $args['container'] ) ); + $wp_args['container'] = ( '' === $container || 'false' === $container || '0' === $container || 'none' === $container ) ? false : sanitize_key( $container ); + } + if ( isset( $args['container_class'] ) ) { + $wp_args['container_class'] = $this->sanitize_class_list( $args['container_class'] ); + } + if ( isset( $args['menu_class'] ) ) { + $wp_args['menu_class'] = $this->sanitize_class_list( $args['menu_class'] ); + } + if ( isset( $args['menu_id'] ) ) { + $wp_args['menu_id'] = sanitize_html_class( (string) $args['menu_id'] ); + } + $html = wp_nav_menu( $wp_args ); + if ( ! is_string( $html ) ) { + $html = ''; + } + return array( 'html' => $html ); + } + + // ------------------------------------------------------------------- + // Write operations + // ------------------------------------------------------------------- + + /** + * @since 3.3.0 + * @param array $args { name }. + * @return array|\WP_Error + */ + private function op_create_menu( array $args ) { + $name = isset( $args['name'] ) ? sanitize_text_field( (string) $args['name'] ) : ''; + if ( '' === $name ) { + return new \WP_Error( 'missing_name', __( 'The "name" argument is required.', 'emcp-tools' ) ); + } + $menu_id = wp_create_nav_menu( $name ); + if ( is_wp_error( $menu_id ) ) { + return $menu_id; + } + $menu = wp_get_nav_menu_object( (int) $menu_id ); + return array( + 'menu_id' => (int) $menu_id, + 'name' => $menu ? (string) $menu->name : $name, + 'slug' => $menu ? (string) $menu->slug : '', + ); + } + + /** + * @since 3.3.0 + * @param array $args { menu, name }. + * @return array|\WP_Error + */ + private function op_rename_menu( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $name = isset( $args['name'] ) ? sanitize_text_field( (string) $args['name'] ) : ''; + if ( '' === $name ) { + return new \WP_Error( 'missing_name', __( 'The "name" argument is required.', 'emcp-tools' ) ); + } + $result = wp_update_nav_menu_object( (int) $menu->term_id, array( 'menu-name' => $name ) ); + if ( is_wp_error( $result ) ) { + return $result; + } + $updated = wp_get_nav_menu_object( (int) $menu->term_id ); + return array( + 'menu_id' => (int) $menu->term_id, + 'name' => $updated ? (string) $updated->name : $name, + 'slug' => $updated ? (string) $updated->slug : (string) $menu->slug, + ); + } + + /** + * @since 3.3.0 + * @param array $args { menu }. + * @return array|\WP_Error + */ + private function op_delete_menu( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $menu_id = (int) $menu->term_id; + $result = wp_delete_nav_menu( $menu_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + if ( false === $result ) { + return new \WP_Error( 'delete_failed', __( 'The menu could not be deleted.', 'emcp-tools' ) ); + } + return array( 'deleted' => true, 'menu_id' => $menu_id ); + } + + /** + * @since 3.3.0 + * @param array $args { menu, location }. + * @return array|\WP_Error + */ + private function op_assign_location( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $location = isset( $args['location'] ) ? sanitize_key( (string) $args['location'] ) : ''; + if ( '' === $location ) { + return new \WP_Error( 'missing_location', __( 'The "location" argument is required.', 'emcp-tools' ) ); + } + $registered = get_registered_nav_menus(); + if ( ! isset( $registered[ $location ] ) ) { + return new \WP_Error( + 'unregistered_location', + sprintf( + /* translators: %s: location slug */ + __( 'The theme does not register a "%s" menu location. Call list-locations for valid slugs.', 'emcp-tools' ), + $location + ) + ); + } + $locations = get_nav_menu_locations(); + $locations[ $location ] = (int) $menu->term_id; + set_theme_mod( 'nav_menu_locations', $locations ); + return array( 'location' => $location, 'menu_id' => (int) $menu->term_id ); + } + + /** + * @since 3.3.0 + * @param array $args { location }. + * @return array|\WP_Error + */ + private function op_unassign_location( array $args ) { + $location = isset( $args['location'] ) ? sanitize_key( (string) $args['location'] ) : ''; + if ( '' === $location ) { + return new \WP_Error( 'missing_location', __( 'The "location" argument is required.', 'emcp-tools' ) ); + } + $locations = get_nav_menu_locations(); + if ( isset( $locations[ $location ] ) ) { + unset( $locations[ $location ] ); + set_theme_mod( 'nav_menu_locations', $locations ); + } + return array( 'location' => $location, 'menu_id' => null ); + } + + /** + * @since 3.3.0 + * @param array $args { menu, type, ... }. + * @return array|\WP_Error + */ + private function op_add_item( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + $resolved = $this->resolve_item_type( $args ); + if ( is_wp_error( $resolved ) ) { + return $resolved; + } + if ( isset( $args['parent'] ) ) { + $parent = $this->validate_parent( $args['parent'], (int) $menu->term_id ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + } + $title = isset( $args['title'] ) ? sanitize_text_field( (string) $args['title'] ) : ''; + $data = array( + 'menu-item-type' => $resolved['type'], + 'menu-item-object' => $resolved['object'], + 'menu-item-object-id' => (int) $resolved['object_id'], + 'menu-item-status' => 'publish', + ); + if ( '' !== $title ) { + $data['menu-item-title'] = $title; + } + if ( 'custom' === $resolved['type'] ) { + $url = isset( $args['url'] ) ? esc_url_raw( (string) $args['url'] ) : ''; + if ( '' === $url ) { + return new \WP_Error( 'missing_url', __( 'Custom-link items require a "url".', 'emcp-tools' ) ); + } + if ( '' === $title ) { + return new \WP_Error( 'missing_title', __( 'Custom-link items require a "title".', 'emcp-tools' ) ); + } + $data['menu-item-url'] = $url; + } + $data = array_merge( $data, $this->optional_item_fields( $args ) ); + $item_id = wp_update_nav_menu_item( (int) $menu->term_id, 0, $data ); + if ( is_wp_error( $item_id ) ) { + return $item_id; + } + return array( 'item_id' => (int) $item_id ); + } + + /** + * @since 3.3.0 + * @param array $args { item, ... }. + * @return array|\WP_Error + */ + private function op_update_item( array $args ) { + $item_id = isset( $args['item'] ) ? absint( $args['item'] ) : 0; + if ( ! $item_id || ! is_nav_menu_item( $item_id ) ) { + return new \WP_Error( 'item_not_found', __( 'A valid "item" (menu item id) is required.', 'emcp-tools' ) ); + } + $menu_id = $this->menu_id_for_item( $item_id ); + if ( is_wp_error( $menu_id ) ) { + return $menu_id; + } + if ( isset( $args['parent'] ) ) { + $parent = $this->validate_parent( $args['parent'], (int) $menu_id ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + } + $existing = wp_setup_nav_menu_item( get_post( $item_id ) ); + $data = $this->merge_existing_item( $existing, $item_id, $args ); + $result = wp_update_nav_menu_item( (int) $menu_id, $item_id, $data ); + if ( is_wp_error( $result ) ) { + return $result; + } + return array( 'item_id' => $item_id ); + } + + /** + * Merges change args onto an existing item's full field set (no data loss). + * + * @since 3.3.0 + * @param object $existing wp_setup_nav_menu_item() object. + * @param int $item_id Menu item id. + * @param array $args Change arguments. + * @return array + */ + private function merge_existing_item( $existing, $item_id, array $args ) { + return array( + 'menu-item-db-id' => (int) $item_id, + 'menu-item-object-id' => (int) $existing->object_id, + 'menu-item-object' => (string) $existing->object, + 'menu-item-type' => (string) $existing->type, + 'menu-item-status' => 'publish', + 'menu-item-attr-title' => (string) $existing->attr_title, + 'menu-item-parent-id' => isset( $args['parent'] ) ? absint( $args['parent'] ) : (int) $existing->menu_item_parent, + 'menu-item-position' => isset( $args['position'] ) ? (int) $args['position'] : (int) $existing->menu_order, + 'menu-item-title' => isset( $args['title'] ) ? sanitize_text_field( (string) $args['title'] ) : (string) $existing->post_title, + 'menu-item-url' => isset( $args['url'] ) ? esc_url_raw( (string) $args['url'] ) : (string) $existing->url, + 'menu-item-description' => isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : (string) $existing->description, + 'menu-item-target' => isset( $args['target'] ) ? ( '_blank' === (string) $args['target'] ? '_blank' : '' ) : (string) $existing->target, + 'menu-item-classes' => isset( $args['classes'] ) ? $this->sanitize_class_list( is_array( $args['classes'] ) ? implode( ' ', $args['classes'] ) : (string) $args['classes'] ) : implode( ' ', (array) $existing->classes ), + 'menu-item-xfn' => isset( $args['xfn'] ) ? sanitize_text_field( (string) $args['xfn'] ) : (string) $existing->xfn, + ); + } + + /** + * @since 3.3.0 + * @param array $args { item }. + * @return array|\WP_Error + */ + private function op_delete_item( array $args ) { + $item_id = isset( $args['item'] ) ? absint( $args['item'] ) : 0; + if ( ! $item_id || ! is_nav_menu_item( $item_id ) ) { + return new \WP_Error( 'item_not_found', __( 'A valid "item" (menu item id) is required.', 'emcp-tools' ) ); + } + $deleted = wp_delete_post( $item_id, true ); + if ( ! $deleted ) { + return new \WP_Error( 'delete_failed', __( 'The menu item could not be deleted.', 'emcp-tools' ) ); + } + return array( 'deleted' => true, 'item_id' => $item_id ); + } + + /** + * @since 3.3.0 + * @param array $args { menu, items: [ { id, parent?, position } ] }. + * @return array|\WP_Error + */ + private function op_reorder_items( array $args ) { + if ( ! isset( $args['menu'] ) || '' === $args['menu'] ) { + return new \WP_Error( 'missing_menu', __( 'The "menu" argument is required.', 'emcp-tools' ) ); + } + $menu = $this->resolve_menu( $args['menu'] ); + if ( is_wp_error( $menu ) ) { + return $menu; + } + if ( ! isset( $args['items'] ) || ! is_array( $args['items'] ) || empty( $args['items'] ) ) { + return new \WP_Error( 'missing_items', __( 'The "items" argument must be a non-empty array of { id, parent?, position }.', 'emcp-tools' ) ); + } + $menu_id = (int) $menu->term_id; + $updated = 0; + foreach ( $args['items'] as $entry ) { + if ( ! is_array( $entry ) || empty( $entry['id'] ) ) { + continue; + } + $item_id = absint( $entry['id'] ); + if ( ! is_nav_menu_item( $item_id ) ) { + continue; + } + $owner = $this->menu_id_for_item( $item_id ); + if ( is_wp_error( $owner ) || (int) $owner !== $menu_id ) { + continue; + } + if ( array_key_exists( 'parent', $entry ) && is_wp_error( $this->validate_parent( $entry['parent'], $menu_id ) ) ) { + continue; + } + $existing = wp_setup_nav_menu_item( get_post( $item_id ) ); + $change = array(); + if ( array_key_exists( 'parent', $entry ) ) { + $change['parent'] = absint( $entry['parent'] ); + } + if ( isset( $entry['position'] ) ) { + $change['position'] = (int) $entry['position']; + } + $data = $this->merge_existing_item( $existing, $item_id, $change ); + $res = wp_update_nav_menu_item( $menu_id, $item_id, $data ); + if ( ! is_wp_error( $res ) ) { + ++$updated; + } + } + return array( 'menu_id' => $menu_id, 'updated' => $updated ); + } +} diff --git a/includes/class-bootstrap.php b/includes/class-bootstrap.php index 00a6133..6a3a051 100644 --- a/includes/class-bootstrap.php +++ b/includes/class-bootstrap.php @@ -115,6 +115,9 @@ class EMCP_Tools_Bootstrap { require_once EMCP_TOOLS_DIR . 'includes/abilities/class-plugin-abilities.php'; require_once EMCP_TOOLS_DIR . 'includes/abilities/class-theme-abilities.php'; require_once EMCP_TOOLS_DIR . 'includes/abilities/class-user-abilities.php'; + require_once EMCP_TOOLS_DIR . 'includes/abilities/class-nav-menu-abilities.php'; + require_once EMCP_TOOLS_DIR . 'includes/class-nav-menu-shortcode.php'; + add_action( 'init', array( 'EMCP_Tools_Nav_Menu_Shortcode', 'register' ) ); // ACF tools (field values + field group discovery/authoring; writes off by default). require_once EMCP_TOOLS_DIR . 'includes/abilities/class-acf-abilities.php'; // Performance Analyzer (v3.0.0) — read-only server/WP/page audit. diff --git a/includes/class-nav-menu-shortcode.php b/includes/class-nav-menu-shortcode.php new file mode 100644 index 0000000..afeee3f --- /dev/null +++ b/includes/class-nav-menu-shortcode.php @@ -0,0 +1,114 @@ + '', + 'slug' => '', + 'location' => '', + 'depth' => 0, + 'class' => '', + 'container' => 'nav', + 'container_class' => '', + 'menu_id' => '', + ), + $atts, + 'emcp_menu' + ); + + $args = array( + 'echo' => false, + 'fallback_cb' => false, + ); + + if ( '' !== $atts['location'] ) { + $args['theme_location'] = sanitize_key( $atts['location'] ); + } elseif ( '' !== $atts['id'] ) { + $args['menu'] = absint( $atts['id'] ); + } elseif ( '' !== $atts['slug'] ) { + $args['menu'] = sanitize_title( $atts['slug'] ); + } else { + return ''; + } + + $args['depth'] = (int) $atts['depth']; + $container = strtolower( trim( (string) $atts['container'] ) ); + $args['container'] = ( '' === $container || 'false' === $container || '0' === $container || 'none' === $container ) ? false : sanitize_key( $container ); + + if ( '' !== $atts['container_class'] ) { + $args['container_class'] = self::sanitize_classes( $atts['container_class'] ); + } + if ( '' !== $atts['class'] ) { + $args['menu_class'] = self::sanitize_classes( $atts['class'] ); + } + if ( '' !== $atts['menu_id'] ) { + $args['menu_id'] = sanitize_html_class( (string) $atts['menu_id'] ); + } + + $html = wp_nav_menu( $args ); + return is_string( $html ) ? $html : ''; + } + + /** + * Sanitizes a space-separated class list. + * + * @since 3.3.0 + * @param string $value Raw class string. + * @return string + */ + private static function sanitize_classes( $value ) { + $parts = preg_split( '/\s+/', trim( (string) $value ) ); + $clean = array(); + foreach ( (array) $parts as $part ) { + if ( '' === $part ) { + continue; + } + $class = sanitize_html_class( $part ); + if ( '' !== $class ) { + $clean[] = $class; + } + } + return implode( ' ', $clean ); + } +} diff --git a/tests/NavMenuAbilitiesTest.php b/tests/NavMenuAbilitiesTest.php new file mode 100644 index 0000000..5e80f88 --- /dev/null +++ b/tests/NavMenuAbilitiesTest.php @@ -0,0 +1,447 @@ + $v ) { + $this->$k = $v; + } + } + } +} + +if ( ! function_exists( 'sanitize_html_class' ) ) { + function sanitize_html_class( $class, $fallback = '' ) { + $class = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $class ); + return '' === $class ? $fallback : $class; + } +} + +if ( ! function_exists( 'sanitize_title' ) ) { + function sanitize_title( $title ) { + return trim( preg_replace( '/[^a-z0-9]+/', '-', strtolower( (string) $title ) ), '-' ); + } +} + +if ( ! function_exists( 'wp_get_nav_menus' ) ) { + function wp_get_nav_menus( $args = array() ) { + return array_values( $GLOBALS['emcp_nav']['menus'] ); + } +} + +if ( ! function_exists( 'wp_get_nav_menu_object' ) ) { + function wp_get_nav_menu_object( $ref ) { + if ( $ref instanceof WP_Term ) { + return $ref; + } + foreach ( $GLOBALS['emcp_nav']['menus'] as $menu ) { + if ( ( is_numeric( $ref ) && (int) $ref === (int) $menu->term_id ) || $ref === $menu->slug || $ref === $menu->name ) { + return $menu; + } + } + return false; + } +} + +if ( ! function_exists( 'wp_create_nav_menu' ) ) { + function wp_create_nav_menu( $name ) { + $id = $GLOBALS['emcp_nav']['next_menu']++; + $GLOBALS['emcp_nav']['menus'][ $id ] = new WP_Term( array( 'term_id' => $id, 'name' => $name, 'slug' => sanitize_title( $name ), 'count' => 0 ) ); + return $id; + } +} + +if ( ! function_exists( 'wp_update_nav_menu_object' ) ) { + function wp_update_nav_menu_object( $id, $data ) { + if ( ! isset( $GLOBALS['emcp_nav']['menus'][ $id ] ) ) { + return new WP_Error( 'menu_missing', 'no menu' ); + } + if ( isset( $data['menu-name'] ) ) { + $GLOBALS['emcp_nav']['menus'][ $id ]->name = $data['menu-name']; + $GLOBALS['emcp_nav']['menus'][ $id ]->slug = sanitize_title( $data['menu-name'] ); + } + return $id; + } +} + +if ( ! function_exists( 'wp_delete_nav_menu' ) ) { + function wp_delete_nav_menu( $menu ) { + $obj = wp_get_nav_menu_object( $menu ); + if ( ! $obj ) { + return false; + } + unset( $GLOBALS['emcp_nav']['menus'][ $obj->term_id ] ); + foreach ( $GLOBALS['emcp_nav']['item_menu'] as $iid => $mid ) { + if ( (int) $mid === (int) $obj->term_id ) { + unset( $GLOBALS['emcp_nav']['items'][ $iid ], $GLOBALS['emcp_nav']['item_menu'][ $iid ] ); + } + } + return true; + } +} + +if ( ! function_exists( 'wp_setup_nav_menu_item' ) ) { + function wp_setup_nav_menu_item( $post ) { + $id = is_object( $post ) ? (int) $post->ID : (int) $post; + $fields = isset( $GLOBALS['emcp_nav']['items'][ $id ] ) ? $GLOBALS['emcp_nav']['items'][ $id ] : array(); + $default = array( + 'ID' => $id, + 'title' => '', + 'post_title' => '', + 'url' => '', + 'type' => 'custom', + 'object' => '', + 'object_id' => 0, + 'target' => '', + 'classes' => array(), + 'xfn' => '', + 'description' => '', + 'menu_order' => 0, + 'menu_item_parent' => 0, + 'attr_title' => '', + ); + return (object) array_merge( $default, $fields ); + } +} + +if ( ! function_exists( 'wp_get_nav_menu_items' ) ) { + function wp_get_nav_menu_items( $menu, $args = array() ) { + $obj = wp_get_nav_menu_object( $menu ); + if ( ! $obj ) { + return false; + } + $items = array(); + foreach ( $GLOBALS['emcp_nav']['items'] as $iid => $fields ) { + if ( isset( $GLOBALS['emcp_nav']['item_menu'][ $iid ] ) && (int) $GLOBALS['emcp_nav']['item_menu'][ $iid ] === (int) $obj->term_id ) { + $items[] = wp_setup_nav_menu_item( (object) array( 'ID' => $iid ) ); + } + } + usort( + $items, + function ( $a, $b ) { + return $a->menu_order - $b->menu_order; + } + ); + return $items; + } +} + +if ( ! function_exists( 'wp_update_nav_menu_item' ) ) { + function wp_update_nav_menu_item( $menu_id, $item_id, $data = array() ) { + if ( ! isset( $GLOBALS['emcp_nav']['menus'][ $menu_id ] ) ) { + return new WP_Error( 'menu_missing', 'no menu' ); + } + $item_id = (int) $item_id; + if ( 0 === $item_id ) { + $item_id = $GLOBALS['emcp_nav']['next_item']++; + } + // Core resets every omitted menu-item-* key to its default — the data-loss + // this feature guards against — so the stub must mirror that (NOT preserve + // existing values), or the field-preservation tests would be circular. + $get = function ( $key, $default ) use ( $data ) { + return array_key_exists( $key, $data ) ? $data[ $key ] : $default; + }; + $title = (string) $get( 'menu-item-title', '' ); + $GLOBALS['emcp_nav']['items'][ $item_id ] = array( + 'ID' => $item_id, + 'title' => $title, + 'post_title' => $title, + 'url' => (string) $get( 'menu-item-url', '' ), + 'type' => (string) $get( 'menu-item-type', 'custom' ), + 'object' => (string) $get( 'menu-item-object', '' ), + 'object_id' => (int) $get( 'menu-item-object-id', 0 ), + 'target' => (string) $get( 'menu-item-target', '' ), + 'classes' => array_values( array_filter( explode( ' ', (string) $get( 'menu-item-classes', '' ) ) ) ), + 'xfn' => (string) $get( 'menu-item-xfn', '' ), + 'description' => (string) $get( 'menu-item-description', '' ), + 'menu_order' => (int) $get( 'menu-item-position', 0 ), + 'menu_item_parent' => (int) $get( 'menu-item-parent-id', 0 ), + 'attr_title' => (string) $get( 'menu-item-attr-title', '' ), + ); + $GLOBALS['emcp_nav']['item_menu'][ $item_id ] = (int) $menu_id; + $GLOBALS['emcp_test']['posts'][ $item_id ] = new WP_Post( array( 'ID' => $item_id, 'post_type' => 'nav_menu_item' ) ); + return $item_id; + } +} + +if ( ! function_exists( 'is_nav_menu_item' ) ) { + function is_nav_menu_item( $id ) { + return isset( $GLOBALS['emcp_nav']['items'][ (int) $id ] ); + } +} + +if ( ! function_exists( 'wp_delete_post' ) ) { + function wp_delete_post( $id, $force = false ) { + $id = (int) $id; + $existed = isset( $GLOBALS['emcp_nav']['items'][ $id ] ); + unset( $GLOBALS['emcp_nav']['items'][ $id ], $GLOBALS['emcp_nav']['item_menu'][ $id ], $GLOBALS['emcp_test']['posts'][ $id ] ); + return $existed ? new WP_Post( array( 'ID' => $id ) ) : false; + } +} + +if ( ! function_exists( 'wp_get_object_terms' ) ) { + function wp_get_object_terms( $id, $taxonomy ) { + $mid = isset( $GLOBALS['emcp_nav']['item_menu'][ (int) $id ] ) ? (int) $GLOBALS['emcp_nav']['item_menu'][ (int) $id ] : 0; + if ( ! $mid || ! isset( $GLOBALS['emcp_nav']['menus'][ $mid ] ) ) { + return array(); + } + return array( $GLOBALS['emcp_nav']['menus'][ $mid ] ); + } +} + +if ( ! function_exists( 'get_registered_nav_menus' ) ) { + function get_registered_nav_menus() { + return $GLOBALS['emcp_nav']['registered']; + } +} + +if ( ! function_exists( 'get_nav_menu_locations' ) ) { + function get_nav_menu_locations() { + return isset( $GLOBALS['emcp_nav']['theme_mods']['nav_menu_locations'] ) ? $GLOBALS['emcp_nav']['theme_mods']['nav_menu_locations'] : array(); + } +} + +if ( ! function_exists( 'set_theme_mod' ) ) { + function set_theme_mod( $name, $value ) { + $GLOBALS['emcp_nav']['theme_mods'][ $name ] = $value; + } +} + +if ( ! function_exists( 'get_term' ) ) { + function get_term( $term_id, $taxonomy = '' ) { + $key = $taxonomy . ':' . (int) $term_id; + return isset( $GLOBALS['emcp_nav']['terms'][ $key ] ) ? $GLOBALS['emcp_nav']['terms'][ $key ] : null; + } +} + +if ( ! function_exists( 'wp_nav_menu' ) ) { + function wp_nav_menu( $args = array() ) { + if ( isset( $args['theme_location'] ) ) { + $locations = get_nav_menu_locations(); + $ref = isset( $locations[ $args['theme_location'] ] ) ? $locations[ $args['theme_location'] ] : 0; + } else { + $ref = isset( $args['menu'] ) ? $args['menu'] : 0; + } + $obj = wp_get_nav_menu_object( $ref ); + $html = ''; + if ( isset( $args['echo'] ) && false === $args['echo'] ) { + return $html; + } + echo $html; // phpcs:ignore + return $html; + } +} + +// --------------------------------------------------------------------------- + +class NavMenuAbilitiesTest extends TestCase { + + /** @var EMCP_Tools_Nav_Menu_Abilities */ + private $abilities; + + protected function setUp(): void { + emcp_test_reset(); + $GLOBALS['emcp_nav'] = array( + 'menus' => array(), + 'items' => array(), + 'item_menu' => array(), + 'registered' => array(), + 'theme_mods' => array(), + 'terms' => array(), + 'next_menu' => 1, + 'next_item' => 1000, + ); + $GLOBALS['emcp_test']['existing_types'] = array( 'page', 'post' ); + $GLOBALS['emcp_test']['existing_taxes'] = array( 'category', 'post_tag' ); + $this->abilities = new EMCP_Tools_Nav_Menu_Abilities(); + } + + private function write( $operation, $arguments = array() ) { + return $this->abilities->run_menu_write( array( 'operation' => $operation, 'arguments' => $arguments ) ); + } + + private function read( $operation, $arguments = array() ) { + return $this->abilities->run_menu_read( array( 'operation' => $operation, 'arguments' => $arguments ) ); + } + + private function make_menu( $name = 'Main' ) { + $res = $this->write( 'create-menu', array( 'name' => $name ) ); + return (int) $res['menu_id']; + } + + public function test_read_catalog_lists_operations(): void { + $catalog = $this->abilities->run_menu_read( array() ); + $this->assertSame( 'read', $catalog['mode'] ); + $names = array_column( $catalog['operations'], 'operation' ); + $this->assertContains( 'list-menus', $names ); + $this->assertContains( 'get-menu', $names ); + $this->assertContains( 'render', $names ); + $this->assertNotContains( 'create-menu', $names ); + } + + public function test_write_catalog_lists_operations(): void { + $catalog = $this->abilities->run_menu_write( array() ); + $names = array_column( $catalog['operations'], 'operation' ); + $this->assertContains( 'create-menu', $names ); + $this->assertContains( 'add-item', $names ); + $this->assertContains( 'reorder-items', $names ); + } + + public function test_unknown_operation_returns_error(): void { + $this->assertTrue( is_wp_error( $this->read( 'nope' ) ) ); + $this->assertTrue( is_wp_error( $this->write( 'nope' ) ) ); + // Wrong-mode routing: a write op via the read dispatcher. + $this->assertTrue( is_wp_error( $this->read( 'create-menu' ) ) ); + } + + public function test_create_and_list_menus(): void { + $id = $this->make_menu( 'Primary' ); + $this->assertGreaterThan( 0, $id ); + $list = $this->read( 'list-menus' ); + $this->assertCount( 1, $list['menus'] ); + $this->assertSame( 'Primary', $list['menus'][0]['name'] ); + } + + public function test_add_items_build_nested_tree(): void { + $menu = $this->make_menu(); + $parent = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'Services', 'url' => 'https://x.test/services' ) ); + $this->assertArrayHasKey( 'item_id', $parent ); + $child = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'SEO', 'url' => 'https://x.test/seo', 'parent' => $parent['item_id'] ) ); + $this->assertArrayHasKey( 'item_id', $child ); + + $tree = $this->read( 'get-menu', array( 'menu' => $menu ) ); + $this->assertCount( 1, $tree['items'] ); + $this->assertSame( 'Services', $tree['items'][0]['title'] ); + $this->assertCount( 1, $tree['items'][0]['children'] ); + $this->assertSame( 'SEO', $tree['items'][0]['children'][0]['title'] ); + } + + public function test_add_page_item_validates_object(): void { + $menu = $this->make_menu(); + $GLOBALS['emcp_test']['posts'][10] = new WP_Post( array( 'ID' => 10, 'post_title' => 'About', 'post_type' => 'page' ) ); + + $ok = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'page', 'object_id' => 10 ) ); + $this->assertArrayHasKey( 'item_id', $ok ); + + $bad = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'page', 'object_id' => 999 ) ); + $this->assertTrue( is_wp_error( $bad ) ); + } + + public function test_update_item_preserves_unspecified_fields(): void { + $menu = $this->make_menu(); + $item = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'Old', 'url' => 'https://x.test/keep', 'classes' => array( 'a', 'b' ) ) ); + $id = $item['item_id']; + + $this->write( 'update-item', array( 'item' => $id, 'title' => 'New' ) ); + + $tree = $this->read( 'get-menu', array( 'menu' => $menu ) ); + $node = $tree['items'][0]; + $this->assertSame( 'New', $node['title'] ); + $this->assertSame( 'https://x.test/keep', $node['url'] ); + $this->assertSame( array( 'a', 'b' ), $node['classes'] ); + } + + public function test_reorder_items_changes_order_and_preserves_titles(): void { + $menu = $this->make_menu(); + $a = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'A', 'url' => 'https://x.test/a', 'position' => 1 ) ); + $b = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'B', 'url' => 'https://x.test/b', 'position' => 2 ) ); + + $res = $this->write( 'reorder-items', array( 'menu' => $menu, 'items' => array( + array( 'id' => $b['item_id'], 'position' => 1 ), + array( 'id' => $a['item_id'], 'position' => 2 ), + ) ) ); + $this->assertSame( 2, $res['updated'] ); + + $tree = $this->read( 'get-menu', array( 'menu' => $menu ) ); + $this->assertSame( 'B', $tree['items'][0]['title'] ); + $this->assertSame( 'A', $tree['items'][1]['title'] ); + } + + public function test_assign_and_unassign_location(): void { + $menu = $this->make_menu(); + $GLOBALS['emcp_nav']['registered'] = array( 'primary' => 'Primary Menu' ); + + $ok = $this->write( 'assign-location', array( 'menu' => $menu, 'location' => 'primary' ) ); + $this->assertSame( $menu, $ok['menu_id'] ); + + $locations = $this->read( 'list-locations' ); + $this->assertSame( 'primary', $locations['locations'][0]['location'] ); + $this->assertSame( $menu, $locations['locations'][0]['menu_id'] ); + + $bad = $this->write( 'assign-location', array( 'menu' => $menu, 'location' => 'nope' ) ); + $this->assertTrue( is_wp_error( $bad ) ); + + $this->write( 'unassign-location', array( 'location' => 'primary' ) ); + $after = $this->read( 'list-locations' ); + $this->assertNull( $after['locations'][0]['menu_id'] ); + } + + public function test_delete_item_and_menu(): void { + $menu = $this->make_menu(); + $item = $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'X', 'url' => 'https://x.test/x' ) ); + + $this->assertTrue( $this->write( 'delete-item', array( 'item' => $item['item_id'] ) )['deleted'] ); + $tree = $this->read( 'get-menu', array( 'menu' => $menu ) ); + $this->assertCount( 0, $tree['items'] ); + + $this->assertTrue( $this->write( 'delete-menu', array( 'menu' => $menu ) )['deleted'] ); + $this->assertCount( 0, $this->read( 'list-menus' )['menus'] ); + } + + public function test_render_returns_html(): void { + $menu = $this->make_menu(); + $this->write( 'add-item', array( 'menu' => $menu, 'type' => 'custom', 'title' => 'Home', 'url' => 'https://x.test/' ) ); + + $out = $this->read( 'render', array( 'menu' => $menu ) ); + $this->assertArrayHasKey( 'html', $out ); + $this->assertStringContainsString( 'Home', $out['html'] ); + + $this->assertTrue( is_wp_error( $this->read( 'render', array() ) ) ); + } + + public function test_permission_follows_capability(): void { + $GLOBALS['emcp_test']['caps'] = array( 'edit_theme_options' ); + $this->assertTrue( $this->abilities->check_permission() ); + $GLOBALS['emcp_test']['caps'] = array( 'edit_posts' ); + $this->assertFalse( $this->abilities->check_permission() ); + } + + public function test_add_item_rejects_parent_from_another_menu(): void { + $menu_a = $this->make_menu( 'A' ); + $menu_b = $this->make_menu( 'B' ); + $in_a = $this->write( 'add-item', array( 'menu' => $menu_a, 'type' => 'custom', 'title' => 'A1', 'url' => 'https://x.test/a1' ) ); + + $bad = $this->write( 'add-item', array( 'menu' => $menu_b, 'type' => 'custom', 'title' => 'B1', 'url' => 'https://x.test/b1', 'parent' => $in_a['item_id'] ) ); + $this->assertTrue( is_wp_error( $bad ) ); + } +}