*/ private $created_items = array(); /** * Singleton instance. * * @var self|null */ private static $instance = null; /** * Get the singleton instance. * * @return self */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Hook into WordPress. */ private function __construct() { add_action( 'wp_ajax_add-menu-item', array( $this, 'mark_bulk_add_request' ), 0 ); add_action( 'wp_update_nav_menu_item', array( $this, 'preserve_hierarchy' ), 20, 3 ); } /** * Flag the Appearance → Menus AJAX "Add to Menu" request. * * WordPress creates those new items with $menu_id = 0. We only * rewrite parent relationships during that specific flow. */ public function mark_bulk_add_request() { if ( ! current_user_can( 'edit_theme_options' ) ) { return; } if ( ! check_ajax_referer( 'add-menu_item', 'menu-settings-column-nonce', false ) ) { return; } $this->is_bulk_add = true; } /** * After WordPress creates a pending menu item, map it to its source * object and relink any parent/child pairs from this same request. * * @param int $menu_id Menu ID. 0 while adding items via AJAX. * @param int $menu_item_db_id Newly created menu item post ID. * @param array $args Menu item arguments passed to wp_update_nav_menu_item(). */ public function preserve_hierarchy( $menu_id, $menu_item_db_id, $args ) { if ( ! $this->should_process( (int) $menu_id, (int) $menu_item_db_id, $args ) ) { return; } $type = sanitize_key( $args['menu-item-type'] ?? '' ); $object = sanitize_key( $args['menu-item-object'] ?? '' ); $object_id = absint( $args['menu-item-object-id'] ?? 0 ); if ( ! $type || ! $object || ! $object_id ) { return; } $ancestor_ids = $this->get_object_ancestor_ids( $type, $object, $object_id ); if ( null === $ancestor_ids ) { return; } $key = $this->item_key( $type, $object, $object_id ); $this->created_items[ $key ] = array( 'menu_item_id' => (int) $menu_item_db_id, 'type' => $type, 'object' => $object, 'object_id' => $object_id, 'ancestor_ids' => $ancestor_ids, ); $this->relink_created_items(); } /** * Whether this update is a pending item from "Add to Menu". * * @param int $menu_id Menu ID. * @param int $menu_item_db_id Menu item post ID. * @param array $args Menu item arguments. * @return bool */ private function should_process( $menu_id, $menu_item_db_id, $args ) { if ( ! $this->is_bulk_add || ! wp_doing_ajax() || 0 !== $menu_id || $menu_item_db_id < 1 ) { return false; } if ( ! current_user_can( 'edit_theme_options' ) ) { return false; } if ( ! check_ajax_referer( 'add-menu_item', 'menu-settings-column-nonce', false ) ) { return false; } if ( 'nav_menu_item' !== get_post_type( $menu_item_db_id ) ) { return false; } if ( ! is_array( $args ) ) { return false; } /** * Filter whether hierarchy should be preserved for this menu item. * * @param bool $should_process Whether to process the item. * @param int $menu_id Menu ID (0 during AJAX add). * @param int $menu_item_db_id Menu item post ID. * @param array $args Menu item arguments. */ return (bool) apply_filters( 'add_menu_items_easier_should_process', true, $menu_id, $menu_item_db_id, $args ); } /** * Ancestor object IDs for a hierarchical post or term, nearest first. * * Returns null when the object is not hierarchical (or cannot be loaded) * so non-hierarchical items are left alone. * * @param string $type Menu item type (post_type or taxonomy). * @param string $object Post type or taxonomy name. * @param int $object_id Post or term ID. * @return int[]|null */ private function get_object_ancestor_ids( $type, $object, $object_id ) { if ( 'post_type' === $type ) { $post_type = get_post_type_object( $object ); if ( ! $post_type || ! $post_type->hierarchical ) { return null; } $post = get_post( $object_id ); if ( ! $post || $post->post_type !== $object ) { return null; } return array_map( 'absint', get_post_ancestors( $post ) ); } if ( 'taxonomy' === $type ) { $taxonomy = get_taxonomy( $object ); if ( ! $taxonomy || ! $taxonomy->hierarchical ) { return null; } $term = get_term( $object_id, $object ); if ( ! $term || is_wp_error( $term ) ) { return null; } return array_map( 'absint', get_ancestors( $object_id, $object, 'taxonomy' ) ); } return null; } /** * After each new item, recompute parents for the whole batch. * * WordPress may create children before parents (Most Recent / Search * checklists are not tree-ordered). Re-walking the map after every * insert keeps the tree correct regardless of order. */ private function relink_created_items() { foreach ( $this->created_items as $item ) { $menu_item_id = absint( $item['menu_item_id'] ); if ( ! $menu_item_id || 'nav_menu_item' !== get_post_type( $menu_item_id ) ) { continue; } $parent_menu_item_id = absint( $this->find_nearest_created_ancestor_menu_id( $item ) ); if ( $parent_menu_item_id && 'nav_menu_item' !== get_post_type( $parent_menu_item_id ) ) { $parent_menu_item_id = 0; } $current_parent = absint( get_post_meta( $menu_item_id, '_menu_item_menu_item_parent', true ) ); if ( $current_parent === $parent_menu_item_id ) { continue; } update_post_meta( $menu_item_id, '_menu_item_menu_item_parent', (string) $parent_menu_item_id ); } } /** * Menu item ID of the nearest ancestor that was also added in this request. * * @param array $item Created item data. * @return int 0 when no selected ancestor exists. */ private function find_nearest_created_ancestor_menu_id( $item ) { foreach ( $item['ancestor_ids'] as $ancestor_object_id ) { $ancestor_object = $this->ancestor_object_name( $item['type'], $item['object'], $ancestor_object_id ); if ( ! $ancestor_object ) { continue; } $ancestor_key = $this->item_key( $item['type'], $ancestor_object, $ancestor_object_id ); if ( isset( $this->created_items[ $ancestor_key ] ) ) { return (int) $this->created_items[ $ancestor_key ]['menu_item_id']; } } return 0; } /** * Object name (post type or taxonomy) for an ancestor ID. * * @param string $type Menu item type. * @param string $fallback_object Child object name. * @param int $ancestor_object_id Ancestor post or term ID. * @return string */ private function ancestor_object_name( $type, $fallback_object, $ancestor_object_id ) { if ( 'post_type' === $type ) { $post_type = get_post_type( $ancestor_object_id ); return $post_type ? $post_type : $fallback_object; } return $fallback_object; } /** * Stable map key for a source object. * * @param string $type Menu item type. * @param string $object Post type or taxonomy name. * @param int $object_id Post or term ID. * @return string */ private function item_key( $type, $object, $object_id ) { return $type . ':' . $object . ':' . (int) $object_id; } } Add_Menu_Items_Easier::instance();