PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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
metasync / redirections / class-metasync-redirection-list-table.php

class-metasync-redirection-list-table.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at redirections/class-metasync-redirection-list-table.php

943 lines 29.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The Record list for the Redirections.
10 *
11 * @since 1.0.0
12 * @package Metasync
13 * @subpackage Metasync/redirections
14 * @author Engineering Team <[email protected]>
15 */
16 class Metasync_Redirection_List_Table extends WP_List_Table
17 {
18 private $records;
19 private $db_redirection;
20 private $_displayed = false;
21 private $_nav_displayed = [];
22 /**
23 * User-resolved results-per-page value for this list (validated against
24 * Metasync_Per_Page_Helper::ALLOWED_VALUES). Cached here so pagination()
25 * can reuse it without re-resolving from the request / user meta.
26 *
27 * @var int
28 */
29 private $resolved_per_page = 10;
30 public function __construct()
31 {
32 // Set parent defaults.
33 parent::__construct(array(
34 'singular' => 'item', // Singular name of the listed records.
35 'plural' => 'items', // Plural name of the listed records.
36 'ajax' => false, // Does this table support ajax?
37 ));
38 }
39
40 /**
41 * Get the current page number for redirections (use separate parameter)
42 */
43 public function get_pagenum()
44 {
45 $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0;
46
47 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
48 $pagenum = $this->_pagination_args['total_pages'];
49 }
50
51 return max(1, $pagenum);
52 }
53
54 /**
55 * Override pagination args to preserve tab parameter
56 */
57 protected function get_views()
58 {
59 return array();
60 }
61
62 /**
63 * Ensure tab parameter is preserved in pagination links
64 */
65 protected function pagination($which)
66 {
67 if (empty($this->_pagination_args)) {
68 return;
69 }
70
71 $total_items = $this->_pagination_args['total_items'];
72 $total_pages = $this->_pagination_args['total_pages'];
73 $infinite_scroll = false;
74 if (isset($this->_pagination_args['infinite_scroll'])) {
75 $infinite_scroll = $this->_pagination_args['infinite_scroll'];
76 }
77
78 if ('top' === $which && $total_pages > 1) {
79 $this->screen->render_screen_reader_content('heading_pagination');
80 }
81
82 // Add tab parameter to pagination links
83 add_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
84
85 $output = '<span class="displaying-num">' . sprintf(
86 /* translators: %s: Number of items */
87 _n('%s item', '%s items', $total_items, 'metasync'),
88 number_format_i18n($total_items)
89 ) . '</span>';
90
91 $current = $this->get_pagenum();
92 $removable_query_args = wp_removable_query_args();
93
94 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
95
96 $current_url = remove_query_arg($removable_query_args, $current_url);
97
98 // Preserve the tab parameter
99 if (isset($_GET['tab'])) {
100 $current_url = add_query_arg('tab', sanitize_text_field($_GET['tab']), $current_url);
101 }
102
103 // If filter action was submitted, remove paged_redir to reset to page 1
104 if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) {
105 $current_url = remove_query_arg('paged_redir', $current_url);
106 }
107
108 // Rebuild visible list state from the allow-listed request values. Removing
109 // every key first ensures clearing a POSTed filter also clears a stale GET
110 // value from generated pagination links.
111 $state_keys = array_merge(
112 Metasync_Per_Page_Helper::state_params('redirections'),
113 [Metasync_Per_Page_Helper::request_key('redirections')]
114 );
115 $current_url = remove_query_arg($state_keys, $current_url);
116 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
117
118 $page_links = array();
119
120 $total_pages_before = '<span class="paging-input">';
121 $total_pages_after = '</span></span>';
122
123 $disable_first = $disable_last = $disable_prev = $disable_next = false;
124
125 if ($current == 1) {
126 $disable_first = true;
127 $disable_prev = true;
128 }
129 if ($current == 2) {
130 $disable_first = true;
131 }
132 if ($current == $total_pages) {
133 $disable_last = true;
134 $disable_next = true;
135 }
136 if ($current == $total_pages - 1) {
137 $disable_last = true;
138 }
139
140 if ($disable_first) {
141 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
142 } else {
143 $page_links[] = sprintf(
144 "<a class='first-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
145 esc_url(remove_query_arg('paged_redir', $current_url)),
146 esc_html__('First page'),
147 '&laquo;'
148 );
149 }
150
151 if ($disable_prev) {
152 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
153 } else {
154 $page_links[] = sprintf(
155 "<a class='prev-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
156 esc_url(add_query_arg('paged_redir', max(1, $current - 1), $current_url)),
157 esc_html__('Previous page'),
158 '&lsaquo;'
159 );
160 }
161
162 if ('bottom' === $which) {
163 $html_current_page = $current;
164 $total_pages_before = '<span class="screen-reader-text">' . esc_html__('Current Page', 'metasync') . '</span><span id="table-paging" class="paging-input"><span class="tablenav-paging-text">';
165 } else {
166 $html_current_page = sprintf(
167 "%s<input class='current-page' id='current-page-selector-redir' type='text' name='paged_redir' value='%s' size='%d' aria-describedby='table-paging' /><span class='tablenav-paging-text'>",
168 '<label for="current-page-selector-redir" class="screen-reader-text">' . esc_html__('Current Page', 'metasync') . '</label>',
169 $current,
170 strlen($total_pages)
171 );
172 }
173 $html_total_pages = sprintf("<span class='total-pages'>%s</span>", number_format_i18n($total_pages));
174 $page_links[] = $total_pages_before . sprintf(
175 /* translators: 1: Current page, 2: Total pages */
176 _x('%1$s of %2$s', 'paging', 'metasync'),
177 $html_current_page,
178 $html_total_pages
179 ) . $total_pages_after;
180
181 if ($disable_next) {
182 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
183 } else {
184 $page_links[] = sprintf(
185 "<a class='next-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
186 esc_url(add_query_arg('paged_redir', min($total_pages, $current + 1), $current_url)),
187 esc_html__('Next page'),
188 '&rsaquo;'
189 );
190 }
191
192 if ($disable_last) {
193 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
194 } else {
195 $page_links[] = sprintf(
196 "<a class='last-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
197 esc_url(add_query_arg('paged_redir', $total_pages, $current_url)),
198 esc_html__('Last page'),
199 '&raquo;'
200 );
201 }
202
203 $pagination_links_class = 'pagination-links';
204 if (!empty($infinite_scroll)) {
205 $pagination_links_class .= ' hide-if-js';
206 }
207 $output .= "\n<span class='$pagination_links_class'>" . join("\n", $page_links) . '</span>';
208
209 // Inline "rows per page" selector (10/20/50/100), persisted per user.
210 // $which keeps the top and bottom selectors' ids distinct.
211 $output .= Metasync_Per_Page_Helper::render_selector('redirections', $this->resolved_per_page, $which);
212
213 if ($total_pages) {
214 $page_class = $total_pages < 2 ? ' one-page' : '';
215 } else {
216 $page_class = ' no-pages';
217 }
218 $this->_pagination = "<div class='tablenav-pages{$page_class}'>$output</div>";
219
220 echo $this->_pagination;
221
222 remove_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
223 }
224
225 /**
226 * Preserve tab parameter in pagination
227 */
228 public function preserve_tab_in_pagination($args)
229 {
230 // Remove 'tab' from removable query args so it's preserved
231 $key = array_search('tab', $args, true);
232 if ($key !== false) {
233 unset($args[$key]);
234 }
235
236 // Also preserve our pagination parameter
237 $key = array_search('paged_redir', $args, true);
238 if ($key !== false) {
239 unset($args[$key]);
240 }
241
242 // Preserve this list's rows-per-page value across pagination / tab changes.
243 $key = array_search(Metasync_Per_Page_Helper::request_key('redirections'), $args, true);
244 if ($key !== false) {
245 unset($args[$key]);
246 }
247
248 // Remove the other pagination parameters to avoid conflicts
249 $args[] = 'paged';
250 $args[] = 'paged_404';
251
252 return array_unique($args);
253 }
254
255 public function setDatabaseResource(&$db_redirection)
256 {
257 $this->db_redirection = $db_redirection;
258 }
259
260 private function setRecords($records)
261 {
262 return $this->records = json_decode(json_encode($records), true);
263 }
264
265 private function loadRecords()
266 {
267 $filters = $this->get_search_filters();
268 return $this->setRecords($this->db_redirection->search_redirections($filters));
269 }
270
271 private function loadRecordsWithPagination($per_page, $offset)
272 {
273 $filters = $this->get_search_filters();
274 $filters['per_page'] = $per_page;
275 $filters['offset'] = $offset;
276 return $this->setRecords($this->db_redirection->search_redirections($filters));
277 }
278
279 private function getTotalRecords()
280 {
281 $filters = $this->get_search_filters();
282 return $this->db_redirection->count_redirections($filters);
283 }
284
285 /**
286 * Get search filters from request
287 */
288 private function get_search_filters()
289 {
290 $filters = [];
291
292 $request_state = Metasync_Per_Page_Helper::request_state('redirections');
293 if (!empty($request_state['s_redir'])) {
294 $filters['search'] = $request_state['s_redir'];
295 }
296
297 if (!empty($_REQUEST['status_filter'])) {
298 $filters['status'] = sanitize_text_field($_REQUEST['status_filter']);
299 }
300
301 if (!empty($_REQUEST['pattern_filter'])) {
302 $filters['pattern_type'] = sanitize_text_field($_REQUEST['pattern_filter']);
303 }
304
305 if (!empty($_REQUEST['http_code_filter'])) {
306 $filters['http_code'] = intval($_REQUEST['http_code_filter']);
307 }
308
309 // Use separate orderby/order parameters for redirections
310 if (!empty($_REQUEST['orderby_redir'])) {
311 $filters['order_by'] = sanitize_sql_orderby($_REQUEST['orderby_redir']);
312 }
313
314 if (!empty($_REQUEST['order_redir'])) {
315 $filters['order'] = sanitize_text_field($_REQUEST['order_redir']);
316 }
317
318 return $filters;
319 }
320
321 public function get_columns()
322 {
323 $columns = array(
324 'cb' => '<input type="checkbox" />', // Render a checkbox instead of text.
325 'sources_from' => _x('From', 'Column label', 'metasync'),
326 'url_redirect_to' => _x('To', 'Column label', 'metasync'),
327 'http_code' => _x('Type', 'Column label', 'metasync'),
328 'pattern_type' => _x('Pattern', 'Column label', 'metasync'),
329 'hits_count' => _x('Hits', 'Column label', 'metasync'),
330 'status' => _x('Status', 'Column label', 'metasync'),
331 'last_accessed_at' => _x('Last Accessed', 'Column label', 'metasync'),
332 );
333
334 return $columns;
335 }
336
337 protected function get_sortable_columns()
338 {
339 $sortable_columns = array(
340 'sources_from' => array('sources_from', false),
341 'url_redirect_to' => array('url_redirect_to', false),
342 'http_code' => array('http_code', false),
343 'pattern_type' => array('pattern_type', false),
344 'hits_count' => array('hits_count', false),
345 'status' => array('status', false),
346 'last_accessed_at' => array('last_accessed_at', false),
347 );
348
349 return $sortable_columns;
350 }
351
352 /**
353 * Override to use custom orderby/order parameter names
354 */
355 protected function get_orderby()
356 {
357 return isset($_REQUEST['orderby_redir']) ? sanitize_key($_REQUEST['orderby_redir']) : '';
358 }
359
360 /**
361 * Override to use custom orderby/order parameter names
362 */
363 protected function get_order()
364 {
365 $raw = isset($_REQUEST['order_redir']) ? strtolower(sanitize_key($_REQUEST['order_redir'])) : '';
366 if ($raw === 'desc') {
367 return 'desc';
368 }
369 return 'asc';
370 }
371
372 /**
373 * Override column headers to use custom parameter names for sorting
374 */
375 public function print_column_headers($with_id = true)
376 {
377 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
378
379 $current_page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : Metasync_Admin::$page_slug;
380 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
381 $current_url = remove_query_arg(['_wpnonce', '_wp_http_referer', 'action', 'action2', 'id'], $current_url);
382 $current_url = add_query_arg('page', $current_page, $current_url);
383 $current_url = add_query_arg('tab', 'redirections', $current_url);
384 $current_url = remove_query_arg('paged_redir', $current_url);
385 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
386
387 $current_orderby = $this->get_orderby();
388 $current_order = $this->get_order();
389
390 foreach ($columns as $column_key => $column_display_name) {
391 $class = array('manage-column', "column-$column_key");
392
393 if (in_array($column_key, $hidden, true)) {
394 $class[] = 'hidden';
395 }
396
397 if ($column_key === $primary) {
398 $class[] = 'column-primary';
399 }
400
401 // Add check-column class for checkbox column
402 if ('cb' === $column_key) {
403 $class[] = 'check-column';
404 }
405
406 if (isset($sortable[$column_key])) {
407 list($orderby, $desc_first) = $sortable[$column_key];
408
409 if ($current_orderby === $orderby) {
410 $order = 'asc' === $current_order ? 'desc' : 'asc';
411 $class[] = 'sorted';
412 $class[] = $current_order;
413 } else {
414 $order = strtolower($desc_first);
415 if (!in_array($order, array('desc', 'asc'), true)) {
416 $order = $desc_first ? 'desc' : 'asc';
417 }
418 $class[] = 'sortable';
419 $class[] = 'desc' === $order ? 'asc' : 'desc';
420 }
421
422 $column_display_name = sprintf(
423 '<a href="%s"><span>%s</span><span class="sorting-indicators"></span></a>',
424 esc_url(add_query_arg(array('orderby_redir' => $orderby, 'order_redir' => $order), $current_url)),
425 esc_html($column_display_name)
426 );
427 } elseif ('cb' !== $column_key) {
428 $column_display_name = esc_html($column_display_name);
429 }
430
431 $tag = ('cb' === $column_key) ? 'td' : 'th';
432 $scope = ('th' === $tag) ? 'scope="col"' : '';
433 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
434
435 if (!empty($class)) {
436 $class = "class='" . esc_attr(implode(' ', $class)) . "'";
437 }
438
439 echo '<' . esc_attr($tag) . ' ' . $scope . ' ' . $id . ' ' . $class . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
440 }
441 }
442
443 protected function column_default($item, $column_name)
444 {
445 switch ($column_name) {
446 case 'sources_from':
447 case 'url_redirect_to':
448 case 'http_code':
449 case 'hits_count':
450 case 'status':
451 case 'last_accessed_at':
452 return esc_html($item[$column_name]); # Fixed: Added esc_html() to prevent XSS
453 case 'pattern_type':
454 return $this->column_pattern_type($item);
455 default:
456 # return print_r($item, true); // Show the whole array for troubleshooting purposes.
457 return esc_html(print_r($item, true)); # Fixed: Added esc_html() to prevent XSS in debug output
458 }
459 }
460
461 protected function column_cb($item)
462 {
463 return sprintf(
464 '<input type="checkbox" name="%1$s[]" value="%2$s" />',
465 $this->_args['plural'], // Let's simply repurpose the table's singular label ("error").
466 $item['id'] // The value of the checkbox should be the record's ID.
467 );
468 }
469
470 protected function column_sources_from($item)
471 {
472 $request_data = metasync_sanitize_input_array($_REQUEST); // WPCS: Input var ok.
473 if (!isset($request_data['page'])) return;
474
475 // Build delete row action.
476 $delete_query_args = array(
477 'page' => sanitize_text_field($request_data['page']),
478 'tab' => 'redirections',
479 'action' => 'delete_redirect',
480 'id' => $item['id'],
481 );
482 $per_page_key = Metasync_Per_Page_Helper::request_key('redirections');
483 if (isset($request_data[$per_page_key])) {
484 $delete_query_args[$per_page_key] = sanitize_text_field($request_data[$per_page_key]);
485 }
486
487 // Build edit row action.
488 $edit_query_args = array(
489 'page' => sanitize_text_field($request_data['page']),
490 'action' => 'edit',
491 'id' => $item['id'],
492 );
493
494 // Build activate row action.
495 $activate_query_args = array(
496 'page' => sanitize_text_field($request_data['page']),
497 'action' => 'activate',
498 'id' => $item['id'],
499 );
500
501 // Build deactivate row action.
502 $deactivate_query_args = array(
503 'page' => sanitize_text_field($request_data['page']),
504 'action' => 'deactivate',
505 'id' => $item['id'],
506 );
507
508 $actions['edit'] = sprintf(
509 '<a href="%1$s">%2$s</a>',
510 esc_url(add_query_arg($edit_query_args, 'admin.php')),
511 _x('Edit', 'List table row action', 'metasync')
512 );
513
514 if ($item['status'] === 'inactive') {
515
516 $actions['activate'] = sprintf(
517 '<a href="%1$s">%2$s</a>',
518 esc_url(wp_nonce_url(add_query_arg($activate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
519 _x('Activate', 'List table row action', 'metasync')
520 );
521 } else {
522
523 $actions['deactivate'] = sprintf(
524 '<a href="%1$s">%2$s</a>',
525 esc_url(wp_nonce_url(add_query_arg($deactivate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
526 _x('Deactivate', 'List table row action', 'metasync')
527 );
528 }
529
530 $actions['delete'] = sprintf(
531 '<a href="%1$s">%2$s</a>',
532 esc_url(wp_nonce_url(add_query_arg($delete_query_args, 'admin.php'), 'metasync_redirection_row_action')),
533 _x('Delete', 'List table row action', 'metasync')
534 );
535
536 // Return the source from contents.
537 return sprintf(
538 '%1$s %2$s',
539 $this->show_source_from_urls($item),
540 $this->row_actions($actions)
541 );
542 }
543
544 /**
545 * Display pattern type column with user-friendly labels
546 */
547 protected function column_pattern_type($item)
548 {
549 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
550 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
551
552 // Pattern type labels
553 $labels = [
554 'exact' => 'Exact Match',
555 'contain' => 'Contains',
556 'start' => 'Starts With',
557 'end' => 'Ends With',
558 'regex' => 'Regex Pattern'
559 ];
560
561 $label = isset($labels[$pattern_type]) ? $labels[$pattern_type] : ucfirst($pattern_type);
562
563 // Add regex pattern as tooltip if available
564 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
565 // Validate regex pattern
566 // Check if pattern has proper delimiters (same character at start and end)
567 $test_pattern = $regex_pattern;
568 $first_char = substr($test_pattern, 0, 1);
569 $has_valid_delimiter = false;
570
571 // Check if it looks like a properly delimited regex
572 if (in_array($first_char, ['/', '#', '~', '%', '@', '!'], true)) {
573 // Find the last occurrence of the delimiter (accounting for flags after it)
574 if (preg_match('/^' . preg_quote($first_char, '/') . '.*' . preg_quote($first_char, '/') . '[imsxuADU]*$/', $test_pattern)) {
575 $has_valid_delimiter = true;
576 }
577 }
578
579 // Add delimiters if missing - use # to avoid conflicts with / in URL patterns
580 if (!$has_valid_delimiter) {
581 $test_pattern = '#' . str_replace('#', '\\#', $regex_pattern) . '#';
582 }
583 $is_valid = @preg_match($test_pattern, '');
584
585 if ($is_valid === false) {
586 // Invalid regex - show red warning icon
587 return sprintf(
588 '<span style="display: flex; align-items: center; gap: 5px;">
589 <span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 18px;" title="Invalid Regex Pattern: %s"></span>
590 <span title="Invalid regex pattern">%s</span>
591 </span>',
592 esc_attr($regex_pattern),
593 esc_html($label)
594 );
595 }
596
597 // Valid regex - show with tooltip
598 return sprintf(
599 '<span title="%s">%s</span>',
600 esc_attr($regex_pattern),
601 esc_html($label)
602 );
603 }
604
605 return esc_html($label);
606 }
607
608 /**
609 * Override current_action() so bulk actions triggered from the bottom
610 * dropdown (which posts `action2`) are detected alongside the top one
611 * (which posts `action`). WP_List_Table::current_action() only reads
612 * `action`, so without this the bottom Apply is a silent no-op.
613 *
614 * @return string|false
615 */
616 public function current_action()
617 {
618 // A "Filter"/query submit is not a bulk action. WP core's own
619 // current_action() short-circuits on filter_action for exactly this
620 // reason, and dropping that check makes ticking rows, choosing a bulk
621 // action and then clicking Filter silently run the action.
622 if (!empty($_REQUEST['filter_action'])) {
623 return false;
624 }
625
626 if (isset($_REQUEST['action']) && '-1' !== $_REQUEST['action'] && '' !== $_REQUEST['action']) {
627 return sanitize_text_field(wp_unslash($_REQUEST['action']));
628 }
629 if (isset($_REQUEST['action2']) && '-1' !== $_REQUEST['action2'] && '' !== $_REQUEST['action2']) {
630 return sanitize_text_field(wp_unslash($_REQUEST['action2']));
631 }
632 return parent::current_action();
633 }
634
635 protected function get_bulk_actions()
636 {
637 // On the combined Redirections screen BOTH this table and the 404
638 // monitor table are rendered, with CSS hiding the inactive tab. If the
639 // hidden one also emitted bulk controls the page would carry two
640 // #bulk-action-selector-top/-bottom and #doaction/#doaction2; WP core's
641 // common.js binds those by ID, so it wired up whichever table came
642 // first and left the visible tab's controls inert. Returning an empty
643 // array makes core render no dropdown and no Apply for the hidden
644 // table, which costs nothing (it cannot be clicked) and keeps the IDs
645 // unique.
646 //
647 // The flag is null unless we are on that combined screen, so this table
648 // keeps its bulk actions everywhere else.
649 if (class_exists('Metasync_Redirections_Admin')
650 && Metasync_Redirections_Admin::$combined_screen_active_tab !== null
651 && Metasync_Redirections_Admin::$combined_screen_active_tab !== 'redirections') {
652 return array();
653 }
654
655 $actions = array(
656 'delete_bulk' => _x('Delete', 'List table bulk action', 'metasync'),
657 'activate_bulk' => _x('Activate', 'List table bulk action', 'metasync'),
658 'deactivate_bulk' => _x('Deactivate', 'List table bulk action', 'metasync'),
659 );
660
661 return $actions;
662 }
663
664 protected function process_bulk_action()
665 {
666 $post_data = metasync_sanitize_input_array($_POST);
667 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
668
669 if (empty($post_data['items'])) return;
670
671 if (!$this->current_action()) {
672 return;
673 }
674
675 // Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
676 // (plural === 'items' → 'bulk-items') and confirm plugin access before any
677 // state change. Guards against CSRF on the bulk delete/activate/deactivate path.
678 check_admin_referer('bulk-items');
679
680 if (!Metasync::current_user_has_plugin_access()) {
681 return;
682 }
683
684 // Detect when bulk delete action is being triggered.
685 if ('delete_bulk' === $this->current_action()) {
686 $this->db_redirection->delete($items);
687 }
688
689 // Detect when bulk activate action is being triggered.
690 if ('activate_bulk' === $this->current_action()) {
691 $this->db_redirection->update_status($items, 'active');
692 }
693
694 // Detect when bulk deactivate action is being triggered.
695 if ('deactivate_bulk' === $this->current_action()) {
696 $this->db_redirection->update_status($items, 'inactive');
697 }
698 }
699
700 protected function process_row_action()
701 {
702 $state_actions = ['delete_redirect', 'activate', 'deactivate'];
703
704 if (!in_array($this->current_action(), $state_actions, true)) {
705 return;
706 }
707
708 // Verify the row-action nonce (added to the row-action links in column_sources_from())
709 // and confirm plugin access before any state change. The row actions run via GET
710 // links, so without this they are open to CSRF.
711 check_admin_referer('metasync_redirection_row_action');
712
713 if (!Metasync::current_user_has_plugin_access()) {
714 return;
715 }
716
717 $get_data = metasync_sanitize_input_array($_GET);
718 $item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
719
720 // Detect when a delete action is being triggered.
721 if ('delete_redirect' === $this->current_action()) {
722 $this->db_redirection->delete([$item]);
723 $this->redirect_after_row_action();
724 }
725 // Detect when a activate action is being triggered.
726 if ('activate' === $this->current_action()) {
727 $this->db_redirection->update_status([$item], 'active');
728 }
729 // Detect when a deactivate action is being triggered.
730 if ('deactivate' === $this->current_action()) {
731 $this->db_redirection->update_status([$item], 'inactive');
732 }
733 }
734
735 protected function redirect_after_row_action()
736 {
737 $redirect_url = remove_query_arg(['action', 'action2', 'id', '_wpnonce']);
738
739 if (!headers_sent()) {
740 wp_safe_redirect($redirect_url);
741 exit;
742 }
743
744 printf('<script>window.location.replace(%s);</script>', wp_json_encode(esc_url_raw($redirect_url)));
745 exit;
746 }
747
748 function prepare_items()
749 {
750 $per_page = Metasync_Per_Page_Helper::resolve('redirections', 10);
751 $this->resolved_per_page = $per_page;
752
753 $columns = $this->get_columns();
754 $hidden = array();
755 $sortable = $this->get_sortable_columns();
756 $this->_column_headers = array($columns, $hidden, $sortable);
757
758 $this->process_bulk_action();
759 $this->process_row_action();
760
761 $current_page = $this->get_pagenum();
762 $offset = ($current_page - 1) * $per_page;
763
764 // Get total count for pagination
765 $total_items = $this->getTotalRecords();
766
767 // Load only the records for the current page (already sorted by database)
768 $data = $this->loadRecordsWithPagination($per_page, $offset);
769
770 $this->items = $data;
771
772 $this->set_pagination_args(array(
773 'total_items' => $total_items, // Total number of items from database
774 'per_page' => $per_page, // Items per page
775 'total_pages' => (int) ceil($total_items / $per_page), // Total number of pages
776 ));
777 }
778
779 /**
780 * Override display method to prevent duplicate table rendering
781 */
782 public function display()
783 {
784 // Only display the table once
785 if (isset($this->_displayed) && $this->_displayed) {
786 return;
787 }
788
789 $this->_displayed = true;
790
791 // Call parent display method
792 parent::display();
793 }
794
795 /**
796 * Override display_tablenav to prevent duplicate navigation.
797 *
798 * Each position ('top' / 'bottom') renders at most once, tracked
799 * SEPARATELY. A single shared flag suppressed the bottom bar entirely,
800 * and with it the `action2` bulk control — so bulk actions could never be
801 * applied from the bottom of the list.
802 */
803 protected function display_tablenav($which)
804 {
805 // Only display each navigation position once
806 if (!empty($this->_nav_displayed[$which])) {
807 return;
808 }
809
810 $this->_nav_displayed[$which] = true;
811
812 parent::display_tablenav($which);
813 }
814
815 private function show_source_from_urls($item)
816 {
817 # $source_urls = unserialize($item['sources_from']);
818 # Unserialize the sources_from field
819 $source_urls = unserialize($item['sources_from'], ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
820
821 # If unserialize failed or returned non-array, try to handle it
822 if (!is_array($source_urls)) {
823 # Fallback: display raw value (shouldn't happen normally)
824 echo esc_html($item['sources_from']);
825 return;
826 }
827
828 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
829 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
830
831 foreach ($source_urls as $source_name => $source_type) {
832
833 # Clean the source name in case it contains serialized data
834 $clean_source = $source_name;
835
836 # Check if source_name itself is serialized (edge case)
837 if (is_string($source_name) && strpos($source_name, 'a:') === 0) {
838 $unserialized = unserialize($source_name, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
839 if (is_array($unserialized) && isset($unserialized[0]['pattern'])) {
840 $clean_source = $unserialized[0]['pattern'];
841 }
842 }
843
844 // For regex patterns, show the regex pattern instead of the label
845 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
846 $display_text = $regex_pattern;
847 // Build the full URL for the link (use the actual pattern for regex)
848 $full_url = '#'; // No direct link for regex patterns
849
850 // Validate regex pattern
851 // Add delimiters if missing
852 $test_pattern = $regex_pattern;
853 if (!preg_match('/^[\/#~%@]/', $test_pattern)) {
854 $test_pattern = '/' . $test_pattern . '/';
855 }
856 $is_valid = @preg_match($test_pattern, '');
857 $warning_icon = '';
858
859 if ($is_valid === false) {
860 // Invalid regex - add red warning icon
861 $warning_icon = '<span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="Invalid Regex Pattern"></span>';
862 }
863
864 echo sprintf(
865 '<span title="Label: %3$s">%1$s%2$s</span>',
866 $warning_icon,
867 esc_html($display_text),
868 esc_attr($clean_source)
869 );
870 } else {
871 // Build the full URL for the link
872 # $full_url = $this->build_full_url($source_name);
873 $full_url = $this->build_full_url($clean_source);
874
875 echo sprintf(
876 '<a href="%1$s" target="_blank">%2$s</a>',
877 esc_url($full_url),
878 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
879 esc_html($clean_source)
880 );
881 }
882 echo sprintf(
883 # '<span>%1$s</span>',
884 # ' [' . $source_type . ']'
885 # ' [' . esc_html($source_type) . ']' # Fixed: Added esc_html() to prevent XSS
886 '<span style="color: #666; font-size: 0.9em;">%1$s</span>',
887 ' [' . esc_html($source_type) . ']'
888 );
889 echo "<br>";
890 }
891 }
892
893 /**
894 * Build full URL from source name, handling different formats
895 */
896 private function build_full_url($source_name)
897 {
898 // If it's already a full URL, return as-is
899 if (strpos($source_name, 'http') === 0) {
900 return $source_name;
901 }
902
903 // If it starts with /, it's an absolute path
904 if (strpos($source_name, '/') === 0) {
905 return site_url() . $source_name;
906 }
907
908 // Otherwise, it's a relative path
909 return site_url() . '/' . $source_name;
910 }
911
912
913 /**
914 * Display HTTP code column with better formatting
915 */
916 protected function column_http_code($item)
917 {
918 $http_code = $item['http_code'];
919 $code_labels = [
920 '301' => '301 Permanent',
921 '302' => '302 Temporary',
922 '307' => '307 Temporary',
923 '410' => '410 Gone',
924 '451' => '451 Unavailable'
925 ];
926
927 $label = isset($code_labels[$http_code]) ? $code_labels[$http_code] : $http_code;
928
929 $class = '';
930 if ($http_code >= 300 && $http_code < 400) {
931 $class = 'color: #0073aa;';
932 } elseif ($http_code >= 400) {
933 $class = 'color: #d63638;';
934 }
935
936 return sprintf(
937 '<span style="%s">%s</span>',
938 esc_attr($class),
939 esc_html($label)
940 );
941 }
942 }
943