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

945 lines 30.0 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"/"Search" 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. The Search
622 // submit posts no filter_action, so it needs its own named marker to
623 // stop the same silent delete when it shares a form with the dropdown.
624 if (!empty($_REQUEST['filter_action']) || !empty($_REQUEST['search_submit'])) {
625 return false;
626 }
627
628 if (isset($_REQUEST['action']) && '-1' !== $_REQUEST['action'] && '' !== $_REQUEST['action']) {
629 return sanitize_text_field(wp_unslash($_REQUEST['action']));
630 }
631 if (isset($_REQUEST['action2']) && '-1' !== $_REQUEST['action2'] && '' !== $_REQUEST['action2']) {
632 return sanitize_text_field(wp_unslash($_REQUEST['action2']));
633 }
634 return parent::current_action();
635 }
636
637 protected function get_bulk_actions()
638 {
639 // On the combined Redirections screen BOTH this table and the 404
640 // monitor table are rendered, with CSS hiding the inactive tab. If the
641 // hidden one also emitted bulk controls the page would carry two
642 // #bulk-action-selector-top/-bottom and #doaction/#doaction2; WP core's
643 // common.js binds those by ID, so it wired up whichever table came
644 // first and left the visible tab's controls inert. Returning an empty
645 // array makes core render no dropdown and no Apply for the hidden
646 // table, which costs nothing (it cannot be clicked) and keeps the IDs
647 // unique.
648 //
649 // The flag is null unless we are on that combined screen, so this table
650 // keeps its bulk actions everywhere else.
651 if (class_exists('Metasync_Redirections_Admin')
652 && Metasync_Redirections_Admin::$combined_screen_active_tab !== null
653 && Metasync_Redirections_Admin::$combined_screen_active_tab !== 'redirections') {
654 return array();
655 }
656
657 $actions = array(
658 'delete_bulk' => _x('Delete', 'List table bulk action', 'metasync'),
659 'activate_bulk' => _x('Activate', 'List table bulk action', 'metasync'),
660 'deactivate_bulk' => _x('Deactivate', 'List table bulk action', 'metasync'),
661 );
662
663 return $actions;
664 }
665
666 protected function process_bulk_action()
667 {
668 $post_data = metasync_sanitize_input_array($_POST);
669 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
670
671 if (empty($post_data['items'])) return;
672
673 if (!$this->current_action()) {
674 return;
675 }
676
677 // Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
678 // (plural === 'items' → 'bulk-items') and confirm plugin access before any
679 // state change. Guards against CSRF on the bulk delete/activate/deactivate path.
680 check_admin_referer('bulk-items');
681
682 if (!Metasync::current_user_has_plugin_access()) {
683 return;
684 }
685
686 // Detect when bulk delete action is being triggered.
687 if ('delete_bulk' === $this->current_action()) {
688 $this->db_redirection->delete($items);
689 }
690
691 // Detect when bulk activate action is being triggered.
692 if ('activate_bulk' === $this->current_action()) {
693 $this->db_redirection->update_status($items, 'active');
694 }
695
696 // Detect when bulk deactivate action is being triggered.
697 if ('deactivate_bulk' === $this->current_action()) {
698 $this->db_redirection->update_status($items, 'inactive');
699 }
700 }
701
702 protected function process_row_action()
703 {
704 $state_actions = ['delete_redirect', 'activate', 'deactivate'];
705
706 if (!in_array($this->current_action(), $state_actions, true)) {
707 return;
708 }
709
710 // Verify the row-action nonce (added to the row-action links in column_sources_from())
711 // and confirm plugin access before any state change. The row actions run via GET
712 // links, so without this they are open to CSRF.
713 check_admin_referer('metasync_redirection_row_action');
714
715 if (!Metasync::current_user_has_plugin_access()) {
716 return;
717 }
718
719 $get_data = metasync_sanitize_input_array($_GET);
720 $item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
721
722 // Detect when a delete action is being triggered.
723 if ('delete_redirect' === $this->current_action()) {
724 $this->db_redirection->delete([$item]);
725 $this->redirect_after_row_action();
726 }
727 // Detect when a activate action is being triggered.
728 if ('activate' === $this->current_action()) {
729 $this->db_redirection->update_status([$item], 'active');
730 }
731 // Detect when a deactivate action is being triggered.
732 if ('deactivate' === $this->current_action()) {
733 $this->db_redirection->update_status([$item], 'inactive');
734 }
735 }
736
737 protected function redirect_after_row_action()
738 {
739 $redirect_url = remove_query_arg(['action', 'action2', 'id', '_wpnonce']);
740
741 if (!headers_sent()) {
742 wp_safe_redirect($redirect_url);
743 exit;
744 }
745
746 printf('<script>window.location.replace(%s);</script>', wp_json_encode(esc_url_raw($redirect_url)));
747 exit;
748 }
749
750 function prepare_items()
751 {
752 $per_page = Metasync_Per_Page_Helper::resolve('redirections', 10);
753 $this->resolved_per_page = $per_page;
754
755 $columns = $this->get_columns();
756 $hidden = array();
757 $sortable = $this->get_sortable_columns();
758 $this->_column_headers = array($columns, $hidden, $sortable);
759
760 $this->process_bulk_action();
761 $this->process_row_action();
762
763 $current_page = $this->get_pagenum();
764 $offset = ($current_page - 1) * $per_page;
765
766 // Get total count for pagination
767 $total_items = $this->getTotalRecords();
768
769 // Load only the records for the current page (already sorted by database)
770 $data = $this->loadRecordsWithPagination($per_page, $offset);
771
772 $this->items = $data;
773
774 $this->set_pagination_args(array(
775 'total_items' => $total_items, // Total number of items from database
776 'per_page' => $per_page, // Items per page
777 'total_pages' => (int) ceil($total_items / $per_page), // Total number of pages
778 ));
779 }
780
781 /**
782 * Override display method to prevent duplicate table rendering
783 */
784 public function display()
785 {
786 // Only display the table once
787 if (isset($this->_displayed) && $this->_displayed) {
788 return;
789 }
790
791 $this->_displayed = true;
792
793 // Call parent display method
794 parent::display();
795 }
796
797 /**
798 * Override display_tablenav to prevent duplicate navigation.
799 *
800 * Each position ('top' / 'bottom') renders at most once, tracked
801 * SEPARATELY. A single shared flag suppressed the bottom bar entirely,
802 * and with it the `action2` bulk control — so bulk actions could never be
803 * applied from the bottom of the list.
804 */
805 protected function display_tablenav($which)
806 {
807 // Only display each navigation position once
808 if (!empty($this->_nav_displayed[$which])) {
809 return;
810 }
811
812 $this->_nav_displayed[$which] = true;
813
814 parent::display_tablenav($which);
815 }
816
817 private function show_source_from_urls($item)
818 {
819 # $source_urls = unserialize($item['sources_from']);
820 # Unserialize the sources_from field
821 $source_urls = unserialize($item['sources_from'], ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
822
823 # If unserialize failed or returned non-array, try to handle it
824 if (!is_array($source_urls)) {
825 # Fallback: display raw value (shouldn't happen normally)
826 echo esc_html($item['sources_from']);
827 return;
828 }
829
830 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
831 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
832
833 foreach ($source_urls as $source_name => $source_type) {
834
835 # Clean the source name in case it contains serialized data
836 $clean_source = $source_name;
837
838 # Check if source_name itself is serialized (edge case)
839 if (is_string($source_name) && strpos($source_name, 'a:') === 0) {
840 $unserialized = unserialize($source_name, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
841 if (is_array($unserialized) && isset($unserialized[0]['pattern'])) {
842 $clean_source = $unserialized[0]['pattern'];
843 }
844 }
845
846 // For regex patterns, show the regex pattern instead of the label
847 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
848 $display_text = $regex_pattern;
849 // Build the full URL for the link (use the actual pattern for regex)
850 $full_url = '#'; // No direct link for regex patterns
851
852 // Validate regex pattern
853 // Add delimiters if missing
854 $test_pattern = $regex_pattern;
855 if (!preg_match('/^[\/#~%@]/', $test_pattern)) {
856 $test_pattern = '/' . $test_pattern . '/';
857 }
858 $is_valid = @preg_match($test_pattern, '');
859 $warning_icon = '';
860
861 if ($is_valid === false) {
862 // Invalid regex - add red warning icon
863 $warning_icon = '<span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="Invalid Regex Pattern"></span>';
864 }
865
866 echo sprintf(
867 '<span title="Label: %3$s">%1$s%2$s</span>',
868 $warning_icon,
869 esc_html($display_text),
870 esc_attr($clean_source)
871 );
872 } else {
873 // Build the full URL for the link
874 # $full_url = $this->build_full_url($source_name);
875 $full_url = $this->build_full_url($clean_source);
876
877 echo sprintf(
878 '<a href="%1$s" target="_blank">%2$s</a>',
879 esc_url($full_url),
880 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
881 esc_html($clean_source)
882 );
883 }
884 echo sprintf(
885 # '<span>%1$s</span>',
886 # ' [' . $source_type . ']'
887 # ' [' . esc_html($source_type) . ']' # Fixed: Added esc_html() to prevent XSS
888 '<span style="color: #666; font-size: 0.9em;">%1$s</span>',
889 ' [' . esc_html($source_type) . ']'
890 );
891 echo "<br>";
892 }
893 }
894
895 /**
896 * Build full URL from source name, handling different formats
897 */
898 private function build_full_url($source_name)
899 {
900 // If it's already a full URL, return as-is
901 if (strpos($source_name, 'http') === 0) {
902 return $source_name;
903 }
904
905 // If it starts with /, it's an absolute path
906 if (strpos($source_name, '/') === 0) {
907 return site_url() . $source_name;
908 }
909
910 // Otherwise, it's a relative path
911 return site_url() . '/' . $source_name;
912 }
913
914
915 /**
916 * Display HTTP code column with better formatting
917 */
918 protected function column_http_code($item)
919 {
920 $http_code = $item['http_code'];
921 $code_labels = [
922 '301' => '301 Permanent',
923 '302' => '302 Temporary',
924 '307' => '307 Temporary',
925 '410' => '410 Gone',
926 '451' => '451 Unavailable'
927 ];
928
929 $label = isset($code_labels[$http_code]) ? $code_labels[$http_code] : $http_code;
930
931 $class = '';
932 if ($http_code >= 300 && $http_code < 400) {
933 $class = 'color: #0073aa;';
934 } elseif ($http_code >= 400) {
935 $class = 'color: #d63638;';
936 }
937
938 return sprintf(
939 '<span style="%s">%s</span>',
940 esc_attr($class),
941 esc_html($label)
942 );
943 }
944 }
945