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 / admin / class-metasync-seo-health-list-table.php

class-metasync-seo-health-list-table.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/class-metasync-seo-health-list-table.php

516 lines 16.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 * SEO Health List Table
9 *
10 * Displays all posts/pages with their SEO completeness indicators.
11 * Uses CSS classes (not inline styles) for dark-theme compatibility.
12 *
13 * @since 3.0.0
14 * @package Metasync
15 * @subpackage Metasync/admin
16 */
17 class Metasync_SEO_Health_List_Table extends WP_List_Table
18 {
19 /** Nonce action for bulk actions. */
20 const NONCE_ACTION = 'metasync_seo_health_bulk';
21
22 public function __construct()
23 {
24 parent::__construct(array(
25 'singular' => 'seo-health-item',
26 'plural' => 'seo-health-items',
27 'ajax' => false,
28 ));
29 }
30
31 public function get_columns()
32 {
33 return array(
34 'cb' => '<input type="checkbox" />',
35 'title' => _x('Title', 'Column label', 'metasync'),
36 'post_type' => _x('Post Type', 'Column label', 'metasync'),
37 'status' => _x('Status', 'Column label', 'metasync'),
38 'seo_title' => _x('SEO Title', 'Column label', 'metasync'),
39 'meta_description' => _x('Meta Description', 'Column label', 'metasync'),
40 'schema' => _x('Schema', 'Column label', 'metasync'),
41 'og_image' => _x('OG Image', 'Column label', 'metasync'),
42 'alt_text_coverage' => _x('Alt Text', 'Column label', 'metasync'),
43 'last_modified' => _x('Last Modified', 'Column label', 'metasync'),
44 );
45 }
46
47 protected function get_sortable_columns()
48 {
49 return array(
50 'title' => array('title', false),
51 'post_type' => array('post_type', false),
52 'status' => array('post_status', false),
53 'last_modified' => array('post_modified', false),
54 );
55 }
56
57 protected function column_cb($item)
58 {
59 return sprintf(
60 '<input type="checkbox" name="post_ids[]" value="%s" />',
61 absint($item->ID)
62 );
63 }
64
65 protected function column_default($item, $column_name)
66 {
67 switch ($column_name) {
68 case 'title':
69 return sprintf(
70 '<a href="%s"><strong>%s</strong></a>',
71 esc_url(get_edit_post_link($item->ID)),
72 esc_html($item->post_title)
73 );
74
75 case 'post_type':
76 $pt_obj = get_post_type_object($item->post_type);
77 return esc_html($pt_obj ? $pt_obj->labels->singular_name : $item->post_type);
78
79 case 'status':
80 return esc_html(ucfirst($item->post_status));
81
82 case 'seo_title':
83 return $this->render_seo_meta_column($item->ID, 'title', 30, 60);
84
85 case 'meta_description':
86 return $this->render_seo_meta_column($item->ID, 'description', 120, 160);
87
88 case 'schema':
89 return Metasync_SEO_Health::has_schema($item->ID)
90 ? '<span class="seo-status-set optimal">&#10003; Yes</span>'
91 : '<span class="seo-status-none">&mdash; None</span>';
92
93 case 'og_image':
94 return Metasync_SEO_Health::has_og_image($item->ID)
95 ? '<span class="seo-status-set optimal">&#10003; Set</span>'
96 : '<span class="seo-status-missing">&#9888; Missing</span>';
97
98 case 'alt_text_coverage':
99 return $this->render_alt_text_column($item);
100
101 case 'last_modified':
102 return esc_html(get_the_modified_date('Y-m-d', $item));
103
104 default:
105 return '';
106 }
107 }
108
109 /**
110 * Render an SEO meta column (title or description) with fallback and length indicator.
111 *
112 * The resolver still reports which tier supplied the value, but the column
113 * deliberately ignores it: the table answers whether the field is set, not
114 * which internal source filled it. Naming one source means naming them all
115 * — OTTO, OG, Yoast, Rank Math, AIOSEO — and a row of competing labels
116 * reads as noise to a customer who only owns one of them.
117 */
118 private function render_seo_meta_column($post_id, $field, $min_optimal, $max_optimal)
119 {
120 $result = Metasync_SEO_Health::get_seo_meta_with_fallback($post_id, $field);
121
122 if (empty($result['value'])) {
123 return '<span class="seo-status-missing">&#9888; Missing</span>';
124 }
125
126 $length = mb_strlen($result['value']);
127 $quality = ($length >= $min_optimal && $length <= $max_optimal) ? 'optimal' : 'suboptimal';
128
129 return sprintf(
130 '<span class="seo-status-set %s">&#10003; %d chars</span>',
131 $quality,
132 $length
133 );
134 }
135
136 /**
137 * Render alt text coverage column.
138 */
139 private function render_alt_text_column($item)
140 {
141 $alt = Metasync_SEO_Health::calculate_alt_text_coverage($item->post_content);
142
143 if ($alt['total'] === 0) {
144 return '<span class="seo-status-none">No images</span>';
145 }
146
147 $quality = 'seo-status-missing';
148 if ($alt['percentage'] === 100) {
149 $quality = 'seo-status-set optimal';
150 } elseif ($alt['percentage'] >= 50) {
151 $quality = 'seo-status-set suboptimal';
152 }
153
154 return sprintf(
155 '<span class="%s">%d%% (%d/%d)</span>',
156 $quality,
157 $alt['percentage'],
158 $alt['with_alt'],
159 $alt['total']
160 );
161 }
162
163 protected function get_bulk_actions()
164 {
165 return array(
166 'push_to_platform' => _x('Push to platform for optimization', 'List table bulk action', 'metasync'),
167 /* translators: %s: effective plugin name (whitelabel-aware). */
168 'clear_metasync_cache' => sprintf(_x('Clear %s cache for selected', 'List table bulk action', 'metasync'), Metasync::get_effective_plugin_name()),
169 );
170 }
171
172 protected function process_bulk_action()
173 {
174 if (!$this->current_action()) {
175 return;
176 }
177
178 check_admin_referer(self::NONCE_ACTION, '_wpnonce_bulk');
179
180 $post_ids = isset($_REQUEST['post_ids']) && is_array($_REQUEST['post_ids'])
181 ? array_map('absint', $_REQUEST['post_ids'])
182 : array();
183
184 if (empty($post_ids)) {
185 return;
186 }
187
188 if ('clear_metasync_cache' === $this->current_action()) {
189 if (class_exists('Metasync_Cache_Purge')) {
190 foreach ($post_ids as $post_id) {
191 $url = get_permalink($post_id);
192 if ($url) {
193 Metasync_Cache_Purge::purge_single_url($url);
194 }
195 }
196 }
197 }
198
199 if ('push_to_platform' === $this->current_action()) {
200 // TODO: Implement Search Atlas API integration to queue posts for optimization.
201 // The action hook is in place — needs a listener once the backend API endpoint exists.
202 do_action('metasync_push_posts_to_platform', $post_ids);
203 }
204 }
205
206 /**
207 * Output the nonce field for bulk actions.
208 * Called automatically by WP_List_Table::display().
209 */
210 protected function extra_tablenav($which)
211 {
212 if ($which === 'top') {
213 wp_nonce_field(self::NONCE_ACTION, '_wpnonce_bulk');
214 ?>
215 <div class="alignleft actions">
216 <?php $this->render_filter_dropdown('post_type_filter', $this->get_post_type_options(), __('All Post Types', 'metasync')); ?>
217 <?php $this->render_filter_dropdown('status_filter', $this->get_status_options(), __('All Statuses', 'metasync')); ?>
218 <?php $this->render_filter_dropdown('missing_filter', $this->get_missing_options(), __('All Items', 'metasync')); ?>
219 <?php submit_button(__('Filter', 'metasync'), '', 'filter_action', false); ?>
220 </div>
221 <?php
222 }
223 }
224
225 /**
226 * Render a single filter dropdown.
227 */
228 private function render_filter_dropdown($name, $options, $default_label)
229 {
230 $current = isset($_GET[$name]) ? sanitize_text_field(wp_unslash($_GET[$name])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
231 ?>
232 <select name="<?php echo esc_attr($name); ?>">
233 <option value=""><?php echo esc_html($default_label); ?></option>
234 <?php foreach ($options as $value => $label): ?>
235 <option value="<?php echo esc_attr($value); ?>" <?php selected($current, $value); ?>>
236 <?php echo esc_html($label); ?>
237 </option>
238 <?php endforeach; ?>
239 </select>
240 <?php
241 }
242
243 private function get_post_type_options()
244 {
245 $options = array();
246 foreach (Metasync_SEO_Health::get_supported_post_types() as $pt) {
247 $pt_obj = get_post_type_object($pt);
248 if ($pt_obj) {
249 $options[$pt] = $pt_obj->labels->singular_name;
250 }
251 }
252 return $options;
253 }
254
255 private function get_status_options()
256 {
257 return array(
258 'publish' => __('Published', 'metasync'),
259 'draft' => __('Draft', 'metasync'),
260 );
261 }
262
263 private function get_missing_options()
264 {
265 return array(
266 'missing_title' => __('Missing SEO Title', 'metasync'),
267 'missing_description' => __('Missing Description', 'metasync'),
268 'missing_schema' => __('Missing Schema', 'metasync'),
269 'missing_og_image' => __('Missing OG Image', 'metasync'),
270 'missing_alt_text' => __('Missing Alt Text', 'metasync'),
271 );
272 }
273
274 public function prepare_items()
275 {
276 $per_page = 20;
277
278 $this->_column_headers = array(
279 $this->get_columns(),
280 array(),
281 $this->get_sortable_columns(),
282 );
283
284 $this->process_bulk_action();
285
286 $current_page = $this->get_pagenum();
287 $missing_filter = isset($_GET['missing_filter']) ? sanitize_text_field(wp_unslash($_GET['missing_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
288
289 $args = array(
290 'post_status' => array('publish', 'draft'),
291 'orderby' => 'date',
292 'order' => 'DESC',
293 );
294
295 // Post type filter — validate against allowed types
296 $post_type_filter = isset($_GET['post_type_filter']) ? sanitize_text_field(wp_unslash($_GET['post_type_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
297 $supported_types = Metasync_SEO_Health::get_supported_post_types();
298 $args['post_type'] = (!empty($post_type_filter) && in_array($post_type_filter, $supported_types, true))
299 ? $post_type_filter
300 : $supported_types;
301
302 // Status filter — validate against allowed statuses
303 $status_filter = isset($_GET['status_filter']) ? sanitize_text_field(wp_unslash($_GET['status_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
304 if (!empty($status_filter) && in_array($status_filter, array('publish', 'draft'), true)) {
305 $args['post_status'] = $status_filter;
306 }
307
308 // Search
309 $search = isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
310 if (!empty($search)) {
311 $args['s'] = $search;
312 }
313
314 // Sorting — validate orderby against allowed columns
315 $allowed_orderby = array('title', 'post_type', 'post_status', 'post_modified', 'date');
316 $orderby = isset($_GET['orderby']) ? sanitize_text_field(wp_unslash($_GET['orderby'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
317 $order = isset($_GET['order']) ? sanitize_text_field(wp_unslash($_GET['order'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query
318 if (!empty($orderby) && in_array($orderby, $allowed_orderby, true)) {
319 $args['orderby'] = $orderby;
320 $args['order'] = (!empty($order) && in_array(strtoupper($order), array('ASC', 'DESC'), true))
321 ? strtoupper($order)
322 : 'ASC';
323 }
324
325 // Always exclude custom/LPS pages — their SEO is self-managed inside their
326 // own HTML bundle, so they would otherwise show as false "not set".
327 $args['metasync_exclude_custom_pages'] = true;
328 add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2);
329
330 // Missing filters (title, description, schema, og_image) use correlated
331 // anti-existence conditions instead of per-key postmeta joins.
332 // This avoids multiplying rows on large stores, especially when found_posts is calculated.
333 $query = null;
334 if ($this->uses_optimized_missing_meta_filter($missing_filter)) {
335 $args['posts_per_page'] = $per_page;
336 $args['paged'] = $current_page;
337 $args['metasync_seo_health_missing_filter'] = $missing_filter;
338
339 add_filter('posts_where', array($this, 'filter_missing_meta_where'), 10, 2);
340 try {
341 $query = new WP_Query($args);
342 } finally {
343 remove_filter('posts_where', array($this, 'filter_missing_meta_where'), 10);
344 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
345 }
346
347 $this->items = $query->posts;
348 $total_items = $query->found_posts;
349 }
350
351 if (!($query instanceof WP_Query)) {
352 try {
353 if ($missing_filter === 'missing_alt_text') {
354 // Alt text requires post_content inspection and post-query filtering.
355 $args['posts_per_page'] = -1;
356 $query = new WP_Query($args);
357 $all_filtered = $this->filter_missing_alt_text($query->posts);
358 $total_items = count($all_filtered);
359 $this->items = array_slice($all_filtered, ($current_page - 1) * $per_page, $per_page);
360 } else {
361 // No missing filter (or unrecognized filter) - standard paginated query.
362 $args['posts_per_page'] = $per_page;
363 $args['paged'] = $current_page;
364
365 $query = new WP_Query($args);
366 $this->items = $query->posts;
367 $total_items = $query->found_posts;
368 }
369 } finally {
370 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
371 }
372 }
373
374 $this->set_pagination_args(array(
375 'total_items' => $total_items,
376 'per_page' => $per_page,
377 'total_pages' => ceil($total_items / $per_page),
378 ));
379 }
380
381 /**
382 * Whether a missing filter can use the single-query anti-existence path.
383 *
384 * @param string $filter The missing_filter value.
385 * @return bool
386 */
387 private function uses_optimized_missing_meta_filter($filter)
388 {
389 return in_array($filter, array('missing_title', 'missing_description', 'missing_schema', 'missing_og_image'), true);
390 }
391
392 /**
393 * Add the prepared anti-existence predicate for missing filters.
394 *
395 * The key maps come from the shared precedence resolver, so the
396 * optimized query checks exactly the same active-plugin and fallback keys as
397 * the row renderer. Empty values remain missing because the resolver treats
398 * only a non-empty value as set.
399 *
400 * @param string $where Existing WHERE clause.
401 * @param WP_Query $query Current query.
402 * @return string
403 */
404 public function filter_missing_meta_where($where, $query)
405 {
406 $filter = $query->get('metasync_seo_health_missing_filter');
407 if (!$this->uses_optimized_missing_meta_filter($filter)) {
408 return $where;
409 }
410
411 return $where . self::build_missing_meta_filter_where($filter);
412 }
413
414 /**
415 * Build correlated NOT EXISTS condition for a missing filter.
416 *
417 * @param string $filter The missing_filter value.
418 * @return string Prepared SQL, or an empty string for unsupported filters.
419 */
420 private static function build_missing_meta_filter_where($filter)
421 {
422 global $wpdb;
423
424 if ($filter === 'missing_schema') {
425 return $wpdb->prepare(
426 " AND NOT EXISTS (\n"
427 . "\tSELECT 1\n"
428 . "\tFROM {$wpdb->postmeta} AS metasync_seo_health_meta\n"
429 . "\tWHERE metasync_seo_health_meta.post_id = {$wpdb->posts}.ID\n"
430 . "\t\tAND metasync_seo_health_meta.meta_key = %s\n"
431 . "\t\tAND metasync_seo_health_meta.meta_value NOT IN ('', '[]')\n"
432 . ")",
433 'metasync_schema_markup'
434 );
435 }
436
437 if ($filter === 'missing_og_image') {
438 return $wpdb->prepare(
439 " AND NOT EXISTS (\n"
440 . "\tSELECT 1\n"
441 . "\tFROM {$wpdb->postmeta} AS metasync_seo_health_og\n"
442 . "\tWHERE metasync_seo_health_og.post_id = {$wpdb->posts}.ID\n"
443 . "\t\tAND metasync_seo_health_og.meta_key = %s\n"
444 . "\t\tAND metasync_seo_health_og.meta_value <> ''\n"
445 . ")\n"
446 . "AND NOT EXISTS (\n"
447 . "\tSELECT 1\n"
448 . "\tFROM {$wpdb->postmeta} AS metasync_seo_health_thumb\n"
449 . "\tWHERE metasync_seo_health_thumb.post_id = {$wpdb->posts}.ID\n"
450 . "\t\tAND metasync_seo_health_thumb.meta_key = %s\n"
451 . "\t\tAND metasync_seo_health_thumb.meta_value <> ''\n"
452 . ")",
453 '_metasync_og_image',
454 '_thumbnail_id'
455 );
456 }
457
458 $keys = self::get_missing_meta_keys($filter);
459 if (empty($keys)) {
460 return '';
461 }
462
463 return $wpdb->prepare(
464 " AND NOT EXISTS (\n"
465 . "\tSELECT 1\n"
466 . "\tFROM {$wpdb->postmeta} AS metasync_seo_health_meta\n"
467 . "\tWHERE metasync_seo_health_meta.post_id = {$wpdb->posts}.ID\n"
468 . "\t\tAND metasync_seo_health_meta.meta_key IN (" . implode(', ', array_fill(0, count($keys), '%s')) . ")\n"
469 . "\t\tAND metasync_seo_health_meta.meta_value <> ''\n"
470 . ")",
471 $keys
472 );
473 }
474
475 /**
476 * Return the precedence keys used by a missing title/description filter.
477 *
478 * @param string $filter The missing_filter value.
479 * @return string[]
480 */
481 private static function get_missing_meta_keys($filter)
482 {
483 if ($filter === 'missing_title') {
484 return array_keys(Metasync_SEO_Health::title_meta_keys());
485 }
486
487 if ($filter === 'missing_description') {
488 return array_keys(Metasync_SEO_Health::desc_meta_keys());
489 }
490
491 return array();
492 }
493
494 /**
495 * Filter posts that have images missing alt text.
496 *
497 * @param array $posts Array of WP_Post objects.
498 * @return array Filtered posts with missing alt text.
499 */
500 private function filter_missing_alt_text($posts)
501 {
502 $filtered = array();
503 foreach ($posts as $post) {
504 if (preg_match_all('/<img[^>]*>/i', $post->post_content, $matches)) {
505 foreach ($matches[0] as $img_tag) {
506 if (!preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img_tag)) {
507 $filtered[] = $post;
508 break;
509 }
510 }
511 }
512 }
513 return $filtered;
514 }
515 }
516