debug_enabled = (defined('WP_DEBUG') && WP_DEBUG);
add_action('plugins_loaded', array($this, 'check_compatibility'), 5);
}
/**
* The running plugin instance (plugin-file variables are not reliable
* globals under every loader, e.g. WP-CLI).
*/
public static function get_instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function check_compatibility() {
if (version_compare(get_bloginfo('version'), '5.0', '<')) {
add_action('admin_notices', array($this, 'wordpress_version_notice'));
return;
}
if (version_compare(PHP_VERSION, '7.4', '<')) {
add_action('admin_notices', array($this, 'php_version_notice'));
return;
}
if (!function_exists('get_post_types') || !function_exists('get_posts') || !function_exists('wp_create_nonce')) {
add_action('admin_notices', array($this, 'missing_functions_notice'));
return;
}
$this->plugin_ready = true;
// Cache invalidation must run in EVERY context: the block editor saves
// through the REST API, and posts also change via WP-CLI, cron and the
// front end, none of which are is_admin().
add_action('save_post', array($this, 'clear_navigation_cache'));
add_action('deleted_post', array($this, 'clear_navigation_cache'));
add_action('trashed_post', array($this, 'clear_navigation_cache'));
add_action('untrashed_post', array($this, 'clear_navigation_cache'));
// Simple Custom Post Order writes menu_order directly (no save_post),
// so hook its AJAX handlers before they run.
add_action('wp_ajax_update-menu-order', array($this, 'bump_cache_generation'), 1);
add_action('wp_ajax_scpo_reset_order', array($this, 'bump_cache_generation'), 1);
add_action('wp_ajax_scpo_set_position', array($this, 'bump_cache_generation'), 1);
// Plugin (de)activation can register or remove post types.
add_action('activated_plugin', array($this, 'bump_cache_generation'));
add_action('deactivated_plugin', array($this, 'bump_cache_generation'));
if (is_admin()) {
add_action('init', array($this, 'init'), 20);
add_action('wp_ajax_twf_admin_posts_nav_get_posts', array($this, 'ajax_get_nav_posts_with_rate_limit'));
add_action('wp_ajax_twf_admin_posts_nav_update_sort', array($this, 'ajax_update_sort_preference'));
}
}
public function init() {
if (!$this->plugin_ready) {
return;
}
if (!is_admin() || (!current_user_can('edit_posts') && !current_user_can('edit_pages'))) {
return;
}
$this->supported_post_types = $this->get_supported_post_types_cached();
if (empty($this->supported_post_types)) {
return;
}
add_action('edit_form_after_title', array($this, 'add_navigation_buttons'));
add_action('enqueue_block_editor_assets', array($this, 'enqueue_gutenberg_assets'));
add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets'));
if ($this->debug_enabled) {
add_action('admin_notices', array($this, 'show_supported_types_notice'));
}
}
/* ---------------------------------------------------------------------
* Sorting
* ------------------------------------------------------------------ */
/**
* The sort methods offered to users, keyed by the value stored in user meta.
*/
private function get_sort_options() {
return array(
'date' => __('Date Published', 'admin-posts-navigation'),
'alphabetical' => __('Alphabetical', 'admin-posts-navigation'),
'post_id' => __('Post ID', 'admin-posts-navigation'),
'menu_order' => __('Sort Order', 'admin-posts-navigation'),
);
}
private function is_valid_sort_method($sort_method) {
return is_string($sort_method) && array_key_exists($sort_method, $this->get_sort_options());
}
/**
* Whether a post type carries a meaningful manual order: it exposes the
* "Order" attribute, or Simple Custom Post Order manages it.
*/
private function post_type_uses_menu_order($post_type) {
if (post_type_supports($post_type, 'page-attributes')) {
return true;
}
$scpo_options = get_option('scporder_options');
if (is_array($scpo_options) && !empty($scpo_options['objects']) && is_array($scpo_options['objects'])) {
return in_array($post_type, $scpo_options['objects'], true);
}
return false;
}
/**
* Default sort method for a post type when the user has not chosen one.
*/
private function get_default_sort($post_type) {
$default = $this->post_type_uses_menu_order($post_type) ? 'menu_order' : 'date';
/**
* Filter the default sort method for a post type.
*
* @param string $default One of: date, alphabetical, post_id, menu_order.
* @param string $post_type Post type name.
*/
$default = apply_filters('twf_admin_posts_navigation_default_sort', $default, $post_type);
return $this->is_valid_sort_method($default) ? $default : 'date';
}
/**
* Get user's sort preference for a specific post type
*/
private function get_sort_preference($post_type) {
$user_id = get_current_user_id();
$preference_key = 'twf_admin_posts_nav_sort_' . $post_type;
$preference = get_user_meta($user_id, $preference_key, true);
if (!$this->is_valid_sort_method($preference)) {
$preference = $this->get_default_sort($post_type);
}
return $preference;
}
/**
* Query orderby for a sort method, in WP_Query array form so every sort
* has a deterministic tiebreaker.
*/
private function get_orderby_for_sort($sort_method) {
switch ($sort_method) {
case 'alphabetical':
return array('title' => 'ASC', 'ID' => 'ASC');
case 'post_id':
return array('ID' => 'ASC');
case 'menu_order':
return array('menu_order' => 'ASC', 'title' => 'ASC', 'ID' => 'ASC');
case 'date':
default:
return array('date' => 'DESC', 'ID' => 'DESC');
}
}
/**
* AJAX handler for updating sort preference
*/
public function ajax_update_sort_preference() {
$user_id = get_current_user_id();
// Security: Check user permissions first
if (!current_user_can('edit_posts') && !current_user_can('edit_pages')) {
wp_send_json_error(array('message' => __('You do not have permission to perform this action.', 'admin-posts-navigation')), 403);
}
// Security: Use check_ajax_referer for AJAX nonce verification
check_ajax_referer('twf_admin_posts_navigation_sort_' . $user_id, 'nonce');
$post_type = isset($_POST['post_type']) ? sanitize_key(wp_unslash($_POST['post_type'])) : '';
$sort_method = isset($_POST['sort_method']) ? sanitize_key(wp_unslash($_POST['sort_method'])) : '';
if (!$post_type || !post_type_exists($post_type) || !in_array($post_type, $this->supported_post_types, true)) {
wp_send_json_error(array('message' => __('Unsupported post type.', 'admin-posts-navigation')), 400);
}
if (!$this->is_valid_sort_method($sort_method)) {
wp_send_json_error(array('message' => __('Invalid parameters.', 'admin-posts-navigation')), 400);
}
$preference_key = 'twf_admin_posts_nav_sort_' . $post_type;
update_user_meta($user_id, $preference_key, $sort_method);
wp_send_json_success(array('sort_method' => $sort_method));
}
/* ---------------------------------------------------------------------
* Logging / notices
* ------------------------------------------------------------------ */
/**
* Debug logging function that respects WordPress debugging settings
*/
private function debug_log($message, $context = array()) {
if (!$this->debug_enabled) {
return;
}
$log_message = 'TWF Admin Posts Navigation: ' . $message;
if (!empty($context)) {
$log_message .= ' Context: ' . wp_json_encode($context);
}
// Only use WordPress debug logging - no error_log()
if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG && function_exists('wp_debug_log')) {
wp_debug_log($log_message);
}
}
/**
* Show admin notice for errors (only to administrators)
*/
private function show_admin_error($message) {
add_action('admin_notices', function() use ($message) {
if (current_user_can('manage_options')) {
printf(
'
',
esc_html__('Admin Posts Navigation', 'admin-posts-navigation'),
esc_html($message)
);
}
});
}
public function wordpress_version_notice() {
echo '';
printf(
'%s: %s',
esc_html__('Admin Posts Navigation', 'admin-posts-navigation'),
esc_html__('This plugin requires WordPress 5.0 or higher.', 'admin-posts-navigation')
);
echo '
';
}
public function php_version_notice() {
echo '';
printf(
'%s: %s',
esc_html__('Admin Posts Navigation', 'admin-posts-navigation'),
esc_html__('This plugin requires PHP 7.4 or higher.', 'admin-posts-navigation')
);
echo '
';
}
public function missing_functions_notice() {
echo '';
printf(
'%s: %s',
esc_html__('Admin Posts Navigation', 'admin-posts-navigation'),
esc_html__('Required WordPress functions are missing. Please check your WordPress installation.', 'admin-posts-navigation')
);
echo '
';
}
/* ---------------------------------------------------------------------
* Supported post types
* ------------------------------------------------------------------ */
private function get_supported_types_cache_key() {
return $this->cache_key_prefix . 'types_' . $this->get_cache_generation() . '_' . get_current_user_id();
}
private function get_supported_post_types_cached() {
$cache_key = $this->get_supported_types_cache_key();
// Use WordPress Object Cache API
$cached_types = wp_cache_get($cache_key, $this->cache_group);
if ($cached_types !== false && is_array($cached_types)) {
return $cached_types;
}
// Fallback to transients if object cache is not persistent
$cached_types = get_transient($cache_key);
if ($cached_types !== false && is_array($cached_types)) {
wp_cache_set($cache_key, $cached_types, $this->cache_group, 300);
return $cached_types;
}
try {
$supported_types = $this->get_supported_post_types();
wp_cache_set($cache_key, $supported_types, $this->cache_group, 300);
set_transient($cache_key, $supported_types, $this->cache_expiration);
$this->debug_log('Generated fresh supported post types', array('types' => $supported_types));
return $supported_types;
} catch (Exception $e) {
$this->debug_log('Error getting supported post types: ' . $e->getMessage());
$this->show_admin_error(__('Unable to detect supported post types. Using defaults.', 'admin-posts-navigation'));
return array('post', 'page');
}
}
private function get_supported_post_types() {
try {
$all_post_types = get_post_types(array('public' => true), 'objects');
if (!is_array($all_post_types)) {
throw new Exception('get_post_types() did not return an array');
}
$supported_types = array();
$excluded_types = apply_filters('twf_admin_posts_navigation_excluded_types', array(
'attachment',
'revision',
'nav_menu_item',
'custom_css',
'customize_changeset',
'oembed_cache',
'user_request',
'wp_block',
'wp_template',
'wp_template_part',
'wp_global_styles',
'wp_navigation'
));
foreach ($all_post_types as $post_type) {
if (!$post_type || !is_object($post_type) || !isset($post_type->name)) {
continue;
}
if (!in_array($post_type->name, $excluded_types, true) &&
isset($post_type->cap->edit_posts) &&
current_user_can($post_type->cap->edit_posts)) {
$supported_types[] = sanitize_key($post_type->name);
}
}
// Ensure core post types are included if they exist and user has permission
if (post_type_exists('post') && current_user_can('edit_posts') && !in_array('post', $supported_types, true)) {
$supported_types[] = 'post';
}
if (post_type_exists('page') && current_user_can('edit_pages') && !in_array('page', $supported_types, true)) {
$supported_types[] = 'page';
}
return array_values(array_unique($supported_types));
} catch (Exception $e) {
$this->debug_log('Failed to get supported post types: ' . $e->getMessage());
throw new Exception('Failed to get supported post types');
}
}
/**
* Whether navigation should be offered for this post object at all.
*/
private function is_navigable_post($post) {
if (!$post || !is_object($post) || !isset($post->post_type) || !isset($post->ID)) {
return false;
}
if (!in_array($post->post_type, $this->supported_post_types, true)) {
return false;
}
// A brand-new post has no place in the sequence yet.
if (isset($post->post_status) && 'auto-draft' === $post->post_status) {
return false;
}
return current_user_can('edit_post', $post->ID);
}
/* ---------------------------------------------------------------------
* Classic Editor output
* ------------------------------------------------------------------ */
public function add_navigation_buttons() {
global $post;
if (!$this->is_navigable_post($post)) {
return;
}
try {
$nav_data = $this->get_navigation_data_cached($post);
if (!$nav_data || (!$nav_data['prev'] && !$nav_data['next'])) {
return;
}
$post_type_obj = get_post_type_object($post->post_type);
$post_type_label = $post_type_obj && isset($post_type_obj->labels->singular_name)
? $post_type_obj->labels->singular_name
: ucfirst($post->post_type);
$current_sort = $this->get_sort_preference($post->post_type);
echo '';
// Header with dropdown
echo '';
// Navigation buttons
echo '
';
$this->render_classic_button($nav_data['prev'], 'prev');
$this->render_classic_button($nav_data['next'], 'next');
echo '
';
if (isset($nav_data['current_position']) && isset($nav_data['total_posts'])) {
echo '
';
printf(
'%s',
esc_html(sprintf(
// translators: %1$d is current position number, %2$d is total posts, %3$s is post type name
__('Position: %1$d of %2$d %3$s', 'admin-posts-navigation'),
$nav_data['current_position'],
$nav_data['total_posts'],
strtolower($post_type_obj && isset($post_type_obj->labels->name) ? $post_type_obj->labels->name : $post->post_type)
))
);
echo '
';
}
echo '
';
} catch (Exception $e) {
$this->debug_log('Error in add_navigation_buttons: ' . $e->getMessage(), array('post_id' => $post->ID));
}
}
private function render_classic_button($target, $direction) {
if (!$target || !is_object($target)) {
return;
}
$edit_link = get_edit_post_link($target->ID);
if (!$edit_link) {
return;
}
$title = wp_trim_words($target->post_title, 5);
if ('prev' === $direction) {
$label = '← ' . __('Previous', 'admin-posts-navigation') . ': ' . $title;
} else {
$label = __('Next', 'admin-posts-navigation') . ': ' . $title . ' →';
}
printf(
'%s',
esc_url($edit_link),
esc_attr($direction),
esc_attr($target->post_title),
absint($target->ID),
esc_html($label)
);
}
/* ---------------------------------------------------------------------
* Assets
* ------------------------------------------------------------------ */
public function enqueue_gutenberg_assets() {
global $post;
if (!$this->is_navigable_post($post)) {
return;
}
wp_enqueue_script(
'twf-admin-posts-navigation-gutenberg',
plugin_dir_url(__FILE__) . 'assets/gutenberg.js',
array('wp-element', 'wp-components', 'wp-data', 'wp-plugins', 'wp-editor', 'wp-edit-post'),
$this->version,
true
);
$post_type_obj = get_post_type_object($post->post_type);
$current_sort = $this->get_sort_preference($post->post_type);
wp_localize_script('twf-admin-posts-navigation-gutenberg', 'twfAdminPostsNavigation', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('twf_admin_posts_navigation_' . get_current_user_id() . '_' . $post->ID),
'sortNonce' => wp_create_nonce('twf_admin_posts_navigation_sort_' . get_current_user_id()),
'postId' => absint($post->ID),
'postType' => sanitize_key($post->post_type),
'postTypeLabel' => $post_type_obj && isset($post_type_obj->labels->singular_name)
? $post_type_obj->labels->singular_name
: ucfirst($post->post_type),
'postTypeLabelPlural' => $post_type_obj && isset($post_type_obj->labels->name)
? $post_type_obj->labels->name
: ucfirst($post->post_type) . 's',
'currentSort' => $current_sort,
'sortOptions' => $this->get_sort_options(),
'i18n' => array(
'sortBy' => __('Sort By', 'admin-posts-navigation'),
'loading' => __('Loading navigation...', 'admin-posts-navigation'),
'updatingSort' => __('Updating sort order...', 'admin-posts-navigation'),
'previous' => __('Previous', 'admin-posts-navigation'),
'next' => __('Next', 'admin-posts-navigation'),
'untitled' => __('(no title)', 'admin-posts-navigation'),
/* translators: %1$d is current position number, %2$d is total posts, %3$s is post type name */
'position' => __('Position: %1$d of %2$d %3$s', 'admin-posts-navigation'),
/* translators: %s is the plural post type name */
'noAdjacent' => __('No adjacent %s found.', 'admin-posts-navigation'),
/* translators: %1$d is total posts, %2$s is the plural post type name */
'total' => __('Total: %1$d %2$s', 'admin-posts-navigation'),
'loadError' => __('Navigation data could not be loaded.', 'admin-posts-navigation'),
'timeoutError' => __('Request timed out.', 'admin-posts-navigation'),
'sortError' => __('Failed to update sort preference.', 'admin-posts-navigation'),
'configError' => __('Configuration error.', 'admin-posts-navigation'),
),
));
}
public function enqueue_admin_assets($hook) {
if (!in_array($hook, array('post.php', 'post-new.php'), true)) {
return;
}
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if ($screen && isset($screen->post_type) && $screen->post_type && !in_array($screen->post_type, $this->supported_post_types, true)) {
return;
}
wp_enqueue_style(
'twf-admin-posts-navigation-styles',
plugin_dir_url(__FILE__) . 'assets/admin-styles.css',
array(),
$this->version
);
// Inline script for the Classic Editor dropdown
wp_add_inline_script('jquery', '
jQuery(document).ready(function($) {
$(document).on("change", ".twf-admin-posts-nav-sort-dropdown", function() {
var $dropdown = $(this);
var postType = $dropdown.data("post-type");
var sortMethod = $dropdown.val();
var nonce = $dropdown.data("nonce");
if (!postType || !sortMethod || !nonce) return;
$dropdown.prop("disabled", true);
$.post(ajaxurl, {
action: "twf_admin_posts_nav_update_sort",
post_type: postType,
sort_method: sortMethod,
nonce: nonce
})
.done(function(response) {
if (response && response.success) {
location.reload();
} else {
window.alert(' . wp_json_encode(__('Failed to update sort preference.', 'admin-posts-navigation')) . ');
$dropdown.prop("disabled", false);
}
})
.fail(function() {
window.alert(' . wp_json_encode(__('Failed to update sort preference.', 'admin-posts-navigation')) . ');
$dropdown.prop("disabled", false);
});
});
});
');
}
/* ---------------------------------------------------------------------
* AJAX: navigation data
* ------------------------------------------------------------------ */
public function ajax_get_nav_posts_with_rate_limit() {
$post_id = 0;
try {
$user_id = get_current_user_id();
// Security: Check user permissions first
if (!current_user_can('edit_posts') && !current_user_can('edit_pages')) {
wp_send_json_error(array('message' => __('You do not have permission to perform this action.', 'admin-posts-navigation')), 403);
}
$rate_limit_key = 'twf_admin_posts_navigation_rate_limit_' . $user_id . '_' . wp_hash(wp_get_session_token());
$requests = wp_cache_get($rate_limit_key, 'twf_admin_posts_navigation_rate');
if ($requests === false) {
$requests = get_transient($rate_limit_key);
}
if ($requests && $requests >= 30) {
wp_send_json_error(array('message' => __('Rate limit exceeded. Please wait before making another request.', 'admin-posts-navigation')), 429);
}
$new_count = $requests ? $requests + 1 : 1;
wp_cache_set($rate_limit_key, $new_count, 'twf_admin_posts_navigation_rate', 60);
set_transient($rate_limit_key, $new_count, 60);
$post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'admin-posts-navigation')), 400);
}
// Security: Use check_ajax_referer for AJAX nonce verification
check_ajax_referer('twf_admin_posts_navigation_' . $user_id . '_' . $post_id, 'nonce');
$post = get_post($post_id);
if (!$post || !is_object($post) || !isset($post->post_type)) {
wp_send_json_error(array('message' => __('Invalid post.', 'admin-posts-navigation')), 404);
}
if (!in_array($post->post_type, $this->supported_post_types, true)) {
wp_send_json_error(array('message' => __('Unsupported post type.', 'admin-posts-navigation')), 400);
}
// Security: Check user can edit this specific post
if (!current_user_can('edit_post', $post_id)) {
wp_send_json_error(array('message' => __('You do not have permission to edit this post.', 'admin-posts-navigation')), 403);
}
$nav_data = $this->get_navigation_data_cached($post);
$response = array(
'prev' => null,
'next' => null,
'current_position' => isset($nav_data['current_position']) ? absint($nav_data['current_position']) : 0,
'total_posts' => isset($nav_data['total_posts']) ? absint($nav_data['total_posts']) : 0,
'sort_method' => $this->get_sort_preference($post->post_type),
);
foreach (array('prev', 'next') as $direction) {
if ($nav_data && isset($nav_data[$direction]) && is_object($nav_data[$direction])) {
$edit_link = get_edit_post_link($nav_data[$direction]->ID, 'raw');
if ($edit_link) {
$response[$direction] = array(
'id' => absint($nav_data[$direction]->ID),
'title' => sanitize_text_field($nav_data[$direction]->post_title),
'edit_link' => esc_url_raw($edit_link),
);
}
}
}
wp_send_json_success($response);
} catch (Exception $e) {
$this->debug_log('AJAX error: ' . $e->getMessage(), array('post_id' => $post_id));
wp_send_json_error(array('message' => __('Navigation data could not be loaded.', 'admin-posts-navigation')), 500);
}
}
/* ---------------------------------------------------------------------
* Navigation data
* ------------------------------------------------------------------ */
private function get_navigation_data_cached($current_post) {
if (!$current_post || !is_object($current_post) || !isset($current_post->ID)) {
return null;
}
$current_sort = $this->get_sort_preference($current_post->post_type);
// The neighbour set depends on the user (capabilities), the post, the
// sort method and the cache generation.
$cache_key = $this->cache_key_prefix . 'nav_'
. $this->get_cache_generation() . '_'
. get_current_user_id() . '_'
. $current_post->ID . '_'
. $current_post->post_type . '_'
. $current_sort;
$cached_data = wp_cache_get($cache_key, $this->cache_group);
if ($cached_data !== false && is_array($cached_data)) {
return $cached_data;
}
$cached_data = get_transient($cache_key);
if ($cached_data !== false && is_array($cached_data)) {
wp_cache_set($cache_key, $cached_data, $this->cache_group, 900);
return $cached_data;
}
try {
$nav_data = $this->get_navigation_data($current_post, $current_sort);
if ($nav_data) {
wp_cache_set($cache_key, $nav_data, $this->cache_group, 900);
set_transient($cache_key, $nav_data, 1800);
$this->debug_log('Generated fresh navigation data', array('post_id' => $current_post->ID));
}
return $nav_data;
} catch (Exception $e) {
$this->debug_log('Error getting navigation data: ' . $e->getMessage(), array('post_id' => $current_post->ID));
return null;
}
}
private function get_navigation_data($current_post, $sort_method) {
try {
if (!$current_post || !is_object($current_post) || !isset($current_post->ID) || !isset($current_post->post_type)) {
throw new Exception('Invalid post object');
}
$post_type = sanitize_key($current_post->post_type);
$orderby = $this->get_orderby_for_sort($sort_method);
/**
* Filter the query ordering.
*
* Receives WP_Query array form (column => direction). Returning a
* string is still supported: it is used together with the
* `twf_admin_posts_navigation_order` filter as before.
*
* @param array|string $orderby Orderby (array form by default).
* @param string $post_type Post type name.
*/
$orderby = apply_filters('twf_admin_posts_navigation_orderby', $orderby, $post_type);
$legacy_order = ('date' === $sort_method) ? 'DESC' : 'ASC';
$order = apply_filters('twf_admin_posts_navigation_order', $legacy_order, $post_type);
$args = array(
'post_type' => $post_type,
'post_status' => array('publish', 'draft', 'pending', 'private', 'future'),
/**
* Filter the maximum number of posts considered for navigation.
* -1 (default) means all posts of the type. Only IDs are fetched.
*/
'posts_per_page' => (int) apply_filters('twf_admin_posts_navigation_max_posts', -1, $post_type),
'fields' => 'ids',
'suppress_filters' => false,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'cache_results' => true,
);
if (is_array($orderby)) {
$args['orderby'] = array();
foreach ($orderby as $column => $direction) {
$args['orderby'][sanitize_key($column)] = ('DESC' === strtoupper((string) $direction)) ? 'DESC' : 'ASC';
}
} else {
$args['orderby'] = sanitize_text_field((string) $orderby);
$args['order'] = ('DESC' === strtoupper((string) $order)) ? 'DESC' : 'ASC';
}
// Users who cannot edit others' posts only ever navigate their own.
$post_type_obj = get_post_type_object($post_type);
if ($post_type_obj && isset($post_type_obj->cap->edit_others_posts) && !current_user_can($post_type_obj->cap->edit_others_posts)) {
$args['author'] = get_current_user_id();
}
$args = apply_filters('twf_admin_posts_navigation_query_args', $args, $current_post);
$all_posts = get_posts($args);
if (!is_array($all_posts)) {
throw new Exception('get_posts() did not return an array');
}
$all_posts = array_map('intval', $all_posts);
$current_index = array_search((int) $current_post->ID, $all_posts, true);
$prev_post = null;
$next_post = null;
$current_position = 0;
$total_posts = count($all_posts);
if ($current_index !== false) {
$current_position = $current_index + 1;
$prev_post = $this->find_editable_neighbor($all_posts, $current_index, -1);
$next_post = $this->find_editable_neighbor($all_posts, $current_index, 1);
}
return array(
'prev' => $prev_post,
'next' => $next_post,
'current_position' => $current_position,
'total_posts' => $total_posts,
);
} catch (Exception $e) {
$this->debug_log('Navigation data generation failed: ' . $e->getMessage());
throw new Exception('Navigation data generation failed');
}
}
/**
* Walk from the current index in the given direction until a post the
* current user may edit is found.
*/
private function find_editable_neighbor(array $ids, $current_index, $step) {
$count = count($ids);
$steps = 0;
for ($i = $current_index + $step; $i >= 0 && $i < $count; $i += $step) {
if (++$steps > $this->neighbor_search_limit) {
break;
}
if (!current_user_can('edit_post', $ids[$i])) {
continue;
}
$post = get_post($ids[$i]);
if ($post) {
return $post;
}
}
return null;
}
/* ---------------------------------------------------------------------
* Cache invalidation
* ------------------------------------------------------------------ */
private function get_cache_generation() {
$generation = absint(get_option(self::CACHE_GEN_OPTION, 1));
return $generation > 0 ? $generation : 1;
}
/**
* Invalidate every cached navigation and post-type list at once.
*/
public function bump_cache_generation() {
update_option(self::CACHE_GEN_OPTION, $this->get_cache_generation() + 1, false);
}
/**
* save_post / deleted_post / trashed_post / untrashed_post callback.
*/
public function clear_navigation_cache($post_id = null) {
if (!$post_id) {
return;
}
// Revisions, autosaves and brand-new auto-drafts never affect the sequence.
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
$post = get_post($post_id);
if ($post && isset($post->post_status) && 'auto-draft' === $post->post_status) {
return;
}
$this->bump_cache_generation();
}
/* ---------------------------------------------------------------------
* Misc
* ------------------------------------------------------------------ */
public function show_supported_types_notice() {
$screen = get_current_screen();
if (!$screen || !in_array($screen->id, array('edit-post', 'edit-page'), true) || !current_user_can('manage_options')) {
return;
}
$notice_key = 'twf_admin_posts_navigation_notice_' . get_current_user_id() . '_' . wp_hash(wp_get_session_token());
if (get_transient($notice_key)) {
return;
}
set_transient($notice_key, true, 3600);
echo '';
printf(
'
%s: %s
',
esc_html__('Admin Posts Navigation', 'admin-posts-navigation'),
esc_html(sprintf(
// translators: %1$d is the number of post types, %2$s is the list of post type names
__('Active for %1$d post types: %2$s', 'admin-posts-navigation'),
count($this->supported_post_types),
implode(', ', array_map('ucfirst', $this->supported_post_types))
))
);
echo '
';
}
public function get_supported_types() {
return $this->plugin_ready ? $this->supported_post_types : array();
}
}
// Initialize the plugin
$twf_admin_posts_navigation = TWF_AdminPostsNavigation::get_instance();
// Activation hook: start from a fresh cache generation.
function twf_admin_posts_navigation_activation_hook() {
update_option(TWF_AdminPostsNavigation::CACHE_GEN_OPTION, time(), false);
}
register_activation_hook(__FILE__, 'twf_admin_posts_navigation_activation_hook');
// Deactivation hook: invalidate caches.
function twf_admin_posts_navigation_deactivation_hook() {
update_option(TWF_AdminPostsNavigation::CACHE_GEN_OPTION, time(), false);
}
register_deactivation_hook(__FILE__, 'twf_admin_posts_navigation_deactivation_hook');
// Uninstall hook
function twf_admin_posts_navigation_uninstall_hook() {
delete_option(TWF_AdminPostsNavigation::CACHE_GEN_OPTION);
delete_option('twf_admin_posts_navigation_activated'); // legacy (< 1.5.0)
// Clean up user sort preferences using WordPress functions only
$users = get_users(array('fields' => 'ID'));
if (!empty($users)) {
foreach ($users as $user_id) {
$user_meta = get_user_meta($user_id);
if (is_array($user_meta)) {
foreach ($user_meta as $meta_key => $meta_value) {
if (strpos($meta_key, 'twf_admin_posts_nav_sort_') === 0) {
delete_user_meta($user_id, $meta_key);
}
}
}
}
}
}
register_uninstall_hook(__FILE__, 'twf_admin_posts_navigation_uninstall_hook');
// Plugin action links
function twf_admin_posts_navigation_plugin_action_links($links) {
try {
$supported_types = TWF_AdminPostsNavigation::get_instance()->get_supported_types();
if (is_array($supported_types) && !empty($supported_types)) {
$settings_link = '' .
esc_html(sprintf(
// translators: %s is a list of supported post types
__('Supports: %s', 'admin-posts-navigation'),
implode(', ', array_slice($supported_types, 0, 3)) .
(count($supported_types) > 3 ? '... (+' . (count($supported_types) - 3) . ' more)' : '')
)) .
'';
array_unshift($links, $settings_link);
}
} catch (Exception $e) {
if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG && function_exists('wp_debug_log')) {
wp_debug_log('TWF Admin Posts Navigation: Plugin action links error - ' . $e->getMessage());
}
}
return $links;
}
add_filter('plugin_action_links_' . plugin_basename(__FILE__), 'twf_admin_posts_navigation_plugin_action_links');