PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | redirections/class-metasync-redirection-list-table.php +225 -72 2.6.19 → 2.7.1 View file →
@@ -14,12 +14,28 @@
14 14 * @author Engineering Team <[email protected]>
15 15 */
16 16 class Metasync_Redirection_List_Table extends WP_List_Table
17 17 {
18 + /**
19 + * Name of the submit-button marker that identifies an Apply submit.
20 + *
21 + * Matches the name WordPress core itself gives the button from 7.1 on, so
22 + * the marker is core's own on new installs and ours on older ones.
23 + */
24 + private const BULK_APPLY_MARKER = 'bulk_action';
25 +
18 26 private $records;
19 27 private $db_redirection;
20 28 private $_displayed = false;
21 - private $_nav_displayed = false;
29 + private $_nav_displayed = [];
30 + /**
31 + * User-resolved results-per-page value for this list (validated against
32 + * Metasync_Per_Page_Helper::ALLOWED_VALUES). Cached here so pagination()
33 + * can reuse it without re-resolving from the request / user meta.
34 + *
35 + * @var int
36 + */
37 + private $resolved_per_page = 10;
22 38 public function __construct()
23 39 {
24 40 // Set parent defaults.
25 41 parent::__construct(array(
@@ -33,9 +49,9 @@
33 49 * Get the current page number for redirections (use separate parameter)
34 50 */
35 51 public function get_pagenum()
36 52 {
37 - $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0;
53 + $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page number for the list query
38 54
39 55 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
40 56 $pagenum = $this->_pagination_args['total_pages'];
41 57 }
@@ -87,44 +103,27 @@
87 103
88 104 $current_url = remove_query_arg($removable_query_args, $current_url);
89 105
90 106 // Preserve the tab parameter
91 - if (isset($_GET['tab'])) {
92 - $current_url = add_query_arg('tab', sanitize_text_field($_GET['tab']), $current_url);
107 + if (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
108 + $current_url = add_query_arg('tab', sanitize_text_field(wp_unslash($_GET['tab'])), $current_url); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
93 109 }
94 110
95 111 // If filter action was submitted, remove paged_redir to reset to page 1
96 - if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) {
112 + if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only values used in pagination links
97 113 $current_url = remove_query_arg('paged_redir', $current_url);
98 114 }
99 115
100 - // Preserve filter parameters from REQUEST (works for both GET and POST)
101 - // First, remove all filter parameters from URL, then add them back if they exist
102 - $filter_params = ['status_filter', 'pattern_filter', 'http_code_filter', 's'];
103 - foreach ($filter_params as $param) {
104 - // Always remove first to clear old values
105 - $current_url = remove_query_arg($param, $current_url);
106 - // Then add back only if it has a value in REQUEST
107 - if (!empty($_REQUEST[$param])) {
108 - $current_url = add_query_arg($param, sanitize_text_field($_REQUEST[$param]), $current_url);
109 - }
110 - }
116 + // Rebuild visible list state from the allow-listed request values. Removing
117 + // every key first ensures clearing a POSTed filter also clears a stale GET
118 + // value from generated pagination links.
119 + $state_keys = array_merge(
120 + Metasync_Per_Page_Helper::state_params('redirections'),
121 + [Metasync_Per_Page_Helper::request_key('redirections')]
122 + );
123 + $current_url = remove_query_arg($state_keys, $current_url);
124 + $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
111 125
112 - // Preserve sorting parameters (same approach - remove first, then add if exists)
113 - $sort_params = ['orderby_redir', 'order_redir'];
114 - foreach ($sort_params as $param) {
115 - // Always remove first to clear old values
116 - $current_url = remove_query_arg($param, $current_url);
117 - // Then add back only if it has a value in REQUEST
118 - if (!empty($_REQUEST[$param])) {
119 - if ($param === 'orderby_redir') {
120 - $current_url = add_query_arg($param, sanitize_sql_orderby($_REQUEST[$param]), $current_url);
121 - } else {
122 - $current_url = add_query_arg($param, sanitize_text_field($_REQUEST[$param]), $current_url);
123 - }
124 - }
125 - }
126 -
127 126 $page_links = array();
128 127
129 128 $total_pages_before = '<span class="paging-input">';
130 129 $total_pages_after = '</span></span>';
@@ -151,9 +150,9 @@
151 150 } else {
152 151 $page_links[] = sprintf(
153 152 "<a class='first-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
154 153 esc_url(remove_query_arg('paged_redir', $current_url)),
155 - esc_html__('First page'),
154 + esc_html__('First page', 'metasync'),
156 155 '&laquo;'
157 156 );
158 157 }
159 158
@@ -162,9 +161,9 @@
162 161 } else {
163 162 $page_links[] = sprintf(
164 163 "<a class='prev-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
165 164 esc_url(add_query_arg('paged_redir', max(1, $current - 1), $current_url)),
166 - esc_html__('Previous page'),
165 + esc_html__('Previous page', 'metasync'),
167 166 '&lsaquo;'
168 167 );
169 168 }
170 169
@@ -192,9 +191,9 @@
192 191 } else {
193 192 $page_links[] = sprintf(
194 193 "<a class='next-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
195 194 esc_url(add_query_arg('paged_redir', min($total_pages, $current + 1), $current_url)),
196 - esc_html__('Next page'),
195 + esc_html__('Next page', 'metasync'),
197 196 '&rsaquo;'
198 197 );
199 198 }
200 199
@@ -203,9 +202,9 @@
203 202 } else {
204 203 $page_links[] = sprintf(
205 204 "<a class='last-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
206 205 esc_url(add_query_arg('paged_redir', $total_pages, $current_url)),
207 - esc_html__('Last page'),
206 + esc_html__('Last page', 'metasync'),
208 207 '&raquo;'
209 208 );
210 209 }
211 210
@@ -214,8 +213,12 @@
214 213 $pagination_links_class .= ' hide-if-js';
215 214 }
216 215 $output .= "\n<span class='$pagination_links_class'>" . join("\n", $page_links) . '</span>';
217 216
217 + // Inline "rows per page" selector (10/20/50/100), persisted per user.
218 + // $which keeps the top and bottom selectors' ids distinct.
219 + $output .= Metasync_Per_Page_Helper::render_selector('redirections', $this->resolved_per_page, $which);
220 +
218 221 if ($total_pages) {
219 222 $page_class = $total_pages < 2 ? ' one-page' : '';
220 223 } else {
221 224 $page_class = ' no-pages';
@@ -243,8 +246,14 @@
243 246 if ($key !== false) {
244 247 unset($args[$key]);
245 248 }
246 249
250 + // Preserve this list's rows-per-page value across pagination / tab changes.
251 + $key = array_search(Metasync_Per_Page_Helper::request_key('redirections'), $args, true);
252 + if ($key !== false) {
253 + unset($args[$key]);
254 + }
255 +
247 256 // Remove the other pagination parameters to avoid conflicts
248 257 $args[] = 'paged';
249 258 $args[] = 'paged_404';
250 259
@@ -287,31 +296,32 @@
287 296 private function get_search_filters()
288 297 {
289 298 $filters = [];
290 299
291 - if (!empty($_REQUEST['s'])) {
292 - $filters['search'] = sanitize_text_field($_REQUEST['s']);
300 + $request_state = Metasync_Per_Page_Helper::request_state('redirections');
301 + if (!empty($request_state['s_redir'])) {
302 + $filters['search'] = $request_state['s_redir'];
293 303 }
294 304
295 - if (!empty($_REQUEST['status_filter'])) {
296 - $filters['status'] = sanitize_text_field($_REQUEST['status_filter']);
305 + if (!empty($_REQUEST['status_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
306 + $filters['status'] = sanitize_text_field(wp_unslash($_REQUEST['status_filter'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
297 307 }
298 308
299 - if (!empty($_REQUEST['pattern_filter'])) {
300 - $filters['pattern_type'] = sanitize_text_field($_REQUEST['pattern_filter']);
309 + if (!empty($_REQUEST['pattern_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
310 + $filters['pattern_type'] = sanitize_text_field(wp_unslash($_REQUEST['pattern_filter'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
301 311 }
302 312
303 - if (!empty($_REQUEST['http_code_filter'])) {
304 - $filters['http_code'] = intval($_REQUEST['http_code_filter']);
313 + if (!empty($_REQUEST['http_code_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
314 + $filters['http_code'] = intval($_REQUEST['http_code_filter']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
305 315 }
306 316
307 317 // Use separate orderby/order parameters for redirections
308 - if (!empty($_REQUEST['orderby_redir'])) {
309 - $filters['order_by'] = sanitize_sql_orderby($_REQUEST['orderby_redir']);
318 + if (!empty($_REQUEST['orderby_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
319 + $filters['order_by'] = sanitize_sql_orderby($_REQUEST['orderby_redir']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
310 320 }
311 321
312 - if (!empty($_REQUEST['order_redir'])) {
313 - $filters['order'] = sanitize_text_field($_REQUEST['order_redir']);
322 + if (!empty($_REQUEST['order_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
323 + $filters['order'] = sanitize_text_field(wp_unslash($_REQUEST['order_redir'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
314 324 }
315 325
316 326 return $filters;
317 327 }
@@ -351,9 +361,9 @@
351 361 * Override to use custom orderby/order parameter names
352 362 */
353 363 protected function get_orderby()
354 364 {
355 - return isset($_REQUEST['orderby_redir']) ? sanitize_key($_REQUEST['orderby_redir']) : '';
365 + return isset($_REQUEST['orderby_redir']) ? sanitize_key(wp_unslash($_REQUEST['orderby_redir'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only sort state for the column headers
356 366 }
357 367
358 368 /**
359 369 * Override to use custom orderby/order parameter names
@@ -359,9 +369,9 @@
359 369 * Override to use custom orderby/order parameter names
360 370 */
361 371 protected function get_order()
362 372 {
363 - $raw = isset($_REQUEST['order_redir']) ? strtolower(sanitize_key($_REQUEST['order_redir'])) : '';
373 + $raw = isset($_REQUEST['order_redir']) ? strtolower(sanitize_key(wp_unslash($_REQUEST['order_redir']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only sort state for the column headers
364 374 if ($raw === 'desc') {
365 375 return 'desc';
366 376 }
367 377 return 'asc';
@@ -373,12 +383,15 @@
373 383 public function print_column_headers($with_id = true)
374 384 {
375 385 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
376 386
377 - $current_page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : Metasync_Admin::$page_slug;
378 - $current_url = admin_url('admin.php');
387 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : Metasync_Admin::$page_slug; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug used in escaped sort links
388 + $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
389 + $current_url = remove_query_arg(['_wpnonce', '_wp_http_referer', 'action', 'action2', 'id'], $current_url);
379 390 $current_url = add_query_arg('page', $current_page, $current_url);
380 391 $current_url = add_query_arg('tab', 'redirections', $current_url);
392 + $current_url = remove_query_arg('paged_redir', $current_url);
393 + $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
381 394
382 395 $current_orderby = $this->get_orderby();
383 396 $current_order = $this->get_order();
384 397
@@ -448,8 +461,9 @@
448 461 case 'pattern_type':
449 462 return $this->column_pattern_type($item);
450 463 default:
451 464 # return print_r($item, true); // Show the whole array for troubleshooting purposes.
465 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- unreachable in production
452 466 return esc_html(print_r($item, true)); # Fixed: Added esc_html() to prevent XSS in debug output
453 467 }
454 468 }
455 469
@@ -463,8 +477,9 @@
463 477 }
464 478
465 479 protected function column_sources_from($item)
466 480 {
481 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, row-action links carry their own nonce
467 482 $request_data = metasync_sanitize_input_array($_REQUEST); // WPCS: Input var ok.
468 483 if (!isset($request_data['page'])) return;
469 484
470 485 // Build delete row action.
@@ -469,11 +484,16 @@
469 484
470 485 // Build delete row action.
471 486 $delete_query_args = array(
472 487 'page' => sanitize_text_field($request_data['page']),
473 - 'action' => 'delete',
474 - 'id' => $item['id'],
488 + 'tab' => 'redirections',
489 + 'action' => 'delete_redirect',
490 + 'id' => $item['id'],
475 491 );
492 + $per_page_key = Metasync_Per_Page_Helper::request_key('redirections');
493 + if (isset($request_data[$per_page_key])) {
494 + $delete_query_args[$per_page_key] = sanitize_text_field($request_data[$per_page_key]);
495 + }
476 496
477 497 // Build edit row action.
478 498 $edit_query_args = array(
479 499 'page' => sanitize_text_field($request_data['page']),
@@ -594,10 +614,57 @@
594 614
595 615 return esc_html($label);
596 616 }
597 617
618 + /**
619 + * Override current_action() so bulk actions triggered from the bottom
620 + * dropdown (which posts `action2`) are detected alongside the top one
621 + * (which posts `action`). WP_List_Table::current_action() only reads
622 + * `action`, so without this the bottom Apply is a silent no-op.
623 + *
624 + * @return string|false
625 + */
626 + public function current_action()
627 + {
628 + // A "Filter"/"Search" submit is not a bulk action. WP core's own
629 + // current_action() short-circuits on filter_action for exactly this
630 + // reason, and dropping that check makes ticking rows, choosing a bulk
631 + // action and then clicking Filter silently run the action. The Search
632 + // submit posts no filter_action, so it needs its own named marker to
633 + // stop the same silent delete when it shares a form with the dropdown.
634 + if (!empty($_REQUEST['filter_action']) || !empty($_REQUEST['search_submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
635 + return false;
636 + }
637 +
638 + if (isset($_REQUEST['action']) && '-1' !== $_REQUEST['action'] && '' !== $_REQUEST['action']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
639 + return sanitize_text_field(wp_unslash($_REQUEST['action'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
640 + }
641 + if (isset($_REQUEST['action2']) && '-1' !== $_REQUEST['action2'] && '' !== $_REQUEST['action2']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
642 + return sanitize_text_field(wp_unslash($_REQUEST['action2'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
643 + }
644 + return parent::current_action();
645 + }
646 +
598 647 protected function get_bulk_actions()
599 648 {
649 + // On the combined Redirections screen BOTH this table and the 404
650 + // monitor table are rendered, with CSS hiding the inactive tab. If the
651 + // hidden one also emitted bulk controls the page would carry two
652 + // #bulk-action-selector-top/-bottom and #doaction/#doaction2; WP core's
653 + // common.js binds those by ID, so it wired up whichever table came
654 + // first and left the visible tab's controls inert. Returning an empty
655 + // array makes core render no dropdown and no Apply for the hidden
656 + // table, which costs nothing (it cannot be clicked) and keeps the IDs
657 + // unique.
658 + //
659 + // The flag is null unless we are on that combined screen, so this table
660 + // keeps its bulk actions everywhere else.
661 + if (class_exists('Metasync_Redirections_Admin')
662 + && Metasync_Redirections_Admin::$combined_screen_active_tab !== null
663 + && Metasync_Redirections_Admin::$combined_screen_active_tab !== 'redirections') {
664 + return array();
665 + }
666 +
600 667 $actions = array(
601 668 'delete_bulk' => _x('Delete', 'List table bulk action', 'metasync'),
602 669 'activate_bulk' => _x('Activate', 'List table bulk action', 'metasync'),
603 670 'deactivate_bulk' => _x('Deactivate', 'List table bulk action', 'metasync'),
@@ -605,15 +672,90 @@
605 672
606 673 return $actions;
607 674 }
608 675
676 + /**
677 + * Guarantee the Apply button posts a marker identifying it as the submitter.
678 + *
679 + * The bulk dropdown, the row checkboxes and the bulk-items nonce all live
680 + * inside the same form as the Search field and the filter controls, so every
681 + * submit of that form carries a complete-looking bulk request. The only thing
682 + * that distinguishes a real Apply from Search, Enter-in-search or a filter
683 + * dropdown's auto-submit is which submit button (if any) was used, and a
684 + * button only reports itself when it has a name attribute.
685 + *
686 + * WordPress core names the Apply button `bulk_action` from 7.1 onwards; on
687 + * the older releases this plugin still supports get_submit_button() is called
688 + * with an empty name and omits the attribute entirely. Add it ourselves in
689 + * that case so process_bulk_action() can rely on the marker on every
690 + * supported version, with no JavaScript involved.
691 + *
692 + * @param string $which Which tablenav is being rendered ('top' or 'bottom').
693 + * @return void
694 + */
695 + protected function bulk_actions($which = '')
696 + {
697 + ob_start();
698 + parent::bulk_actions($which);
699 + $markup = ob_get_clean();
700 +
701 + // get_bulk_actions() returns an empty array on screens where this table
702 + // must not emit bulk controls, and core then renders nothing at all.
703 + if (!is_string($markup) || '' === $markup) {
704 + return;
705 + }
706 +
707 + if (false === strpos($markup, 'name="' . self::BULK_APPLY_MARKER . '"')) {
708 + $named = preg_replace(
709 + '/<input\b(?=[^>]*\bid="doaction2?")/',
710 + '<input name="' . self::BULK_APPLY_MARKER . '"',
711 + $markup,
712 + 1
713 + );
714 +
715 + // Keep core's markup untouched if the shape ever changes and the
716 + // pattern stops matching, rather than emitting nothing.
717 + if (is_string($named)) {
718 + $markup = $named;
719 + }
720 + }
721 +
722 + echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core markup, already escaped by WP_List_Table.
723 + }
724 +
725 + /**
726 + * Whether the current request was submitted with the Apply button.
727 + *
728 + * Only Apply carries the marker added in bulk_actions(); Search and the
729 + * unnamed implicit-submission button send no submit-button value, and a
730 + * filter dropdown's programmatic form.submit() sends none either. Testing
731 + * for the marker's presence rather than for the absence of `filter_action`
732 + * keeps every current and future non-Apply submit path suppressed by
733 + * default.
734 + *
735 + * @param array $post_data Sanitised copy of $_POST.
736 + * @return bool
737 + */
738 + private function is_bulk_apply_submit($post_data)
739 + {
740 + return !empty($post_data[self::BULK_APPLY_MARKER]);
741 + }
742 +
609 743 protected function process_bulk_action()
610 744 {
611 - $post_data = metasync_sanitize_input_array($_POST);
745 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() runs before any write
612 746 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
613 747
614 748 if (empty($post_data['items'])) return;
615 749
750 + // Suppress every submit that did not come from Apply. Search, Enter in the
751 + // search box and a filter dropdown's auto-submit all post the bulk
752 + // dropdown, the ticked rows and a valid nonce, so without this the
753 + // selected action ran on any of them - silently, with no confirmation.
754 + if (!$this->is_bulk_apply_submit($post_data)) {
755 + return;
756 + }
757 +
616 758 if (!$this->current_action()) {
617 759 return;
618 760 }
619 761
@@ -643,9 +785,9 @@
643 785 }
644 786
645 787 protected function process_row_action()
646 788 {
647 - $state_actions = ['delete', 'activate', 'deactivate'];
789 + $state_actions = ['delete_redirect', 'activate', 'deactivate'];
648 790
649 791 if (!in_array($this->current_action(), $state_actions, true)) {
650 792 return;
651 793 }
@@ -662,10 +804,11 @@
662 804 $get_data = metasync_sanitize_input_array($_GET);
663 805 $item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
664 806
665 807 // Detect when a delete action is being triggered.
666 - if ('delete' === $this->current_action()) {
808 + if ('delete_redirect' === $this->current_action()) {
667 809 $this->db_redirection->delete([$item]);
810 + $this->redirect_after_row_action();
668 811 }
669 812 // Detect when a activate action is being triggered.
670 813 if ('activate' === $this->current_action()) {
671 814 $this->db_redirection->update_status([$item], 'active');
@@ -675,11 +818,25 @@
675 818 $this->db_redirection->update_status([$item], 'inactive');
676 819 }
677 820 }
678 821
822 + protected function redirect_after_row_action()
823 + {
824 + $redirect_url = remove_query_arg(['action', 'action2', 'id', '_wpnonce']);
825 +
826 + if (!headers_sent()) {
827 + wp_safe_redirect($redirect_url);
828 + exit;
829 + }
830 +
831 + printf('<script>window.location.replace(%s);</script>', wp_json_encode(esc_url_raw($redirect_url)));
832 + exit;
833 + }
834 +
679 835 function prepare_items()
680 836 {
681 - $per_page = 10;
837 + $per_page = Metasync_Per_Page_Helper::resolve('redirections', 10);
838 + $this->resolved_per_page = $per_page;
682 839
683 840 $columns = $this->get_columns();
684 841 $hidden = array();
685 842 $sortable = $this->get_sortable_columns();
@@ -701,9 +858,9 @@
701 858
702 859 $this->set_pagination_args(array(
703 860 'total_items' => $total_items, // Total number of items from database
704 861 'per_page' => $per_page, // Items per page
705 - 'total_pages' => ceil($total_items / $per_page), // Total number of pages
862 + 'total_pages' => (int) ceil($total_items / $per_page), // Total number of pages
706 863 ));
707 864 }
708 865
709 866 /**
@@ -722,21 +879,24 @@
722 879 parent::display();
723 880 }
724 881
725 882 /**
726 - * Override display_tablenav to prevent duplicate navigation
883 + * Override display_tablenav to prevent duplicate navigation.
884 + *
885 + * Each position ('top' / 'bottom') renders at most once, tracked
886 + * SEPARATELY. A single shared flag suppressed the bottom bar entirely,
887 + * and with it the `action2` bulk control — so bulk actions could never be
888 + * applied from the bottom of the list.
727 889 */
728 890 protected function display_tablenav($which)
729 891 {
730 - // Only display navigation once
731 - if (isset($this->_nav_displayed) && $this->_nav_displayed) {
892 + // Only display each navigation position once
893 + if (!empty($this->_nav_displayed[$which])) {
732 894 return;
733 895 }
734 896
735 - if ($which === 'top') {
736 - $this->_nav_displayed = true;
737 - }
738 -
897 + $this->_nav_displayed[$which] = true;
898 +
739 899 parent::display_tablenav($which);
740 900 }
741 901
742 902 private function show_source_from_urls($item)
@@ -805,15 +965,8 @@
805 965 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
806 966 esc_html($clean_source)
807 967 );
808 968 }
809 - echo sprintf(
810 - # '<span>%1$s</span>',
811 - # ' [' . $source_type . ']'
812 - # ' [' . esc_html($source_type) . ']' # Fixed: Added esc_html() to prevent XSS
813 - '<span style="color: #666; font-size: 0.9em;">%1$s</span>',
814 - ' [' . esc_html($source_type) . ']'
815 - );
816 969 echo "<br>";
817 970 }
818 971 }
819 972