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

1,208 lines 43.4 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 /**
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
26 private $records;
27 private $db_redirection;
28 private $_displayed = false;
29 private $_nav_displayed = [];
30 /**
31 * Lazy-built map of shadowed row IDs → winning row destination.
32 *
33 * Replicates Metasync_Redirection::ensure_redirect_index() semantics:
34 * active rows are iterated in the same ORDER BY created_at DESC, id DESC
35 * order as the engine, the last write to each normalized exact source
36 * path wins, and every earlier (newer) row sharing that key is shadowed.
37 *
38 * @var array<int, string>|null
39 */
40 private $shadowed_sources = null;
41 /**
42 * User-resolved results-per-page value for this list (validated against
43 * Metasync_Per_Page_Helper::ALLOWED_VALUES). Cached here so pagination()
44 * can reuse it without re-resolving from the request / user meta.
45 *
46 * @var int
47 */
48 private $resolved_per_page = 10;
49 public function __construct()
50 {
51 // Set parent defaults.
52 parent::__construct(array(
53 'singular' => 'item', // Singular name of the listed records.
54 'plural' => 'items', // Plural name of the listed records.
55 'ajax' => false, // Does this table support ajax?
56 ));
57 }
58
59 /**
60 * Get the current page number for redirections (use separate parameter)
61 */
62 public function get_pagenum()
63 {
64 $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page number for the list query
65
66 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
67 $pagenum = $this->_pagination_args['total_pages'];
68 }
69
70 return max(1, $pagenum);
71 }
72
73 /**
74 * Override pagination args to preserve tab parameter
75 */
76 protected function get_views()
77 {
78 return array();
79 }
80
81 /**
82 * Ensure tab parameter is preserved in pagination links
83 */
84 protected function pagination($which)
85 {
86 if (empty($this->_pagination_args)) {
87 return;
88 }
89
90 $total_items = $this->_pagination_args['total_items'];
91 $total_pages = $this->_pagination_args['total_pages'];
92 $infinite_scroll = false;
93 if (isset($this->_pagination_args['infinite_scroll'])) {
94 $infinite_scroll = $this->_pagination_args['infinite_scroll'];
95 }
96
97 if ('top' === $which && $total_pages > 1) {
98 $this->screen->render_screen_reader_content('heading_pagination');
99 }
100
101 // Add tab parameter to pagination links
102 add_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
103
104 $output = '<span class="displaying-num">' . sprintf(
105 /* translators: %s: Number of items */
106 _n('%s item', '%s items', $total_items, 'metasync'),
107 number_format_i18n($total_items)
108 ) . '</span>';
109
110 $current = $this->get_pagenum();
111 $removable_query_args = wp_removable_query_args();
112
113 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
114
115 $current_url = remove_query_arg($removable_query_args, $current_url);
116
117 // Preserve the tab parameter
118 if (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
119 $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
120 }
121
122 // If filter action was submitted, remove paged_redir to reset to page 1
123 if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only values used in pagination links
124 $current_url = remove_query_arg('paged_redir', $current_url);
125 }
126
127 // Rebuild visible list state from the allow-listed request values. Removing
128 // every key first ensures clearing a POSTed filter also clears a stale GET
129 // value from generated pagination links.
130 $state_keys = array_merge(
131 Metasync_Per_Page_Helper::state_params('redirections'),
132 [Metasync_Per_Page_Helper::request_key('redirections')]
133 );
134 $current_url = remove_query_arg($state_keys, $current_url);
135 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
136
137 $page_links = array();
138
139 $total_pages_before = '<span class="paging-input">';
140 $total_pages_after = '</span></span>';
141
142 $disable_first = $disable_last = $disable_prev = $disable_next = false;
143
144 if ($current == 1) {
145 $disable_first = true;
146 $disable_prev = true;
147 }
148 if ($current == 2) {
149 $disable_first = true;
150 }
151 if ($current == $total_pages) {
152 $disable_last = true;
153 $disable_next = true;
154 }
155 if ($current == $total_pages - 1) {
156 $disable_last = true;
157 }
158
159 if ($disable_first) {
160 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
161 } else {
162 $page_links[] = sprintf(
163 "<a class='first-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
164 esc_url(remove_query_arg('paged_redir', $current_url)),
165 esc_html__('First page', 'metasync'),
166 '&laquo;'
167 );
168 }
169
170 if ($disable_prev) {
171 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
172 } else {
173 $page_links[] = sprintf(
174 "<a class='prev-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
175 esc_url(add_query_arg('paged_redir', max(1, $current - 1), $current_url)),
176 esc_html__('Previous page', 'metasync'),
177 '&lsaquo;'
178 );
179 }
180
181 if ('bottom' === $which) {
182 $html_current_page = $current;
183 $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">';
184 } else {
185 $html_current_page = sprintf(
186 "%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'>",
187 '<label for="current-page-selector-redir" class="screen-reader-text">' . esc_html__('Current Page', 'metasync') . '</label>',
188 $current,
189 strlen($total_pages)
190 );
191 }
192 $html_total_pages = sprintf("<span class='total-pages'>%s</span>", number_format_i18n($total_pages));
193 $page_links[] = $total_pages_before . sprintf(
194 /* translators: 1: Current page, 2: Total pages */
195 _x('%1$s of %2$s', 'paging', 'metasync'),
196 $html_current_page,
197 $html_total_pages
198 ) . $total_pages_after;
199
200 if ($disable_next) {
201 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
202 } else {
203 $page_links[] = sprintf(
204 "<a class='next-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
205 esc_url(add_query_arg('paged_redir', min($total_pages, $current + 1), $current_url)),
206 esc_html__('Next page', 'metasync'),
207 '&rsaquo;'
208 );
209 }
210
211 if ($disable_last) {
212 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
213 } else {
214 $page_links[] = sprintf(
215 "<a class='last-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
216 esc_url(add_query_arg('paged_redir', $total_pages, $current_url)),
217 esc_html__('Last page', 'metasync'),
218 '&raquo;'
219 );
220 }
221
222 $pagination_links_class = 'pagination-links';
223 if (!empty($infinite_scroll)) {
224 $pagination_links_class .= ' hide-if-js';
225 }
226 $output .= "\n<span class='$pagination_links_class'>" . join("\n", $page_links) . '</span>';
227
228 // Inline "rows per page" selector (10/20/50/100), persisted per user.
229 // $which keeps the top and bottom selectors' ids distinct.
230 $output .= Metasync_Per_Page_Helper::render_selector('redirections', $this->resolved_per_page, $which);
231
232 if ($total_pages) {
233 $page_class = $total_pages < 2 ? ' one-page' : '';
234 } else {
235 $page_class = ' no-pages';
236 }
237 $this->_pagination = "<div class='tablenav-pages{$page_class}'>$output</div>";
238
239 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::pagination(); assembled above from esc_url()/esc_html()/number_format_i18n() output
240 echo $this->_pagination;
241
242 remove_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
243 }
244
245 /**
246 * Preserve tab parameter in pagination
247 */
248 public function preserve_tab_in_pagination($args)
249 {
250 // Remove 'tab' from removable query args so it's preserved
251 $key = array_search('tab', $args, true);
252 if ($key !== false) {
253 unset($args[$key]);
254 }
255
256 // Also preserve our pagination parameter
257 $key = array_search('paged_redir', $args, true);
258 if ($key !== false) {
259 unset($args[$key]);
260 }
261
262 // Preserve this list's rows-per-page value across pagination / tab changes.
263 $key = array_search(Metasync_Per_Page_Helper::request_key('redirections'), $args, true);
264 if ($key !== false) {
265 unset($args[$key]);
266 }
267
268 // Remove the other pagination parameters to avoid conflicts
269 $args[] = 'paged';
270 $args[] = 'paged_404';
271
272 return array_unique($args);
273 }
274
275 public function setDatabaseResource(&$db_redirection)
276 {
277 $this->db_redirection = $db_redirection;
278 }
279
280 private function setRecords($records)
281 {
282 return $this->records = json_decode(json_encode($records), true);
283 }
284
285 private function loadRecords()
286 {
287 $filters = $this->get_search_filters();
288 return $this->setRecords($this->db_redirection->search_redirections($filters));
289 }
290
291 private function loadRecordsWithPagination($per_page, $offset)
292 {
293 $filters = $this->get_search_filters();
294 $filters['per_page'] = $per_page;
295 $filters['offset'] = $offset;
296 return $this->setRecords($this->db_redirection->search_redirections($filters));
297 }
298
299 private function getTotalRecords()
300 {
301 $filters = $this->get_search_filters();
302 return $this->db_redirection->count_redirections($filters);
303 }
304
305 /**
306 * Get search filters from request
307 */
308 private function get_search_filters()
309 {
310 $filters = [];
311
312 $request_state = Metasync_Per_Page_Helper::request_state('redirections');
313 if (!empty($request_state['s_redir'])) {
314 $filters['search'] = $request_state['s_redir'];
315 }
316
317 if (!empty($_REQUEST['status_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
318 $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
319 }
320
321 if (!empty($_REQUEST['pattern_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
322 $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
323 }
324
325 if (!empty($_REQUEST['http_code_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
326 $filters['http_code'] = intval($_REQUEST['http_code_filter']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
327 }
328
329 // Use separate orderby/order parameters for redirections
330 if (!empty($_REQUEST['orderby_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
331 $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
332 }
333
334 if (!empty($_REQUEST['order_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
335 $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
336 }
337
338 return $filters;
339 }
340
341 public function get_columns()
342 {
343 $columns = array(
344 'cb' => '<input type="checkbox" />', // Render a checkbox instead of text.
345 'sources_from' => _x('From', 'Column label', 'metasync'),
346 'url_redirect_to' => _x('To', 'Column label', 'metasync'),
347 'http_code' => _x('Type', 'Column label', 'metasync'),
348 'pattern_type' => _x('Pattern', 'Column label', 'metasync'),
349 'hits_count' => _x('Hits', 'Column label', 'metasync'),
350 'status' => _x('Status', 'Column label', 'metasync'),
351 'last_accessed_at' => _x('Last Accessed', 'Column label', 'metasync'),
352 );
353
354 return $columns;
355 }
356
357 protected function get_sortable_columns()
358 {
359 $sortable_columns = array(
360 'sources_from' => array('sources_from', false),
361 'url_redirect_to' => array('url_redirect_to', false),
362 'http_code' => array('http_code', false),
363 'pattern_type' => array('pattern_type', false),
364 'hits_count' => array('hits_count', false),
365 'status' => array('status', false),
366 'last_accessed_at' => array('last_accessed_at', false),
367 );
368
369 return $sortable_columns;
370 }
371
372 /**
373 * Override to use custom orderby/order parameter names
374 */
375 protected function get_orderby()
376 {
377 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
378 }
379
380 /**
381 * Override to use custom orderby/order parameter names
382 */
383 protected function get_order()
384 {
385 $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
386 if ($raw === 'desc') {
387 return 'desc';
388 }
389 return 'asc';
390 }
391
392 /**
393 * Override column headers to use custom parameter names for sorting
394 */
395 public function print_column_headers($with_id = true)
396 {
397 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
398
399 $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
400 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
401 $current_url = remove_query_arg(['_wpnonce', '_wp_http_referer', 'action', 'action2', 'id'], $current_url);
402 $current_url = add_query_arg('page', $current_page, $current_url);
403 $current_url = add_query_arg('tab', 'redirections', $current_url);
404 $current_url = remove_query_arg('paged_redir', $current_url);
405 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
406
407 $current_orderby = $this->get_orderby();
408 $current_order = $this->get_order();
409
410 foreach ($columns as $column_key => $column_display_name) {
411 $class = array('manage-column', "column-$column_key");
412
413 if (in_array($column_key, $hidden, true)) {
414 $class[] = 'hidden';
415 }
416
417 if ($column_key === $primary) {
418 $class[] = 'column-primary';
419 }
420
421 // Add check-column class for checkbox column
422 if ('cb' === $column_key) {
423 $class[] = 'check-column';
424 }
425
426 if (isset($sortable[$column_key])) {
427 list($orderby, $desc_first) = $sortable[$column_key];
428
429 if ($current_orderby === $orderby) {
430 $order = 'asc' === $current_order ? 'desc' : 'asc';
431 $class[] = 'sorted';
432 $class[] = $current_order;
433 } else {
434 $order = strtolower($desc_first);
435 if (!in_array($order, array('desc', 'asc'), true)) {
436 $order = $desc_first ? 'desc' : 'asc';
437 }
438 $class[] = 'sortable';
439 $class[] = 'desc' === $order ? 'asc' : 'desc';
440 }
441
442 $column_display_name = sprintf(
443 '<a href="%s"><span>%s</span><span class="sorting-indicators"></span></a>',
444 esc_url(add_query_arg(array('orderby_redir' => $orderby, 'order_redir' => $order), $current_url)),
445 esc_html($column_display_name)
446 );
447 } elseif ('cb' !== $column_key) {
448 $column_display_name = esc_html($column_display_name);
449 }
450
451 $tag = ('cb' === $column_key) ? 'td' : 'th';
452 $scope = ('th' === $tag) ? 'scope="col"' : '';
453 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
454
455 if (!empty($class)) {
456 $class = "class='" . esc_attr(implode(' ', $class)) . "'";
457 }
458
459 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::print_column_headers(); $scope is a literal, $id/$class escaped via esc_attr() and \$column_display_name via esc_html() just above
460 echo '<' . esc_attr($tag) . ' ' . $scope . ' ' . $id . ' ' . $class . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
461 }
462 }
463
464 /**
465 * Render the Last Accessed column.
466 *
467 * A redirect that has never been fired stores the MySQL zero-date
468 * `0000-00-00 00:00:00` (or an empty string) in `last_accessed_at`.
469 * Passing that raw value through to the user reads as a broken or
470 * corrupted date, so render an explicit "Never" instead. Real
471 * timestamps are formatted the same way the 404 Monitor formats its
472 * date column (`date('M j, Y g:i A', ...)` with a human-time-diff
473 * hint).
474 *
475 * WP_List_Table dispatches `column_{name}` methods before falling back
476 * to `column_default()`, so defining this keeps the zero-date out of
477 * `column_default()`'s raw `esc_html()` passthrough.
478 */
479 protected function column_last_accessed_at($item)
480 {
481 $raw = isset($item['last_accessed_at']) ? $item['last_accessed_at'] : '';
482
483 // A redirect that has never been hit stores the database's empty
484 // timestamp. Treat an empty value, the exact zero-date string, and
485 // any unparseable / pre-epoch strtotime() result as "never accessed".
486 if (
487 empty($raw)
488 || '0000-00-00 00:00:00' === $raw
489 || '0000-00-00' === $raw
490 ) {
491 return esc_html__('Never', 'metasync');
492 }
493
494 $timestamp = strtotime($raw);
495 if ($timestamp === false || $timestamp < 0) {
496 return esc_html__('Never', 'metasync');
497 }
498
499 $time_diff = human_time_diff($timestamp, current_time('timestamp'));
500
501 return sprintf(
502 '%s<br><small>%s ago</small>',
503 esc_html(gmdate('M j, Y g:i A', $timestamp)),
504 esc_html($time_diff)
505 );
506 }
507
508 protected function column_default($item, $column_name)
509 {
510 switch ($column_name) {
511 case 'sources_from':
512 case 'url_redirect_to':
513 case 'http_code':
514 case 'hits_count':
515 case 'status':
516 return esc_html($item[$column_name]); # Fixed: Added esc_html() to prevent XSS
517 case 'pattern_type':
518 return $this->column_pattern_type($item);
519 default:
520 # return print_r($item, true); // Show the whole array for troubleshooting purposes.
521 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- unreachable in production
522 return esc_html(print_r($item, true)); # Fixed: Added esc_html() to prevent XSS in debug output
523 }
524 }
525
526 protected function column_cb($item)
527 {
528 return sprintf(
529 '<input type="checkbox" name="%1$s[]" value="%2$s" />',
530 $this->_args['plural'], // Let's simply repurpose the table's singular label ("error").
531 $item['id'] // The value of the checkbox should be the record's ID.
532 );
533 }
534
535 /**
536 * Build the shadowed-source index for the current page of rows.
537 *
538 * Two active redirect rows can share the same normalized exact source
539 * path, but the redirect engine only ever fires one — the winner of the
540 * last index write in ensure_redirect_index(). Every other active row
541 * with that same source is "shadowed": it looks active in the list but
542 * never fires. This replicates the engine's exact-index semantics
543 * (ORDER BY created_at DESC, id DESC, last write wins) to flag the
544 * losing rows so the user can tell which destination a visitor actually
545 * reaches.
546 *
547 * Returns an array keyed by shadowed row ID, with the value being the
548 * winning row's destination URL (for the tooltip). Only exact-pattern
549 * sources are considered — wildcard/regex/contain/start/end sources do
550 * not collapse onto a single key in the engine's index.
551 *
552 * @return array<int, string> Map of shadowed row ID => winning destination URL.
553 */
554 protected function get_shadowed_sources()
555 {
556 if ($this->shadowed_sources !== null) {
557 return $this->shadowed_sources;
558 }
559
560 $this->shadowed_sources = array();
561
562 if ($this->db_redirection === null) {
563 return $this->shadowed_sources;
564 }
565
566 // getAllActiveRecords() returns rows ordered by created_at DESC, id DESC —
567 // the exact same order the engine iterates in ensure_redirect_index().
568 $active_rows = $this->db_redirection->getAllActiveRecords();
569 if (empty($active_rows)) {
570 return $this->shadowed_sources;
571 }
572
573 // For each normalized exact source path, the LAST row in the iteration
574 // order wins (its assignment to exact_index[$norm] overwrites earlier
575 // ones). Because the order is DESC, the oldest row is processed last,
576 // so the oldest active row wins — matching the engine exactly.
577 $winners = array(); // norm => { 'id' => int, 'destination' => string }
578 $seen = array(); // norm => int[] row ids that wrote this key
579
580 foreach ($active_rows as $row) {
581 $sources_from = !empty($row->sources_from)
582 ? unserialize($row->sources_from, array('allowed_classes' => false))
583 : array();
584 $source_urls = is_array($sources_from) ? $sources_from : array();
585 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
586
587 foreach ($source_urls as $source_key => $source_value) {
588 // Legacy list-format rows store the URL as the VALUE under a
589 // numeric key; remap so the legacy source lands in the exact
590 // index instead of '/0'.
591 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
592 $source_key = $source_value;
593 }
594 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
595 ? $source_value
596 : ($global_pattern_type ? $global_pattern_type : 'exact');
597
598 if ($pattern_type !== 'exact' || strpos((string) $source_key, '*') !== false) {
599 continue;
600 }
601
602 // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash.
603 $norm = (string) $source_key;
604 if (strpos($norm, 'http') === 0) {
605 $parsed = wp_parse_url($norm);
606 $norm = isset($parsed['path']) ? $parsed['path'] : '/';
607 }
608 if ($norm === '' || $norm[0] !== '/') {
609 $norm = '/' . $norm;
610 }
611 $norm = rtrim($norm, '/') ?: '/';
612
613 if (!isset($seen[$norm])) {
614 $seen[$norm] = array();
615 }
616 $seen[$norm][] = (int) $row->id;
617 // Last write wins — overwrite with each row in iteration order,
618 // so the final value is the oldest row for this source.
619 $winners[$norm] = array(
620 'id' => (int) $row->id,
621 'destination' => isset($row->url_redirect_to) ? (string) $row->url_redirect_to : '',
622 );
623 }
624 }
625
626 // Any source path claimed by more than one active row is a duplicate;
627 // every row that is NOT the winner is shadowed.
628 foreach ($seen as $norm => $ids) {
629 if (count($ids) < 2 || !isset($winners[$norm])) {
630 continue;
631 }
632 $winner_id = $winners[$norm]['id'];
633 $winner_dest = $winners[$norm]['destination'];
634 foreach ($ids as $row_id) {
635 if ($row_id !== $winner_id) {
636 $this->shadowed_sources[$row_id] = $winner_dest;
637 }
638 }
639 }
640
641 return $this->shadowed_sources;
642 }
643
644 protected function column_sources_from($item)
645 {
646 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, row-action links carry their own nonce
647 $request_data = metasync_sanitize_input_array($_REQUEST); // WPCS: Input var ok.
648 if (!isset($request_data['page'])) return;
649
650 // Build delete row action.
651 $delete_query_args = array(
652 'page' => sanitize_text_field($request_data['page']),
653 'tab' => 'redirections',
654 'action' => 'delete_redirect',
655 'id' => $item['id'],
656 );
657 $per_page_key = Metasync_Per_Page_Helper::request_key('redirections');
658 if (isset($request_data[$per_page_key])) {
659 $delete_query_args[$per_page_key] = sanitize_text_field($request_data[$per_page_key]);
660 }
661
662 // Build edit row action.
663 $edit_query_args = array(
664 'page' => sanitize_text_field($request_data['page']),
665 'action' => 'edit',
666 'id' => $item['id'],
667 );
668
669 // Build activate row action.
670 $activate_query_args = array(
671 'page' => sanitize_text_field($request_data['page']),
672 'action' => 'activate',
673 'id' => $item['id'],
674 );
675
676 // Build deactivate row action.
677 $deactivate_query_args = array(
678 'page' => sanitize_text_field($request_data['page']),
679 'action' => 'deactivate',
680 'id' => $item['id'],
681 );
682
683 $actions['edit'] = sprintf(
684 '<a href="%1$s">%2$s</a>',
685 esc_url(add_query_arg($edit_query_args, 'admin.php')),
686 _x('Edit', 'List table row action', 'metasync')
687 );
688
689 if ($item['status'] === 'inactive') {
690
691 $actions['activate'] = sprintf(
692 '<a href="%1$s">%2$s</a>',
693 esc_url(wp_nonce_url(add_query_arg($activate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
694 _x('Activate', 'List table row action', 'metasync')
695 );
696 } else {
697
698 $actions['deactivate'] = sprintf(
699 '<a href="%1$s">%2$s</a>',
700 esc_url(wp_nonce_url(add_query_arg($deactivate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
701 _x('Deactivate', 'List table row action', 'metasync')
702 );
703 }
704
705 $actions['delete'] = sprintf(
706 '<a href="%1$s">%2$s</a>',
707 esc_url(wp_nonce_url(add_query_arg($delete_query_args, 'admin.php'), 'metasync_redirection_row_action')),
708 _x('Delete', 'List table row action', 'metasync')
709 );
710
711 // Return the source from contents.
712 return sprintf(
713 '%1$s %2$s',
714 $this->show_source_from_urls($item),
715 $this->row_actions($actions)
716 );
717 }
718
719 /**
720 * Display pattern type column with user-friendly labels
721 */
722 protected function column_pattern_type($item)
723 {
724 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
725 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
726
727 // Pattern type labels
728 $labels = [
729 'exact' => 'Exact Match',
730 'contain' => 'Contains',
731 'start' => 'Starts With',
732 'end' => 'Ends With',
733 'regex' => 'Regex Pattern'
734 ];
735
736 $label = isset($labels[$pattern_type]) ? $labels[$pattern_type] : ucfirst($pattern_type);
737
738 // Add regex pattern as tooltip if available
739 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
740 // Validate regex pattern
741 // Check if pattern has proper delimiters (same character at start and end)
742 $test_pattern = $regex_pattern;
743 $first_char = substr($test_pattern, 0, 1);
744 $has_valid_delimiter = false;
745
746 // Check if it looks like a properly delimited regex
747 if (in_array($first_char, ['/', '#', '~', '%', '@', '!'], true)) {
748 // Find the last occurrence of the delimiter (accounting for flags after it)
749 if (preg_match('/^' . preg_quote($first_char, '/') . '.*' . preg_quote($first_char, '/') . '[imsxuADU]*$/', $test_pattern)) {
750 $has_valid_delimiter = true;
751 }
752 }
753
754 // Add delimiters if missing - use # to avoid conflicts with / in URL patterns
755 if (!$has_valid_delimiter) {
756 $test_pattern = '#' . str_replace('#', '\\#', $regex_pattern) . '#';
757 }
758 $is_valid = @preg_match($test_pattern, '');
759
760 if ($is_valid === false) {
761 // Invalid regex - show red warning icon
762 return sprintf(
763 '<span style="display: flex; align-items: center; gap: 5px;">
764 <span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 18px;" title="Invalid Regex Pattern: %s"></span>
765 <span title="Invalid regex pattern">%s</span>
766 </span>',
767 esc_attr($regex_pattern),
768 esc_html($label)
769 );
770 }
771
772 // Valid regex - show with tooltip
773 return sprintf(
774 '<span title="%s">%s</span>',
775 esc_attr($regex_pattern),
776 esc_html($label)
777 );
778 }
779
780 return esc_html($label);
781 }
782
783 /**
784 * Override current_action() so bulk actions triggered from the bottom
785 * dropdown (which posts `action2`) are detected alongside the top one
786 * (which posts `action`). WP_List_Table::current_action() only reads
787 * `action`, so without this the bottom Apply is a silent no-op.
788 *
789 * @return string|false
790 */
791 public function current_action()
792 {
793 // A "Filter"/"Search" submit is not a bulk action. WP core's own
794 // current_action() short-circuits on filter_action for exactly this
795 // reason, and dropping that check makes ticking rows, choosing a bulk
796 // action and then clicking Filter silently run the action. The Search
797 // submit posts no filter_action, so it needs its own named marker to
798 // stop the same silent delete when it shares a form with the dropdown.
799 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
800 return false;
801 }
802
803 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
804 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
805 }
806 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
807 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
808 }
809 return parent::current_action();
810 }
811
812 protected function get_bulk_actions()
813 {
814 // On the combined Redirections screen BOTH this table and the 404
815 // monitor table are rendered, with CSS hiding the inactive tab. If the
816 // hidden one also emitted bulk controls the page would carry two
817 // #bulk-action-selector-top/-bottom and #doaction/#doaction2; WP core's
818 // common.js binds those by ID, so it wired up whichever table came
819 // first and left the visible tab's controls inert. Returning an empty
820 // array makes core render no dropdown and no Apply for the hidden
821 // table, which costs nothing (it cannot be clicked) and keeps the IDs
822 // unique.
823 //
824 // The flag is null unless we are on that combined screen, so this table
825 // keeps its bulk actions everywhere else.
826 if (class_exists('Metasync_Redirections_Admin')
827 && Metasync_Redirections_Admin::$combined_screen_active_tab !== null
828 && Metasync_Redirections_Admin::$combined_screen_active_tab !== 'redirections') {
829 return array();
830 }
831
832 $actions = array(
833 'delete_bulk' => _x('Delete', 'List table bulk action', 'metasync'),
834 'activate_bulk' => _x('Activate', 'List table bulk action', 'metasync'),
835 'deactivate_bulk' => _x('Deactivate', 'List table bulk action', 'metasync'),
836 );
837
838 return $actions;
839 }
840
841 /**
842 * Guarantee the Apply button posts a marker identifying it as the submitter.
843 *
844 * The bulk dropdown, the row checkboxes and the bulk-items nonce all live
845 * inside the same form as the Search field and the filter controls, so every
846 * submit of that form carries a complete-looking bulk request. The only thing
847 * that distinguishes a real Apply from Search, Enter-in-search or a filter
848 * dropdown's auto-submit is which submit button (if any) was used, and a
849 * button only reports itself when it has a name attribute.
850 *
851 * WordPress core names the Apply button `bulk_action` from 7.1 onwards; on
852 * the older releases this plugin still supports get_submit_button() is called
853 * with an empty name and omits the attribute entirely. Add it ourselves in
854 * that case so process_bulk_action() can rely on the marker on every
855 * supported version, with no JavaScript involved.
856 *
857 * @param string $which Which tablenav is being rendered ('top' or 'bottom').
858 * @return void
859 */
860 protected function bulk_actions($which = '')
861 {
862 ob_start();
863 parent::bulk_actions($which);
864 $markup = ob_get_clean();
865
866 // get_bulk_actions() returns an empty array on screens where this table
867 // must not emit bulk controls, and core then renders nothing at all.
868 if (!is_string($markup) || '' === $markup) {
869 return;
870 }
871
872 if (false === strpos($markup, 'name="' . self::BULK_APPLY_MARKER . '"')) {
873 $named = preg_replace(
874 '/<input\b(?=[^>]*\bid="doaction2?")/',
875 '<input name="' . self::BULK_APPLY_MARKER . '"',
876 $markup,
877 1
878 );
879
880 // Keep core's markup untouched if the shape ever changes and the
881 // pattern stops matching, rather than emitting nothing.
882 if (is_string($named)) {
883 $markup = $named;
884 }
885 }
886
887 echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core markup, already escaped by WP_List_Table.
888 }
889
890 /**
891 * Whether the current request was submitted with the Apply button.
892 *
893 * Only Apply carries the marker added in bulk_actions(); Search and the
894 * unnamed implicit-submission button send no submit-button value, and a
895 * filter dropdown's programmatic form.submit() sends none either. Testing
896 * for the marker's presence rather than for the absence of `filter_action`
897 * keeps every current and future non-Apply submit path suppressed by
898 * default.
899 *
900 * @param array $post_data Sanitised copy of $_POST.
901 * @return bool
902 */
903 private function is_bulk_apply_submit($post_data)
904 {
905 return !empty($post_data[self::BULK_APPLY_MARKER]);
906 }
907
908 protected function process_bulk_action()
909 {
910 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() runs before any write
911 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
912
913 if (empty($post_data['items'])) return;
914
915 // Suppress every submit that did not come from Apply. Search, Enter in the
916 // search box and a filter dropdown's auto-submit all post the bulk
917 // dropdown, the ticked rows and a valid nonce, so without this the
918 // selected action ran on any of them - silently, with no confirmation.
919 if (!$this->is_bulk_apply_submit($post_data)) {
920 return;
921 }
922
923 if (!$this->current_action()) {
924 return;
925 }
926
927 // Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
928 // (plural === 'items' → 'bulk-items') and confirm plugin access before any
929 // state change. Guards against CSRF on the bulk delete/activate/deactivate path.
930 check_admin_referer('bulk-items');
931
932 if (!Metasync::current_user_has_plugin_access()) {
933 return;
934 }
935
936 // Detect when bulk delete action is being triggered.
937 if ('delete_bulk' === $this->current_action()) {
938 $this->db_redirection->delete($items);
939 }
940
941 // Detect when bulk activate action is being triggered.
942 if ('activate_bulk' === $this->current_action()) {
943 $this->db_redirection->update_status($items, 'active');
944 }
945
946 // Detect when bulk deactivate action is being triggered.
947 if ('deactivate_bulk' === $this->current_action()) {
948 $this->db_redirection->update_status($items, 'inactive');
949 }
950 }
951
952 protected function process_row_action()
953 {
954 $state_actions = ['delete_redirect', 'activate', 'deactivate'];
955
956 if (!in_array($this->current_action(), $state_actions, true)) {
957 return;
958 }
959
960 // Verify the row-action nonce (added to the row-action links in column_sources_from())
961 // and confirm plugin access before any state change. The row actions run via GET
962 // links, so without this they are open to CSRF.
963 check_admin_referer('metasync_redirection_row_action');
964
965 if (!Metasync::current_user_has_plugin_access()) {
966 return;
967 }
968
969 $get_data = metasync_sanitize_input_array($_GET);
970 $item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
971
972 // Detect when a delete action is being triggered.
973 if ('delete_redirect' === $this->current_action()) {
974 $this->db_redirection->delete([$item]);
975 $this->redirect_after_row_action();
976 }
977 // Detect when a activate action is being triggered.
978 if ('activate' === $this->current_action()) {
979 $this->db_redirection->update_status([$item], 'active');
980 }
981 // Detect when a deactivate action is being triggered.
982 if ('deactivate' === $this->current_action()) {
983 $this->db_redirection->update_status([$item], 'inactive');
984 }
985 }
986
987 protected function redirect_after_row_action()
988 {
989 $redirect_url = remove_query_arg(['action', 'action2', 'id', '_wpnonce']);
990
991 if (!headers_sent()) {
992 wp_safe_redirect($redirect_url);
993 exit;
994 }
995
996 printf('<script>window.location.replace(%s);</script>', wp_json_encode(esc_url_raw($redirect_url)));
997 exit;
998 }
999
1000 function prepare_items()
1001 {
1002 $per_page = Metasync_Per_Page_Helper::resolve('redirections', 10);
1003 $this->resolved_per_page = $per_page;
1004
1005 $columns = $this->get_columns();
1006 $hidden = array();
1007 $sortable = $this->get_sortable_columns();
1008 $this->_column_headers = array($columns, $hidden, $sortable);
1009
1010 $this->process_bulk_action();
1011 $this->process_row_action();
1012
1013 $current_page = $this->get_pagenum();
1014 $offset = ($current_page - 1) * $per_page;
1015
1016 // Get total count for pagination
1017 $total_items = $this->getTotalRecords();
1018
1019 // Load only the records for the current page (already sorted by database)
1020 $data = $this->loadRecordsWithPagination($per_page, $offset);
1021
1022 $this->items = $data;
1023
1024 $this->set_pagination_args(array(
1025 'total_items' => $total_items, // Total number of items from database
1026 'per_page' => $per_page, // Items per page
1027 'total_pages' => (int) ceil($total_items / $per_page), // Total number of pages
1028 ));
1029 }
1030
1031 /**
1032 * Override display method to prevent duplicate table rendering
1033 */
1034 public function display()
1035 {
1036 // Only display the table once
1037 if (isset($this->_displayed) && $this->_displayed) {
1038 return;
1039 }
1040
1041 $this->_displayed = true;
1042
1043 // Call parent display method
1044 parent::display();
1045 }
1046
1047 /**
1048 * Override display_tablenav to prevent duplicate navigation.
1049 *
1050 * Each position ('top' / 'bottom') renders at most once, tracked
1051 * SEPARATELY. A single shared flag suppressed the bottom bar entirely,
1052 * and with it the `action2` bulk control — so bulk actions could never be
1053 * applied from the bottom of the list.
1054 */
1055 protected function display_tablenav($which)
1056 {
1057 // Only display each navigation position once
1058 if (!empty($this->_nav_displayed[$which])) {
1059 return;
1060 }
1061
1062 $this->_nav_displayed[$which] = true;
1063
1064 parent::display_tablenav($which);
1065 }
1066
1067 private function show_source_from_urls($item)
1068 {
1069 # $source_urls = unserialize($item['sources_from']);
1070 # Unserialize the sources_from field
1071 $source_urls = unserialize($item['sources_from'], ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
1072
1073 # If unserialize failed or returned non-array, try to handle it
1074 if (!is_array($source_urls)) {
1075 # Fallback: display raw value (shouldn't happen normally)
1076 echo esc_html($item['sources_from']);
1077 return;
1078 }
1079
1080 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
1081 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
1082
1083 foreach ($source_urls as $source_name => $source_type) {
1084
1085 # Clean the source name in case it contains serialized data
1086 $clean_source = $source_name;
1087
1088 # Check if source_name itself is serialized (edge case)
1089 if (is_string($source_name) && strpos($source_name, 'a:') === 0) {
1090 $unserialized = unserialize($source_name, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
1091 if (is_array($unserialized) && isset($unserialized[0]['pattern'])) {
1092 $clean_source = $unserialized[0]['pattern'];
1093 }
1094 }
1095
1096 // For regex patterns, show the regex pattern instead of the label
1097 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
1098 $display_text = $regex_pattern;
1099 // Build the full URL for the link (use the actual pattern for regex)
1100 $full_url = '#'; // No direct link for regex patterns
1101
1102 // Validate regex pattern
1103 // Add delimiters if missing
1104 $test_pattern = $regex_pattern;
1105 if (!preg_match('/^[\/#~%@]/', $test_pattern)) {
1106 $test_pattern = '/' . $test_pattern . '/';
1107 }
1108 $is_valid = @preg_match($test_pattern, '');
1109 $warning_icon = '';
1110
1111 if ($is_valid === false) {
1112 // Invalid regex - add red warning icon
1113 $warning_icon = '<span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="Invalid Regex Pattern"></span>';
1114 }
1115
1116 echo sprintf(
1117 '<span title="Label: %3$s">%1$s%2$s</span>',
1118 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal dashicon span defined in this method
1119 $warning_icon,
1120 esc_html($display_text),
1121 esc_attr($clean_source)
1122 );
1123 } else {
1124 // Build the full URL for the link
1125 # $full_url = $this->build_full_url($source_name);
1126 $full_url = $this->build_full_url($clean_source);
1127
1128 echo sprintf(
1129 '<a href="%1$s" target="_blank">%2$s</a>',
1130 esc_url($full_url),
1131 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
1132 esc_html($clean_source)
1133 );
1134 }
1135 echo "<br>";
1136 }
1137
1138 // Shadowed-source badge: when two active exact-match rules share the
1139 // same normalized source path, only the oldest row (the engine's last
1140 // index write) ever fires. Flag the losing row so the user can tell
1141 // the dead rule from the live one.
1142 $shadowed = $this->get_shadowed_sources();
1143 $row_id = isset($item['id']) ? (int) $item['id'] : 0;
1144 if ($row_id > 0 && isset($shadowed[$row_id])) {
1145 $winner_dest = $shadowed[$row_id];
1146 echo sprintf(
1147 '<span class="dashicons dashicons-warning" style="color: #d63638; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="%1$s"></span>',
1148 esc_attr(sprintf(
1149 /* translators: %s: destination URL of the winning (firing) rule */
1150 __('Shadowed — this rule never fires. An earlier active rule with the same source points to: %s', 'metasync'),
1151 $winner_dest
1152 ))
1153 );
1154 echo '<span style="color: #d63638; font-size: 0.9em; font-weight: 600;">' . esc_html(__('Shadowed', 'metasync')) . '</span>';
1155 }
1156 }
1157
1158 /**
1159 * Build full URL from source name, handling different formats
1160 */
1161 private function build_full_url($source_name)
1162 {
1163 // If it's already a full URL, return as-is
1164 if (strpos($source_name, 'http') === 0) {
1165 return $source_name;
1166 }
1167
1168 // If it starts with /, it's an absolute path
1169 if (strpos($source_name, '/') === 0) {
1170 return site_url() . $source_name;
1171 }
1172
1173 // Otherwise, it's a relative path
1174 return site_url() . '/' . $source_name;
1175 }
1176
1177
1178 /**
1179 * Display HTTP code column with better formatting
1180 */
1181 protected function column_http_code($item)
1182 {
1183 $http_code = $item['http_code'];
1184 $code_labels = [
1185 '301' => '301 Permanent',
1186 '302' => '302 Temporary',
1187 '307' => '307 Temporary',
1188 '410' => '410 Gone',
1189 '451' => '451 Unavailable'
1190 ];
1191
1192 $label = isset($code_labels[$http_code]) ? $code_labels[$http_code] : $http_code;
1193
1194 $class = '';
1195 if ($http_code >= 300 && $http_code < 400) {
1196 $class = 'color: #0073aa;';
1197 } elseif ($http_code >= 400) {
1198 $class = 'color: #d63638;';
1199 }
1200
1201 return sprintf(
1202 '<span style="%s">%s</span>',
1203 esc_attr($class),
1204 esc_html($label)
1205 );
1206 }
1207 }
1208