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;
}
}