get_allowed_post_types(); if ( ! in_array( $post->post_type, $allowed_post_types, true ) ) { return; } wp_enqueue_script( 'ai-editor-navigation-buttons', AI_EDITOR_URL . 'js/insert-div.js', array( 'wp-dom-ready', 'wp-edit-post', 'wp-element' ), AI_EDITOR_VERSION, true ); wp_enqueue_style( 'ai-editor-navigation-buttons', AI_EDITOR_URL . 'css/navigation-buttons.css', array(), AI_EDITOR_VERSION ); $settings = $this->get_plugin_settings(); $first_last_enabled = isset( $settings['enable_first_last'] ) ? $settings['enable_first_last'] : 1; // Get sort parameters $sort_params = $this->get_sort_parameters(); // Fetch navigation posts $nav_posts = $this->get_navigation_posts( $post, $sort_params ); // Helper to add URL params to edit links $ai_add_query_to_edit_link = function( $post_id, $params ) { $url = get_edit_post_link( $post_id, 'raw' ); if ( ! empty( $params ) ) { $url = add_query_arg( $params, $url ); // Add nonce to the URL $url = add_query_arg( AI_Editor_Nonce_Manager::NAVIGATION_NONCE, AI_Editor_Nonce_Manager::create_nonce( AI_Editor_Nonce_Manager::NAVIGATION_ACTION ), $url ); } return $url; }; // Get post type object to access its labels $post_type_object = get_post_type_object( $post->post_type ); // Get singular and plural names for the post type $post_type_singular = $post_type_object ? $post_type_object->labels->singular_name : __( 'Post', 'ai-editor-post-switcher' ); $post_type_plural = $post_type_object ? $post_type_object->labels->name : __( 'Posts', 'ai-editor-post-switcher' ); // Build URL params for navigation $url_params = array(); if ( ! empty( $sort_params['orderby'] ) ) { $url_params['orderby'] = $sort_params['orderby']; } if ( ! empty( $sort_params['order'] ) ) { $url_params['order'] = $sort_params['order']; } if ( ! empty( $sort_params['post_status'] ) && 'all' !== $sort_params['post_status'] ) { $url_params['post_status'] = $sort_params['post_status']; } if ( ! empty( $sort_params['s'] ) ) { $url_params['s'] = $sort_params['s']; } $data = array( 'plugin_url' => AI_EDITOR_URL, 'previous' => $nav_posts['prev_id'] ? $ai_add_query_to_edit_link( $nav_posts['prev_id'], $url_params ) : null, 'previous_title' => $nav_posts['prev_title'], 'next' => $nav_posts['next_id'] ? $ai_add_query_to_edit_link( $nav_posts['next_id'], $url_params ) : null, 'next_title' => $nav_posts['next_title'], 'first' => ( $first_last_enabled && $nav_posts['first_id'] && $nav_posts['first_id'] != $post->ID ) ? $ai_add_query_to_edit_link( $nav_posts['first_id'], $url_params ) : null, 'first_title' => $nav_posts['first_title'], 'last' => ( $first_last_enabled && $nav_posts['last_id'] && $nav_posts['last_id'] != $post->ID ) ? $ai_add_query_to_edit_link( $nav_posts['last_id'], $url_params ) : null, 'last_title' => $nav_posts['last_title'], 'current_post_type' => $post->post_type, 'allowed_post_types' => $allowed_post_types, 'enable_first_last' => $first_last_enabled, 'post_type_info' => array( 'singular' => $post_type_singular, 'plural' => $post_type_plural, ), 'labels' => array( // translators: Label for the button that navigates to the previous content item 'previous' => __( 'Previous', 'ai-editor-post-switcher' ), // translators: Label for the button that navigates to the next content item 'next' => __( 'Next', 'ai-editor-post-switcher' ), // translators: Label for the button that navigates to the first content item 'first' => __( 'First', 'ai-editor-post-switcher' ), // translators: Label for the button that navigates to the last content item 'last' => __( 'Last', 'ai-editor-post-switcher' ), // translators: Label for the search button 'search' => __( 'Search', 'ai-editor-post-switcher' ), // translators: Placeholder text for the search input field 'search_placeholder' => __( 'Type to search...', 'ai-editor-post-switcher' ), // translators: Text shown while content is loading 'loading' => __( 'Loading...', 'ai-editor-post-switcher' ), // translators: Label for the "All" filter button 'all' => __( 'All', 'ai-editor-post-switcher' ), // translators: Message shown when search returns no results 'no_results' => __( 'No results found', 'ai-editor-post-switcher' ), // translators: Message shown when no content items exist 'no_posts_found' => __( 'No results', 'ai-editor-post-switcher' ), // translators: %s is the post type name (plural) 'error_loading' => sprintf( __( 'Error loading %s', 'ai-editor-post-switcher' ), lcfirst( $post_type_plural ) ), // translators: Error message for failed response parsing 'error_parsing' => __( 'Error parsing response', 'ai-editor-post-switcher' ), // translators: Error message for network failure 'network_error' => __( 'Network error', 'ai-editor-post-switcher' ), // translators: Text for status filter tabs 'status_all' => __( 'ALL', 'ai-editor-post-switcher' ), 'status_publish' => __( 'PUBLISHED', 'ai-editor-post-switcher' ), 'status_draft' => __( 'DRAFT', 'ai-editor-post-switcher' ), 'status_pending' => __( 'PENDING', 'ai-editor-post-switcher' ), 'status_private' => __( 'PRIVATE', 'ai-editor-post-switcher' ), 'status_future' => __( 'SCHEDULED', 'ai-editor-post-switcher' ), ), 'status_labels' => array( // translators: Label for published content status 'publish' => __( 'Published', 'ai-editor-post-switcher' ), // translators: Label for draft content status 'draft' => __( 'Draft', 'ai-editor-post-switcher' ), // translators: Label for pending review content status 'pending' => __( 'Pending', 'ai-editor-post-switcher' ), // translators: Label for private content status 'private' => __( 'Private', 'ai-editor-post-switcher' ), // translators: Label for scheduled content status 'future' => __( 'Scheduled', 'ai-editor-post-switcher' ), ), 'nonce' => AI_Editor_Nonce_Manager::create_nonce( AI_Editor_Nonce_Manager::AJAX_ACTION ), 'ajax_nonce_name' => AI_Editor_Nonce_Manager::AJAX_NONCE, ); wp_localize_script( 'ai-editor-navigation-buttons', 'AI_Editor_HeaderData', $data ); } /** * Get sort parameters from URL * * @return array Sort parameters */ private function get_sort_parameters() { // Default values $orderby = 'menu_order'; $order = 'ASC'; $post_status = 'all'; $search = ''; $allowed_fields = array( 'menu_order', 'title', 'date', 'modified', 'author', 'ID' ); // Verify nonce if in admin and processing GET data $verified = false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking if nonce exists before verification if ( is_admin() && isset( $_REQUEST[ AI_Editor_Nonce_Manager::NAVIGATION_NONCE ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified in the next line $nonce = sanitize_text_field( wp_unslash( $_REQUEST[ AI_Editor_Nonce_Manager::NAVIGATION_NONCE ] ) ); $verified = AI_Editor_Nonce_Manager::verify_nonce( $nonce, AI_Editor_Nonce_Manager::NAVIGATION_ACTION ); } // Only process $_GET parameters if nonce is verified or during initial load // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce check is performed above if ( $verified || ! isset( $_GET[ AI_Editor_Nonce_Manager::NAVIGATION_NONCE ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load if ( ! empty( $_GET['orderby'] ) && in_array( $_GET['orderby'], $allowed_fields, true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load $orderby = sanitize_text_field( wp_unslash( $_GET['orderby'] ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load if ( ! empty( $_GET['order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load $order = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ); // Validate order parameter if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { $order = 'ASC'; } } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load if ( isset( $_GET['post_status'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load $post_status = sanitize_text_field( wp_unslash( $_GET['post_status'] ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load if ( isset( $_GET['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce already verified or initial load $search = sanitize_text_field( wp_unslash( $_GET['s'] ) ); } } return array( 'orderby' => $orderby, 'order' => $order, 'post_status' => $post_status, 's' => $search, ); } /** * Get navigation posts (first, last, previous, next) * * @param WP_Post $current_post Current post object * @param array $sort_params Sort parameters * @return array Navigation post IDs and titles */ private function get_navigation_posts( $current_post, $sort_params ) { $result = array( 'first_id' => null, 'first_title' => null, 'last_id' => null, 'last_title' => null, 'prev_id' => null, 'prev_title' => null, 'next_id' => null, 'next_title' => null, ); // Build orderby array with ID as secondary sort $orderby_array = $this->build_orderby_array( $sort_params['orderby'], $sort_params['order'] ); // Base query args $base_args = array( 'post_type' => $current_post->post_type, 'post_status' => $this->get_post_statuses( $sort_params['post_status'] ), 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'orderby' => $orderby_array, ); // Add search if provided if ( ! empty( $sort_params['s'] ) ) { $base_args['s'] = $sort_params['s']; } // Get first post $first_args = $base_args; $first_query = new WP_Query( $first_args ); if ( $first_query->have_posts() ) { $result['first_id'] = $first_query->posts[0]; $result['first_title'] = get_the_title( $result['first_id'] ); } // Get last post $last_args = $base_args; // Reverse the order for last post $last_orderby_array = array(); foreach ( $orderby_array as $field => $direction ) { $last_orderby_array[ $field ] = ( 'ASC' === $direction ) ? 'DESC' : 'ASC'; } $last_args['orderby'] = $last_orderby_array; $last_query = new WP_Query( $last_args ); if ( $last_query->have_posts() ) { $result['last_id'] = $last_query->posts[0]; $result['last_title'] = get_the_title( $result['last_id'] ); } // Get previous and next posts using optimized queries $nav_results = $this->get_adjacent_posts( $current_post, $sort_params ); $result['prev_id'] = $nav_results['prev_id']; $result['prev_title'] = $nav_results['prev_title']; $result['next_id'] = $nav_results['next_id']; $result['next_title'] = $nav_results['next_title']; return $result; } /** * Get adjacent posts (previous and next) * * @param WP_Post $current_post Current post * @param array $sort_params Sort parameters * @return array Previous and next post IDs and titles */ private function get_adjacent_posts( $current_post, $sort_params ) { $result = array( 'prev_id' => null, 'prev_title' => null, 'next_id' => null, 'next_title' => null, ); // Build cache key $cache_key = 'ai_editor_adjacent_' . md5( serialize( array( 'post_id' => $current_post->ID, 'post_type' => $current_post->post_type, 'sort_params' => $sort_params ) ) ); // Try cache first $cached = wp_cache_get( $cache_key, 'ai_editor_post_switcher' ); if ( false !== $cached ) { return $cached; } // For complex sorting (menu_order, title), use optimized WP_Query if ( in_array( $sort_params['orderby'], array( 'menu_order', 'title' ), true ) ) { $result = $this->get_adjacent_posts_by_wp_query( $current_post, $sort_params ); } else { // For date-based or simple field sorting (date, modified, author, ID) $result = $this->get_adjacent_posts_by_date_or_simple( $current_post, $sort_params ); } // Cache the result wp_cache_set( $cache_key, $result, 'ai_editor_post_switcher', HOUR_IN_SECONDS ); return $result; } /** * Get adjacent posts using WP_Query for complex sorting (menu_order, title) * * @param WP_Post $current_post Current post * @param array $sort_params Sort parameters * @return array Previous and next post IDs and titles */ private function get_adjacent_posts_by_wp_query( $current_post, $sort_params ) { $result = array( 'prev_id' => null, 'prev_title' => null, 'next_id' => null, 'next_title' => null, ); // Base query args $base_args = array( 'post_type' => $current_post->post_type, 'post_status' => $this->get_post_statuses( $sort_params['post_status'] ), 'no_found_rows' => true, 'cache_results' => true, // WP_Query internal cache flag 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ); // Add search if provided if ( ! empty( $sort_params['s'] ) ) { $base_args['s'] = $sort_params['s']; } // Build orderby for queries $orderby_array = $this->build_orderby_array( $sort_params['orderby'], $sort_params['order'] ); // For menu_order if ( 'menu_order' === $sort_params['orderby'] ) { $all_args = $base_args; $all_args['posts_per_page'] = -1; // Fetch all matching posts $all_args['orderby'] = $orderby_array; $all_args['fields'] = 'ids'; // We only need IDs for this step $all_query = new WP_Query( $all_args ); if ( $all_query->have_posts() ) { $all_ids = $all_query->posts; $current_index = array_search( $current_post->ID, $all_ids ); if ( false !== $current_index ) { if ( $current_index > 0 ) { $result['prev_id'] = $all_ids[ $current_index - 1 ]; $result['prev_title'] = get_the_title( $result['prev_id'] ); } if ( $current_index < count( $all_ids ) - 1 ) { $result['next_id'] = $all_ids[ $current_index + 1 ]; $result['next_title'] = get_the_title( $result['next_id'] ); } } } return $result; // Return early for menu_order } // For title sorting, fetch a batch and filter if ( 'title' === $sort_params['orderby'] ) { $batch_args = $base_args; $batch_args['posts_per_page'] = 200; // Fetch a batch $batch_args['orderby'] = $orderby_array; // Includes ID for tie-breaking $batch_args['fields'] = 'all'; // Need post_title and ID $batch_query = new WP_Query( $batch_args ); if ( $batch_query->have_posts() ) { $prev_post_obj = null; $next_post_obj = null; $found_current = false; foreach ( $batch_query->posts as $post_obj ) { if ( $post_obj->ID == $current_post->ID ) { $found_current = true; continue; } if ( ! $found_current ) { $prev_post_obj = $post_obj; } elseif ( ! $next_post_obj ) { $next_post_obj = $post_obj; break; } } if ( $prev_post_obj ) { $result['prev_id'] = $prev_post_obj->ID; $result['prev_title'] = $prev_post_obj->post_title; } if ( $next_post_obj ) { $result['next_id'] = $next_post_obj->ID; $result['next_title'] = $next_post_obj->post_title; } } } return $result; } /** * Get adjacent posts for date-based or simple field sorting (date, modified, author, ID) * * @param WP_Post $current_post Current post * @param array $sort_params Sort parameters * @return array Previous and next post IDs and titles */ private function get_adjacent_posts_by_date_or_simple( $current_post, $sort_params ) { $result = array( 'prev_id' => null, 'prev_title' => null, 'next_id' => null, 'next_title' => null, ); $orderby = $sort_params['orderby']; $order = $sort_params['order']; // Base query args, common to both strategies below $common_base_args = array( 'post_type' => $current_post->post_type, 'post_status' => $this->get_post_statuses( $sort_params['post_status'] ), 'no_found_rows' => true, ); if ( ! empty( $sort_params['s'] ) ) { $common_base_args['s'] = $sort_params['s']; } // Strategy 1: Fetch all relevant IDs and find index. // Use this for author/ID sorting, OR for date/modified sorting if NO search term is present (most robust tie-breaking). if ( in_array( $orderby, array( 'author', 'ID' ), true ) || ( in_array( $orderby, array( 'date', 'modified' ), true ) && empty( $sort_params['s'] ) ) ) { $all_args = $common_base_args; $all_args['posts_per_page'] = -1; // Fetch all matching $all_args['fields'] = 'ids'; $all_args['orderby'] = $this->build_orderby_array( $orderby, $order ); // Ensures ID tie-breaker $all_query = new WP_Query( $all_args ); if ( $all_query->have_posts() ) { $all_ids = $all_query->posts; $current_index = array_search( $current_post->ID, $all_ids ); if ( false !== $current_index ) { if ( $current_index > 0 ) { $result['prev_id'] = $all_ids[ $current_index - 1 ]; $result['prev_title'] = get_the_title( $result['prev_id'] ); } if ( $current_index < count( $all_ids ) - 1 ) { $result['next_id'] = $all_ids[ $current_index + 1 ]; $result['next_title'] = get_the_title( $result['next_id'] ); } } } } // Strategy 2: Use date_query for date/modified sorting if a search term IS present. elseif ( in_array( $orderby, array( 'date', 'modified' ), true ) && ! empty( $sort_params['s'] ) ) { $query_base_args = $common_base_args; // Includes 's' search term $query_base_args['posts_per_page'] = 1; $query_base_args['fields'] = 'ids'; $date_field_column = ( 'date' === $orderby ) ? 'post_date' : 'post_modified'; $current_date_value = ( 'date' === $orderby ) ? $current_post->post_date : $current_post->post_modified; // Previous post $prev_args = $query_base_args; if ( 'ASC' === $order ) { // Main sort ASC: previous is older $prev_args['date_query'] = array( array( 'column' => $date_field_column, 'before' => $current_date_value, 'inclusive' => false ) ); $prev_args['orderby'] = array( $orderby => 'DESC', 'ID' => 'DESC' ); // Get latest of the older posts } else { // Main sort DESC: previous is newer $prev_args['date_query'] = array( array( 'column' => $date_field_column, 'after' => $current_date_value, 'inclusive' => false ) ); $prev_args['orderby'] = array( $orderby => 'ASC', 'ID' => 'ASC' ); // Get earliest of the newer posts } $prev_query = new WP_Query( $prev_args ); if ( $prev_query->have_posts() ) { $result['prev_id'] = $prev_query->posts[0]; $result['prev_title'] = get_the_title( $result['prev_id'] ); } // Next post $next_args = $query_base_args; if ( 'ASC' === $order ) { // Main sort ASC: next is newer $next_args['date_query'] = array( array( 'column' => $date_field_column, 'after' => $current_date_value, 'inclusive' => false ) ); $next_args['orderby'] = array( $orderby => 'ASC', 'ID' => 'ASC' ); // Get earliest of the newer posts } else { // Main sort DESC: next is older $next_args['date_query'] = array( array( 'column' => $date_field_column, 'before' => $current_date_value, 'inclusive' => false ) ); $next_args['orderby'] = array( $orderby => 'DESC', 'ID' => 'DESC' ); // Get latest of the older posts } $next_query = new WP_Query( $next_args ); if ( $next_query->have_posts() ) { $result['next_id'] = $next_query->posts[0]; $result['next_title'] = get_the_title( $result['next_id'] ); } } // If $orderby is an unexpected value, it will fall through and return empty $result. return $result; } /** * Build orderby array with ID as secondary/tertiary sort * * @param string $orderby Primary orderby field * @param string $order Sort order (ASC/DESC) * @return array Orderby array for WP_Query */ private function build_orderby_array( $orderby, $order ) { if ( 'menu_order' === $orderby ) { return array( 'menu_order' => $order, 'title' => $order, // Title as secondary for menu_order 'ID' => $order // ID as tertiary for menu_order ); } // For other fields (date, modified, title, author, ID), ID is the crucial tie-breaker. return array( $orderby => $order, 'ID' => $order ); } /** * Get post statuses array * * @param string $status Status parameter * @return array Post statuses */ private function get_post_statuses( $status ) { if ( 'all' === $status || empty( $status ) ) { return array( 'publish', 'draft', 'pending', 'private', 'future' ); } return array( $status ); } /** * Get plugin settings * * @return array Plugin settings. */ private function get_plugin_settings() { $defaults = array( 'delete_on_uninstall' => 0, 'enable_first_last' => 1, ); $settings = get_option( 'ai_editor_post_switcher_settings', array() ); return wp_parse_args( $settings, $defaults ); } /** * Get allowed post types * * @return array Allowed post types. */ private function get_allowed_post_types() { $cache_key = 'ai_editor_allowed_post_types'; $allowed_post_types = get_transient( $cache_key ); if ( false !== $allowed_post_types ) { return $allowed_post_types; } $allowed_post_types = get_option( 'ai_editor_post_types', array( 'post', 'page' ) ); if ( ! is_array( $allowed_post_types ) ) { $allowed_post_types = array(); } set_transient( $cache_key, $allowed_post_types, HOUR_IN_SECONDS ); return $allowed_post_types; } /** * Add plugin action links * * @param array $links Existing links. * @return array Modified links. */ public function add_action_links( $links ) { $settings_link = sprintf( '%s', esc_url( admin_url( 'options-general.php?page=ai-editor-post-switcher' ) ), // translators: Link text for settings link in plugin list esc_html__( 'Settings', 'ai-editor-post-switcher' ) ); array_unshift( $links, $settings_link ); return $links; } /** * Add plugin meta links * * @param array $links Existing links. * @param string $file Plugin file. * @return array Modified links. */ public function add_plugin_meta( $links, $file ) { if ( $file === AI_EDITOR_BASENAME ) { $changelog_link = sprintf( '%s', esc_url( admin_url( 'options-general.php?page=ai-editor-post-switcher&tab=changelog' ) ), // translators: Link text for changelog link in plugin list esc_html__( 'Changelog', 'ai-editor-post-switcher' ) ); $links[] = $changelog_link; } return $links; } }