PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / admin / class-post-list-columns.php

class-post-list-columns.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/admin/class-post-list-columns.php

788 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Post List Columns Class
5 *
6 * Handles custom columns in WordPress admin post list tables
7 *
8 * @package ThinkRank\Admin
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Post List Columns Class
23 *
24 * Single Responsibility: Manage custom columns in post list tables
25 *
26 * @since 1.0.0
27 */
28 class Post_List_Columns {
29
30 /**
31 * Column ID
32 *
33 * @var string
34 */
35 private const COLUMN_ID = 'thinkrank_seo_overview';
36
37 /**
38 * Post meta holding the cached on-the-fly column metrics.
39 *
40 * Postmeta rather than a transient on purpose: WordPress primes the meta
41 * cache for every row of a list table in one query, so reading this costs
42 * nothing extra, whereas N non-autoloaded transients would be N queries.
43 *
44 * @since 1.28.0
45 * @var string
46 */
47 private const METRICS_CACHE_META = '_thinkrank_seo_column_metrics';
48
49 /**
50 * Ceiling on how many posts may be scored from scratch in a single request.
51 *
52 * Scoring resolves builder markup (`do_blocks()` + `do_shortcode()`), so an
53 * uncached list at 200-per-page could otherwise stall the page. Results are
54 * cached per post, so anything skipped here is picked up on a later load and
55 * the cap stops mattering once the list has been viewed. Sits comfortably
56 * above WordPress's default 20-per-page.
57 *
58 * @since 1.28.0
59 * @var int
60 */
61 private const MAX_SCORED_PER_REQUEST = 50;
62
63 /**
64 * Number of posts scored from scratch so far in this request.
65 *
66 * @since 1.28.0
67 * @var int
68 */
69 private static int $scored_this_request = 0;
70
71 /**
72 * Initialize post list columns
73 *
74 * @return void
75 */
76 public function init(): void {
77 // Get all public post types
78 add_action('admin_init', function () {
79 $post_types = $this->get_supported_post_types();
80
81 foreach ($post_types as $post_type) {
82 // Add column header
83 add_filter("manage_{$post_type}_posts_columns", [$this, 'add_column_header'], 10);
84
85 // Add column content
86 add_action("manage_{$post_type}_posts_custom_column", [$this, 'render_column_content'], 10, 2);
87
88 // Make column sortable
89 add_filter("manage_edit-{$post_type}_sortable_columns", [$this, 'make_column_sortable'], 10);
90 }
91 });
92
93 // Handle sorting
94 add_action('pre_get_posts', [$this, 'handle_column_sorting']);
95
96 // Enqueue admin styles for column
97 add_action('admin_enqueue_scripts', [$this, 'enqueue_column_styles']);
98 }
99
100 /**
101 * Get supported post types
102 *
103 * @return array Array of post type names
104 */
105 private function get_supported_post_types(): array {
106 $post_types = get_post_types(['public' => true], 'names');
107
108 // Remove unnecessary post types
109 unset($post_types['attachment']);
110 unset($post_types['elementor_library']);
111 unset($post_types['oceanwp_library']);
112 unset($post_types['ae_global_templates']);
113 // Divi library + Theme Builder templates.
114 unset($post_types['et_pb_layout']);
115 unset($post_types['et_theme_builder']);
116 unset($post_types['et_template']);
117 unset($post_types['et_header_layout']);
118 unset($post_types['et_body_layout']);
119 unset($post_types['et_footer_layout']);
120
121 /**
122 * Filter supported post types for ThinkRank SEO Overview column
123 *
124 * @param array $post_types Array of post type names
125 */
126 return apply_filters('thinkrank_seo_overview_post_types', $post_types);
127 }
128
129 /**
130 * Add column header
131 *
132 * @param array $columns Existing columns
133 * @return array Modified columns
134 */
135 public function add_column_header(array $columns): array {
136 // Insert column before date column
137 $new_columns = [];
138
139 foreach ($columns as $key => $value) {
140 if ($key === 'date') {
141 $new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank');
142 }
143 $new_columns[$key] = $value;
144 }
145
146 // If date column doesn't exist, add at the end
147 if (!isset($columns['date'])) {
148 $new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank');
149 }
150
151 return $new_columns;
152 }
153
154 /**
155 * Render column content
156 *
157 * @param string $column_name Column name
158 * @param int $post_id Post ID
159 * @return void
160 */
161 public function render_column_content(string $column_name, int $post_id): void {
162 if ($column_name !== self::COLUMN_ID) {
163 return;
164 }
165
166 // Get SEO data
167 $seo_data = $this->get_seo_data($post_id);
168
169 // Render the column content
170 $this->render_seo_overview($seo_data, $post_id);
171 }
172
173 /**
174 * Get SEO data for a post
175 *
176 * @param int $post_id Post ID
177 * @return array SEO data
178 */
179 private function get_seo_data(int $post_id): array {
180 // Get focus keyword(s). The column shows only the primary keyword, but
181 // the inline editor operates on the full comma-separated set so editing
182 // never drops the secondary keywords.
183 $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id);
184 $focus_keyword = implode(', ', $focus_keywords);
185 $focus_keyword_primary = $focus_keywords[0] ?? '';
186
187 // Get the effective meta title/description. When no custom per-post value
188 // is set, ThinkRank still renders these from the Bulk SEO Optimization
189 // (Global SEO) patterns, so resolve those inherited values here instead of
190 // reporting "Not Set" for content that is actually output on the frontend.
191 $meta_title = \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id);
192 $meta_description = \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id);
193
194 // Raw per-post values (what the Quick Edit inputs actually edit — the
195 // effective values above are what the placeholders show). Both reads hit
196 // the already-primed meta cache.
197 $seo_title_raw = (string) get_post_meta($post_id, '_thinkrank_seo_title', true);
198 $meta_description_raw = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
199
200 // Get SEO score and metrics — the stored analysis when it is still
201 // current, otherwise one calculated on the spot.
202 $seo_metrics = $this->get_seo_metrics($post_id, $meta_title, $meta_description, $focus_keywords);
203
204 // Get content quality and readability from database columns
205 $content_quality = $seo_metrics['content_quality'] ?? 'Not Analyzed';
206 $readability = $seo_metrics['readability_score'] ?? 'N/A';
207
208 return [
209 'seo_score' => $seo_metrics['seo_score'],
210 'seo_grade' => $seo_metrics['seo_grade'],
211 'content_quality' => $content_quality,
212 'readability' => $readability,
213 'focus_keyword' => !empty($focus_keyword),
214 'focus_keyword_value' => $focus_keyword,
215 'focus_keyword_primary' => $focus_keyword_primary,
216 'meta_title' => !empty($meta_title),
217 'meta_description' => !empty($meta_description),
218 'seo_title_raw' => $seo_title_raw,
219 'meta_description_raw' => $meta_description_raw,
220 // Placeholders in Quick Edit, so decoded to the text the page
221 // shows: get_the_title() texturizes "&" into `&#038;`, which
222 // esc_attr() then carried into the field verbatim.
223 'effective_title' => \ThinkRank\Core\Seo_Text::as_displayed($meta_title),
224 'effective_description' => \ThinkRank\Core\Seo_Text::as_displayed($meta_description),
225 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1',
226 ];
227 }
228
229 /**
230 * Check if link suggestions are enabled for a post type
231 *
232 * @param string $post_type Post type
233 * @return bool True if enabled
234 */
235 private function is_link_suggestions_enabled(string $post_type): bool {
236 $settings = get_option('thinkrank_global_seo_settings', []);
237
238 if (isset($settings[$post_type]['link_suggestions'])) {
239 return (bool) $settings[$post_type]['link_suggestions'];
240 }
241
242 return true;
243 }
244
245 /**
246 * Cached result of the scores-table existence check.
247 *
248 * The post-list column renders this method once per row; the table schema
249 * cannot change mid-request, so the `SHOW TABLES LIKE` probe is memoized for
250 * the lifetime of the request instead of re-running per row.
251 *
252 * @since 1.16.2
253 * @var bool|null Null until first resolved, then the boolean result.
254 */
255 private static ?bool $scores_table_exists = null;
256
257 /**
258 * Resolve the metrics to display for a post.
259 *
260 * Prefers the stored analysis, but only while it still describes the post as
261 * it stands: a score saved before the last edit is stale, and showing it is
262 * how a rewritten post could keep advertising the score of the draft it
263 * replaced. When there is no usable stored score the metrics are calculated
264 * here instead.
265 *
266 * That fallback is the difference between a column that reads "N/A / Not
267 * Analyzed" forever and one that reports a real score straight away. A score
268 * row is only ever written by an explicit analysis (the editor panel, the
269 * bulk analyzer, an MCP call or the importer) — nothing scores a post on
270 * save — so before this fallback existed, editing the SEO title and
271 * description left the column empty with no indication that a separate,
272 * manual step was what it was waiting for.
273 *
274 * @since 1.28.0
275 *
276 * @param int $post_id Post ID.
277 * @param string $title Effective meta title.
278 * @param string $description Effective meta description.
279 * @param string[] $keywords Focus keywords.
280 * @return array SEO metrics (seo_score, seo_grade, content_quality, readability_score)
281 */
282 private function get_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
283 $stored = $this->get_seo_metrics_from_database($post_id);
284
285 if ($stored['seo_score'] !== null && !$this->is_score_stale($post_id, $stored['calculated_at'] ?? null)) {
286 return $stored;
287 }
288
289 return $this->calculate_seo_metrics($post_id, $title, $description, $keywords);
290 }
291
292 /**
293 * Whether a stored score predates the post's last modification.
294 *
295 * Both timestamps are site-local `Y-m-d H:i:s` — `post_modified` from
296 * WordPress and `created_at` written with `current_time('mysql')` — so they
297 * are directly comparable as strings. A missing timestamp counts as stale;
298 * calculating is cheap and cached, and a score we cannot date is one we
299 * cannot vouch for.
300 *
301 * @since 1.28.0
302 *
303 * @param int $post_id Post ID.
304 * @param string|null $calculated_at When the stored score was written.
305 * @return bool True when the post changed after the score was calculated.
306 */
307 private function is_score_stale(int $post_id, ?string $calculated_at): bool {
308 if (empty($calculated_at)) {
309 return true;
310 }
311
312 $post = get_post($post_id);
313 if (!$post instanceof \WP_Post || empty($post->post_modified)) {
314 return false;
315 }
316
317 return $post->post_modified > $calculated_at;
318 }
319
320 /**
321 * Calculate a post's metrics locally, caching the result against the inputs
322 * that produced it.
323 *
324 * The scoring engine is pure PHP — no AI provider and no HTTP calls — so the
325 * list table can run it directly. The cache is keyed on a fingerprint of
326 * everything the score depends on (last modification, effective title and
327 * description, focus keywords, plugin version), which means a Global SEO
328 * pattern change or an inline focus-keyword edit invalidates it on its own,
329 * with nothing to hook and no cache to sweep.
330 *
331 * @since 1.28.0
332 *
333 * @param int $post_id Post ID.
334 * @param string $title Effective meta title.
335 * @param string $description Effective meta description.
336 * @param string[] $keywords Focus keywords.
337 * @return array SEO metrics; score/grade null when the post could not be scored.
338 */
339 private function calculate_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
340 $empty = [
341 'seo_score' => null,
342 'seo_grade' => null,
343 'content_quality' => null,
344 'readability_score' => null,
345 ];
346
347 $post = get_post($post_id);
348 if (!$post instanceof \WP_Post) {
349 return $empty;
350 }
351
352 $fingerprint = md5(implode('|', [
353 (string) $post->post_modified_gmt,
354 $title,
355 $description,
356 implode(',', $keywords),
357 THINKRANK_VERSION,
358 ]));
359
360 $cached = get_post_meta($post_id, self::METRICS_CACHE_META, true);
361 if (is_array($cached) && ($cached['fingerprint'] ?? '') === $fingerprint && isset($cached['metrics'])) {
362 return $cached['metrics'];
363 }
364
365 // Past the ceiling the column falls back to the un-scored display rather
366 // than holding up the page; the next load picks these up.
367 if (self::$scored_this_request >= self::MAX_SCORED_PER_REQUEST) {
368 return $empty;
369 }
370 self::$scored_this_request++;
371
372 try {
373 $calculator = new \ThinkRank\AI\SEOScoreCalculator(new \ThinkRank\Core\Database());
374
375 $content_data = $calculator->analyze_post_content($post_id);
376 if (empty($content_data)) {
377 return $empty;
378 }
379
380 // Score against the effective title/description so a post inheriting
381 // either from a Global pattern is scored the way it actually renders,
382 // matching the editor panel rather than counting the field as empty.
383 $score_data = $calculator->calculate_score(
384 $content_data,
385 ['title' => $title, 'description' => $description],
386 ['target_keywords' => $keywords]
387 );
388 } catch (\Throwable $e) {
389 // A broken shortcode in one post must not take down the whole list.
390 return $empty;
391 }
392
393 if (!isset($score_data['overall_score'])) {
394 return $empty;
395 }
396
397 $metrics = [
398 'seo_score' => (int) $score_data['overall_score'],
399 'seo_grade' => $score_data['grade'] ?? null,
400 'content_quality' => $score_data['content_quality'] ?? null,
401 'readability_score' => $score_data['readability_score'] ?? null,
402 ];
403
404 update_post_meta($post_id, self::METRICS_CACHE_META, [
405 'fingerprint' => $fingerprint,
406 'metrics' => $metrics,
407 ]);
408
409 return $metrics;
410 }
411
412 /**
413 * Get SEO metrics from database table
414 *
415 * @param int $post_id Post ID
416 * @return array SEO metrics (seo_score, content_quality, readability_score)
417 */
418 private function get_seo_metrics_from_database(int $post_id): array {
419 global $wpdb;
420
421 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
422
423 // Default values
424 $default_metrics = [
425 'seo_score' => null,
426 'seo_grade' => null,
427 'content_quality' => null,
428 'readability_score' => null,
429 'calculated_at' => null,
430 ];
431
432 // Check if table exists (memoized per request — the column runs this
433 // per row and the schema is stable within a single page load).
434 if (null === self::$scores_table_exists) {
435 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
436 self::$scores_table_exists = (bool) $wpdb->get_var($wpdb->prepare(
437 "SHOW TABLES LIKE %s",
438 $table_name
439 ));
440 }
441
442 if (!self::$scores_table_exists) {
443 return $default_metrics;
444 }
445
446 // Get the most recent metrics from the database
447 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
448 $result = $wpdb->get_row($wpdb->prepare(
449 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped
450 "SELECT overall_score, grade, content_quality, readability_score, created_at FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1",
451 $post_id
452 ), ARRAY_A);
453
454 if (!$result) {
455 return $default_metrics;
456 }
457
458 return [
459 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null,
460 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null,
461 'content_quality' => $result['content_quality'],
462 'readability_score' => $result['readability_score'],
463 'calculated_at' => $result['created_at'],
464 ];
465 }
466
467 /**
468 * Render SEO overview content
469 *
470 * @param array $seo_data SEO data
471 * @param int $post_id Post ID
472 * @return void
473 */
474 private function render_seo_overview(array $seo_data, int $post_id): void {
475 ?>
476 <div class="thinkrank-seo-overview">
477 <div class="thinkrank-seo-row thinkrank-seo-row-top">
478 <?php if ($seo_data['seo_score'] !== null): ?>
479 <div class="thinkrank-seo-badge thinkrank-score-<?php echo esc_attr($this->get_score_class($seo_data['seo_score'])); ?>"
480 title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>">
481 <?php echo esc_html($seo_data['seo_score']); ?>% <?php echo esc_html($seo_data['seo_grade']); ?>
482 </div>
483 <?php else: ?>
484 <div class="thinkrank-seo-badge thinkrank-not-set" title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>">
485 <?php esc_html_e('N/A', 'thinkrank'); ?>
486 </div>
487 <?php endif; ?>
488
489 <div class="thinkrank-seo-separator">•</div>
490
491 <div class="thinkrank-seo-badge thinkrank-status-<?php echo esc_attr(sanitize_html_class(strtolower(str_replace(' ', '-', $seo_data['content_quality'])))); ?>"
492 title="<?php esc_attr_e('Content Quality', 'thinkrank'); ?>">
493 <?php echo esc_html($seo_data['content_quality']); ?>
494 </div>
495
496 <?php if ($seo_data['pillar_content']): ?>
497 <div class="thinkrank-seo-separator">•</div>
498 <div class="thinkrank-seo-badge thinkrank-pillar-icon" title="<?php esc_attr_e('Pillar Content', 'thinkrank'); ?>">
499 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
500 <path d="M2.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
501 <path d="M6 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
502 <path d="M9.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
503 <path d="M1.5 3.5H10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
504 <path d="M2 3.5L6 1L10 3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
505 <path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
506 </svg>
507 </div>
508 <?php endif; ?>
509
510 <?php if (current_user_can('edit_post', $post_id)): ?>
511 <button type="button"
512 class="thinkrank-qe-trigger"
513 data-post-id="<?php echo esc_attr($post_id); ?>"
514 data-post-title="<?php echo esc_attr(get_the_title($post_id)); ?>"
515 data-seo-title="<?php echo esc_attr($seo_data['seo_title_raw']); ?>"
516 data-meta-description="<?php echo esc_attr($seo_data['meta_description_raw']); ?>"
517 data-effective-title="<?php echo esc_attr($seo_data['effective_title']); ?>"
518 data-effective-description="<?php echo esc_attr($seo_data['effective_description']); ?>"
519 data-focus-keywords="<?php echo esc_attr($seo_data['focus_keyword_value']); ?>"
520 data-edit-link="<?php echo esc_url(get_edit_post_link($post_id, 'raw') ?? ''); ?>"
521 aria-label="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>"
522 title="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>">
523 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
524 <path d="M8 2H3.333A1.333 1.333 0 0 0 2 3.333v9.334A1.333 1.333 0 0 0 3.333 14h9.334A1.334 1.334 0 0 0 14 12.667V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
525 <path d="M12.25 1.75a1.414 1.414 0 1 1 2 2L8.24 9.758a1.334 1.334 0 0 1-.568.336l-1.915.56a.334.334 0 0 1-.414-.413l.56-1.915c.063-.215.18-.41.338-.568l6.008-6.01Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
526 </svg>
527 </button>
528 <?php endif; ?>
529 </div>
530
531 <div class="thinkrank-seo-row thinkrank-seo-row-middle">
532 <div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>">
533 <span class="thinkrank-focus-keyword-display <?php echo $seo_data['focus_keyword'] ? 'thinkrank-set' : 'thinkrank-not-set'; ?>"
534 data-keyword="<?php echo esc_attr($seo_data['focus_keyword_primary']); ?>"
535 title="<?php esc_attr_e('Focus Keyword - Click to edit', 'thinkrank'); ?>">
536 <?php echo $seo_data['focus_keyword'] ? esc_html($seo_data['focus_keyword_primary']) : esc_html__('Not Set', 'thinkrank'); ?>
537 </span>
538 <svg class="thinkrank-edit-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
539 <path d="M8 2H3.333A1.333 1.333 0 0 0 2 3.333v9.334A1.333 1.333 0 0 0 3.333 14h9.334A1.334 1.334 0 0 0 14 12.667V8" stroke="#A5AAAC" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
540 <path d="M12.25 1.75a1.414 1.414 0 1 1 2 2L8.24 9.758a1.334 1.334 0 0 1-.568.336l-1.915.56a.334.334 0 0 1-.414-.413l.56-1.915c.063-.215.18-.41.338-.568l6.008-6.01Z" stroke="#A5AAAC" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
541 </svg>
542 <svg class="thinkrank-success-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
543 <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm4.486 4.515a.688.688 0 0 0-.972 0L6.5 9.528 4.486 7.515a.688.688 0 0 0-.972.972l2.5 2.5a.688.688 0 0 0 .972 0l5.5-5.5a.688.688 0 0 0 0-.972Z" fill="#4451FF" />
544 </svg>
545 <span class="thinkrank-focus-keyword-loading" style="display:none;">
546 <span class="spinner is-active" style="float:none;margin:0;"></span>
547 </span>
548 </div>
549 </div>
550
551 <div class="thinkrank-seo-row thinkrank-seo-row-bottom">
552 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Readability', 'thinkrank'); ?>">
553 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
554 <path d="M6.74999 11.75C6.74999 13.1307 5.63071 14.25 4.24999 14.25C2.86928 14.25 1.75 13.1307 1.75 11.75C1.75 10.3693 2.86928 9.25001 4.24999 9.25001C5.63071 9.25001 6.74999 10.3693 6.74999 11.75Z" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
555 <path d="M14.25 11.75C14.25 13.1307 13.1307 14.25 11.75 14.25C10.3693 14.25 9.25 13.1307 9.25 11.75C9.25 10.3693 10.3693 9.25001 11.75 9.25001C13.1307 9.25001 14.25 10.3693 14.25 11.75Z" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
556 <path d="M0 11.75H1.74999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
557 <path d="M6.75 11.75H9.24999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
558 <path d="M14.25 11.75H16" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
559 <path d="M3 7.375V3.00001C3 2.30967 3.55965 1.75002 4.25 1.75002H11.75C12.4403 1.75002 13 2.30967 13 3.00001V7.375" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
560 <path d="M4.875 4.25H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
561 <path d="M4.875 6.75H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
562 </svg>
563 <span class="thinkrank-metric-value"><?php echo esc_html($seo_data['readability']); ?></span>
564 </div>
565
566 <div class="thinkrank-seo-separator">•</div>
567
568 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Title', 'thinkrank'); ?>">
569 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
570 <path d="M10 3.33398H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
571 <path d="M10 8H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
572 <path d="M2 12.666H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
573 <path d="M2 7.99933L4.36867 2.84999C4.39638 2.7947 4.43893 2.74821 4.49156 2.71572C4.54419 2.68322 4.60482 2.66602 4.66667 2.66602C4.72852 2.66602 4.78915 2.68322 4.84177 2.71572C4.8944 2.74821 4.93695 2.7947 4.96467 2.84999L7.33333 7.99933" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
574 <path d="M2.61328 6.66602H6.71995" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
575 </svg>
576 <span class="thinkrank-metric-value"><?php echo $seo_data['meta_title'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span>
577 </div>
578
579 <div class="thinkrank-seo-separator">•</div>
580
581 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Description', 'thinkrank'); ?>">
582 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
583 <path d="M4.0013 14.6673C3.64768 14.6673 3.30854 14.5268 3.05849 14.2768C2.80844 14.0267 2.66797 13.6876 2.66797 13.334V2.66732C2.66797 2.3137 2.80844 1.97456 3.05849 1.72451C3.30854 1.47446 3.64768 1.33399 4.0013 1.33399H9.33464C9.54567 1.33364 9.75469 1.37505 9.94966 1.45583C10.1446 1.53661 10.3217 1.65516 10.4706 1.80465L12.8626 4.19665C13.0125 4.34566 13.1314 4.52288 13.2124 4.71809C13.2934 4.91331 13.335 5.12263 13.3346 5.33399V13.334C13.3346 13.6876 13.1942 14.0267 12.9441 14.2768C12.6941 14.5268 12.3549 14.6673 12.0013 14.6673H4.0013Z" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
584 <path d="M6.66536 6H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
585 <path d="M10.6654 8.66602H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
586 <path d="M10.6654 11.334H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
587 </svg>
588 <span class="thinkrank-metric-value"><?php echo $seo_data['meta_description'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span>
589 </div>
590 </div>
591 </div>
592 <?php
593 }
594
595 /**
596 * Get score class for styling
597 *
598 * @param int $score SEO score
599 * @return string CSS class
600 */
601 private function get_score_class(int $score): string {
602 if ($score >= 90) {
603 return 'great';
604 } elseif ($score >= 70) {
605 return 'good';
606 } elseif ($score >= 50) {
607 return 'medium';
608 } else {
609 return 'poor';
610 }
611 }
612
613 /**
614 * Make column sortable
615 *
616 * @param array $columns Sortable columns
617 * @return array Modified sortable columns
618 */
619 public function make_column_sortable(array $columns): array {
620 $columns[self::COLUMN_ID] = 'thinkrank_seo_score';
621 return $columns;
622 }
623
624 /**
625 * Handle column sorting
626 *
627 * @param \WP_Query $query WordPress query object
628 * @return void
629 */
630 public function handle_column_sorting(\WP_Query $query): void {
631 if (!is_admin() || !$query->is_main_query()) {
632 return;
633 }
634
635 if ($query->get('orderby') !== 'thinkrank_seo_score') {
636 return;
637 }
638
639 // Sort on the scores table — the same source the column renders. The
640 // `_thinkrank_seo_score` meta cannot be used here: it is written only by
641 // the AI analyze flow and reset to 0 on every save, and ordering by
642 // meta_key INNER JOINs postmeta, which silently drops every post that
643 // lacks the key from the list entirely.
644 add_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10, 2);
645 }
646
647 /**
648 * Order the posts list by each post's latest SEO score.
649 *
650 * LEFT JOIN so posts that have never been scored still appear (they order as
651 * NULL: last when sorting DESC). The subquery collapses the append-only
652 * score history to one row per post — the newest by primary key — so posts
653 * are never duplicated by the join.
654 *
655 * @param array $clauses Query clauses
656 * @param \WP_Query $query Query object
657 * @return array Modified clauses
658 */
659 public function add_seo_score_order_clauses(array $clauses, \WP_Query $query): array {
660 global $wpdb;
661
662 // Leave any other query untouched — and leave the filter attached, since
663 // the query that asked for this ordering may not be the first to run.
664 if ($query->get('orderby') !== 'thinkrank_seo_score') {
665 return $clauses;
666 }
667
668 // Handled — don't touch anything that runs later in the request.
669 remove_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10);
670
671 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
672 $order = strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC';
673
674 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name derives from $wpdb->prefix and $order is whitelisted above
675 $clauses['join'] .= " LEFT JOIN (
676 SELECT s.post_id, s.overall_score
677 FROM `{$table_name}` s
678 INNER JOIN (
679 SELECT post_id, MAX(id) AS latest_id
680 FROM `{$table_name}`
681 GROUP BY post_id
682 ) latest ON latest.latest_id = s.id
683 ) thinkrank_scores ON thinkrank_scores.post_id = {$wpdb->posts}.ID";
684
685 // Tie-break on ID so equal scores keep a stable, deterministic order.
686 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $order is whitelisted above
687 $clauses['orderby'] = "thinkrank_scores.overall_score {$order}, {$wpdb->posts}.ID DESC";
688
689 return $clauses;
690 }
691
692 /**
693 * Enqueue column styles
694 *
695 * @param string $hook_suffix Current admin page hook
696 * @return void
697 */
698 public function enqueue_column_styles(string $hook_suffix): void {
699 // Only load on post list pages (edit.php for all post types)
700 $screen = get_current_screen();
701 if (!$screen || $screen->base !== 'edit') {
702 return;
703 }
704
705 // Enqueue column styles
706 wp_enqueue_style(
707 'thinkrank-admin-columns',
708 THINKRANK_PLUGIN_URL . 'static/css/admin-columns.css',
709 [],
710 THINKRANK_VERSION
711 );
712
713 // Enqueue inline editing script
714 wp_enqueue_script(
715 'thinkrank-focus-keyword-inline-edit',
716 THINKRANK_PLUGIN_URL . 'assets/focus-keyword-inline-edit.js',
717 ['jquery'],
718 THINKRANK_VERSION,
719 true
720 );
721
722 // Enqueue the Quick Edit SEO modal (style + script + config)
723 wp_enqueue_style(
724 'thinkrank-seo-quick-edit',
725 THINKRANK_PLUGIN_URL . 'static/css/seo-quick-edit.css',
726 [],
727 THINKRANK_VERSION
728 );
729
730 wp_enqueue_script(
731 'thinkrank-seo-quick-edit',
732 THINKRANK_PLUGIN_URL . 'assets/seo-quick-edit.js',
733 [],
734 THINKRANK_VERSION,
735 true
736 );
737
738 wp_localize_script(
739 'thinkrank-seo-quick-edit',
740 'thinkrankSeoQuickEdit',
741 [
742 'ajaxUrl' => admin_url('admin-ajax.php'),
743 'nonce' => wp_create_nonce(\ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_nonce_action()),
744 'actionGet' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_get(),
745 'actionSave' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_save(),
746 'titleMax' => 60,
747 'descriptionMax' => 160,
748 'i18n' => [
749 'modalTitle' => __('Quick Edit SEO', 'thinkrank'),
750 'seoTitle' => __('SEO Title', 'thinkrank'),
751 'seoTitleHelp' => __('Keep it under 60 characters for best results.', 'thinkrank'),
752 'metaDescription' => __('Meta Description', 'thinkrank'),
753 'metaDescriptionHelp' => __('Keep it under 160 characters for best results.', 'thinkrank'),
754 'focusKeywords' => __('Focus Keywords', 'thinkrank'),
755 'focusKeywordsHelp' => __('Separate keywords with commas. The first keyword is primary.', 'thinkrank'),
756 'inheritedHint' => __('Leave empty to inherit from your Global SEO pattern.', 'thinkrank'),
757 'save' => __('Save', 'thinkrank'),
758 'saving' => __('Saving…', 'thinkrank'),
759 'cancel' => __('Cancel', 'thinkrank'),
760 'close' => __('Close', 'thinkrank'),
761 'editFull' => __('Open full editor', 'thinkrank'),
762 'loadError' => __('Could not load SEO fields. Please try again.', 'thinkrank'),
763 'saveError' => __('Could not save. Please try again.', 'thinkrank'),
764 ],
765 ]
766 );
767
768 // Localize script with AJAX data
769 wp_localize_script(
770 'thinkrank-focus-keyword-inline-edit',
771 'thinkrankFocusKeyword',
772 [
773 'ajaxUrl' => admin_url('admin-ajax.php'),
774 'nonce' => wp_create_nonce(\ThinkRank\Admin\Focus_Keyword_Ajax::get_nonce_action()),
775 'action' => \ThinkRank\Admin\Focus_Keyword_Ajax::get_ajax_action(),
776 'i18n' => [
777 'notSet' => __('Not Set', 'thinkrank'),
778 'clickToEdit' => __('Click to edit', 'thinkrank'),
779 'pressEnter' => __('Press Enter to save, Esc to cancel', 'thinkrank'),
780 'saving' => __('Saving...', 'thinkrank'),
781 'saved' => __('Saved!', 'thinkrank'),
782 'error' => __('Error updating focus keyword. Please try again.', 'thinkrank'),
783 ]
784 ]
785 );
786 }
787 }
788