PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.0.2 1.1.0 1.10.0 1.11.0 All 47 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 trunk, at includes/admin/class-post-list-columns.php

785 lines 36.0 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 'effective_title' => $meta_title,
221 'effective_description' => $meta_description,
222 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1',
223 ];
224 }
225
226 /**
227 * Check if link suggestions are enabled for a post type
228 *
229 * @param string $post_type Post type
230 * @return bool True if enabled
231 */
232 private function is_link_suggestions_enabled(string $post_type): bool {
233 $settings = get_option('thinkrank_global_seo_settings', []);
234
235 if (isset($settings[$post_type]['link_suggestions'])) {
236 return (bool) $settings[$post_type]['link_suggestions'];
237 }
238
239 return true;
240 }
241
242 /**
243 * Cached result of the scores-table existence check.
244 *
245 * The post-list column renders this method once per row; the table schema
246 * cannot change mid-request, so the `SHOW TABLES LIKE` probe is memoized for
247 * the lifetime of the request instead of re-running per row.
248 *
249 * @since 1.16.2
250 * @var bool|null Null until first resolved, then the boolean result.
251 */
252 private static ?bool $scores_table_exists = null;
253
254 /**
255 * Resolve the metrics to display for a post.
256 *
257 * Prefers the stored analysis, but only while it still describes the post as
258 * it stands: a score saved before the last edit is stale, and showing it is
259 * how a rewritten post could keep advertising the score of the draft it
260 * replaced. When there is no usable stored score the metrics are calculated
261 * here instead.
262 *
263 * That fallback is the difference between a column that reads "N/A / Not
264 * Analyzed" forever and one that reports a real score straight away. A score
265 * row is only ever written by an explicit analysis (the editor panel, the
266 * bulk analyzer, an MCP call or the importer) — nothing scores a post on
267 * save — so before this fallback existed, editing the SEO title and
268 * description left the column empty with no indication that a separate,
269 * manual step was what it was waiting for.
270 *
271 * @since 1.28.0
272 *
273 * @param int $post_id Post ID.
274 * @param string $title Effective meta title.
275 * @param string $description Effective meta description.
276 * @param string[] $keywords Focus keywords.
277 * @return array SEO metrics (seo_score, seo_grade, content_quality, readability_score)
278 */
279 private function get_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
280 $stored = $this->get_seo_metrics_from_database($post_id);
281
282 if ($stored['seo_score'] !== null && !$this->is_score_stale($post_id, $stored['calculated_at'] ?? null)) {
283 return $stored;
284 }
285
286 return $this->calculate_seo_metrics($post_id, $title, $description, $keywords);
287 }
288
289 /**
290 * Whether a stored score predates the post's last modification.
291 *
292 * Both timestamps are site-local `Y-m-d H:i:s` — `post_modified` from
293 * WordPress and `created_at` written with `current_time('mysql')` — so they
294 * are directly comparable as strings. A missing timestamp counts as stale;
295 * calculating is cheap and cached, and a score we cannot date is one we
296 * cannot vouch for.
297 *
298 * @since 1.28.0
299 *
300 * @param int $post_id Post ID.
301 * @param string|null $calculated_at When the stored score was written.
302 * @return bool True when the post changed after the score was calculated.
303 */
304 private function is_score_stale(int $post_id, ?string $calculated_at): bool {
305 if (empty($calculated_at)) {
306 return true;
307 }
308
309 $post = get_post($post_id);
310 if (!$post instanceof \WP_Post || empty($post->post_modified)) {
311 return false;
312 }
313
314 return $post->post_modified > $calculated_at;
315 }
316
317 /**
318 * Calculate a post's metrics locally, caching the result against the inputs
319 * that produced it.
320 *
321 * The scoring engine is pure PHP — no AI provider and no HTTP calls — so the
322 * list table can run it directly. The cache is keyed on a fingerprint of
323 * everything the score depends on (last modification, effective title and
324 * description, focus keywords, plugin version), which means a Global SEO
325 * pattern change or an inline focus-keyword edit invalidates it on its own,
326 * with nothing to hook and no cache to sweep.
327 *
328 * @since 1.28.0
329 *
330 * @param int $post_id Post ID.
331 * @param string $title Effective meta title.
332 * @param string $description Effective meta description.
333 * @param string[] $keywords Focus keywords.
334 * @return array SEO metrics; score/grade null when the post could not be scored.
335 */
336 private function calculate_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
337 $empty = [
338 'seo_score' => null,
339 'seo_grade' => null,
340 'content_quality' => null,
341 'readability_score' => null,
342 ];
343
344 $post = get_post($post_id);
345 if (!$post instanceof \WP_Post) {
346 return $empty;
347 }
348
349 $fingerprint = md5(implode('|', [
350 (string) $post->post_modified_gmt,
351 $title,
352 $description,
353 implode(',', $keywords),
354 THINKRANK_VERSION,
355 ]));
356
357 $cached = get_post_meta($post_id, self::METRICS_CACHE_META, true);
358 if (is_array($cached) && ($cached['fingerprint'] ?? '') === $fingerprint && isset($cached['metrics'])) {
359 return $cached['metrics'];
360 }
361
362 // Past the ceiling the column falls back to the un-scored display rather
363 // than holding up the page; the next load picks these up.
364 if (self::$scored_this_request >= self::MAX_SCORED_PER_REQUEST) {
365 return $empty;
366 }
367 self::$scored_this_request++;
368
369 try {
370 $calculator = new \ThinkRank\AI\SEOScoreCalculator(new \ThinkRank\Core\Database());
371
372 $content_data = $calculator->analyze_post_content($post_id);
373 if (empty($content_data)) {
374 return $empty;
375 }
376
377 // Score against the effective title/description so a post inheriting
378 // either from a Global pattern is scored the way it actually renders,
379 // matching the editor panel rather than counting the field as empty.
380 $score_data = $calculator->calculate_score(
381 $content_data,
382 ['title' => $title, 'description' => $description],
383 ['target_keywords' => $keywords]
384 );
385 } catch (\Throwable $e) {
386 // A broken shortcode in one post must not take down the whole list.
387 return $empty;
388 }
389
390 if (!isset($score_data['overall_score'])) {
391 return $empty;
392 }
393
394 $metrics = [
395 'seo_score' => (int) $score_data['overall_score'],
396 'seo_grade' => $score_data['grade'] ?? null,
397 'content_quality' => $score_data['content_quality'] ?? null,
398 'readability_score' => $score_data['readability_score'] ?? null,
399 ];
400
401 update_post_meta($post_id, self::METRICS_CACHE_META, [
402 'fingerprint' => $fingerprint,
403 'metrics' => $metrics,
404 ]);
405
406 return $metrics;
407 }
408
409 /**
410 * Get SEO metrics from database table
411 *
412 * @param int $post_id Post ID
413 * @return array SEO metrics (seo_score, content_quality, readability_score)
414 */
415 private function get_seo_metrics_from_database(int $post_id): array {
416 global $wpdb;
417
418 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
419
420 // Default values
421 $default_metrics = [
422 'seo_score' => null,
423 'seo_grade' => null,
424 'content_quality' => null,
425 'readability_score' => null,
426 'calculated_at' => null,
427 ];
428
429 // Check if table exists (memoized per request — the column runs this
430 // per row and the schema is stable within a single page load).
431 if (null === self::$scores_table_exists) {
432 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
433 self::$scores_table_exists = (bool) $wpdb->get_var($wpdb->prepare(
434 "SHOW TABLES LIKE %s",
435 $table_name
436 ));
437 }
438
439 if (!self::$scores_table_exists) {
440 return $default_metrics;
441 }
442
443 // Get the most recent metrics from the database
444 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
445 $result = $wpdb->get_row($wpdb->prepare(
446 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped
447 "SELECT overall_score, grade, content_quality, readability_score, created_at FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1",
448 $post_id
449 ), ARRAY_A);
450
451 if (!$result) {
452 return $default_metrics;
453 }
454
455 return [
456 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null,
457 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null,
458 'content_quality' => $result['content_quality'],
459 'readability_score' => $result['readability_score'],
460 'calculated_at' => $result['created_at'],
461 ];
462 }
463
464 /**
465 * Render SEO overview content
466 *
467 * @param array $seo_data SEO data
468 * @param int $post_id Post ID
469 * @return void
470 */
471 private function render_seo_overview(array $seo_data, int $post_id): void {
472 ?>
473 <div class="thinkrank-seo-overview">
474 <div class="thinkrank-seo-row thinkrank-seo-row-top">
475 <?php if ($seo_data['seo_score'] !== null): ?>
476 <div class="thinkrank-seo-badge thinkrank-score-<?php echo esc_attr($this->get_score_class($seo_data['seo_score'])); ?>"
477 title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>">
478 <?php echo esc_html($seo_data['seo_score']); ?>% <?php echo esc_html($seo_data['seo_grade']); ?>
479 </div>
480 <?php else: ?>
481 <div class="thinkrank-seo-badge thinkrank-not-set" title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>">
482 <?php esc_html_e('N/A', 'thinkrank'); ?>
483 </div>
484 <?php endif; ?>
485
486 <div class="thinkrank-seo-separator"></div>
487
488 <div class="thinkrank-seo-badge thinkrank-status-<?php echo esc_attr(sanitize_html_class(strtolower(str_replace(' ', '-', $seo_data['content_quality'])))); ?>"
489 title="<?php esc_attr_e('Content Quality', 'thinkrank'); ?>">
490 <?php echo esc_html($seo_data['content_quality']); ?>
491 </div>
492
493 <?php if ($seo_data['pillar_content']): ?>
494 <div class="thinkrank-seo-separator"></div>
495 <div class="thinkrank-seo-badge thinkrank-pillar-icon" title="<?php esc_attr_e('Pillar Content', 'thinkrank'); ?>">
496 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
497 <path d="M2.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
498 <path d="M6 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
499 <path d="M9.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
500 <path d="M1.5 3.5H10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
501 <path d="M2 3.5L6 1L10 3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
502 <path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
503 </svg>
504 </div>
505 <?php endif; ?>
506
507 <?php if (current_user_can('edit_post', $post_id)): ?>
508 <button type="button"
509 class="thinkrank-qe-trigger"
510 data-post-id="<?php echo esc_attr($post_id); ?>"
511 data-post-title="<?php echo esc_attr(get_the_title($post_id)); ?>"
512 data-seo-title="<?php echo esc_attr($seo_data['seo_title_raw']); ?>"
513 data-meta-description="<?php echo esc_attr($seo_data['meta_description_raw']); ?>"
514 data-effective-title="<?php echo esc_attr($seo_data['effective_title']); ?>"
515 data-effective-description="<?php echo esc_attr($seo_data['effective_description']); ?>"
516 data-focus-keywords="<?php echo esc_attr($seo_data['focus_keyword_value']); ?>"
517 data-edit-link="<?php echo esc_url(get_edit_post_link($post_id, 'raw') ?? ''); ?>"
518 aria-label="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>"
519 title="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>">
520 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
521 <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" />
522 <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" />
523 </svg>
524 </button>
525 <?php endif; ?>
526 </div>
527
528 <div class="thinkrank-seo-row thinkrank-seo-row-middle">
529 <div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>">
530 <span class="thinkrank-focus-keyword-display <?php echo $seo_data['focus_keyword'] ? 'thinkrank-set' : 'thinkrank-not-set'; ?>"
531 data-keyword="<?php echo esc_attr($seo_data['focus_keyword_primary']); ?>"
532 title="<?php esc_attr_e('Focus Keyword - Click to edit', 'thinkrank'); ?>">
533 <?php echo $seo_data['focus_keyword'] ? esc_html($seo_data['focus_keyword_primary']) : esc_html__('Not Set', 'thinkrank'); ?>
534 </span>
535 <svg class="thinkrank-edit-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
536 <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" />
537 <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" />
538 </svg>
539 <svg class="thinkrank-success-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
540 <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" />
541 </svg>
542 <span class="thinkrank-focus-keyword-loading" style="display:none;">
543 <span class="spinner is-active" style="float:none;margin:0;"></span>
544 </span>
545 </div>
546 </div>
547
548 <div class="thinkrank-seo-row thinkrank-seo-row-bottom">
549 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Readability', 'thinkrank'); ?>">
550 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
551 <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" />
552 <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" />
553 <path d="M0 11.75H1.74999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
554 <path d="M6.75 11.75H9.24999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
555 <path d="M14.25 11.75H16" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
556 <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" />
557 <path d="M4.875 4.25H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
558 <path d="M4.875 6.75H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" />
559 </svg>
560 <span class="thinkrank-metric-value"><?php echo esc_html($seo_data['readability']); ?></span>
561 </div>
562
563 <div class="thinkrank-seo-separator"></div>
564
565 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Title', 'thinkrank'); ?>">
566 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
567 <path d="M10 3.33398H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
568 <path d="M10 8H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
569 <path d="M2 12.666H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
570 <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" />
571 <path d="M2.61328 6.66602H6.71995" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
572 </svg>
573 <span class="thinkrank-metric-value"><?php echo $seo_data['meta_title'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span>
574 </div>
575
576 <div class="thinkrank-seo-separator"></div>
577
578 <div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Description', 'thinkrank'); ?>">
579 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
580 <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" />
581 <path d="M6.66536 6H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
582 <path d="M10.6654 8.66602H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
583 <path d="M10.6654 11.334H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
584 </svg>
585 <span class="thinkrank-metric-value"><?php echo $seo_data['meta_description'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span>
586 </div>
587 </div>
588 </div>
589 <?php
590 }
591
592 /**
593 * Get score class for styling
594 *
595 * @param int $score SEO score
596 * @return string CSS class
597 */
598 private function get_score_class(int $score): string {
599 if ($score >= 90) {
600 return 'great';
601 } elseif ($score >= 70) {
602 return 'good';
603 } elseif ($score >= 50) {
604 return 'medium';
605 } else {
606 return 'poor';
607 }
608 }
609
610 /**
611 * Make column sortable
612 *
613 * @param array $columns Sortable columns
614 * @return array Modified sortable columns
615 */
616 public function make_column_sortable(array $columns): array {
617 $columns[self::COLUMN_ID] = 'thinkrank_seo_score';
618 return $columns;
619 }
620
621 /**
622 * Handle column sorting
623 *
624 * @param \WP_Query $query WordPress query object
625 * @return void
626 */
627 public function handle_column_sorting(\WP_Query $query): void {
628 if (!is_admin() || !$query->is_main_query()) {
629 return;
630 }
631
632 if ($query->get('orderby') !== 'thinkrank_seo_score') {
633 return;
634 }
635
636 // Sort on the scores table — the same source the column renders. The
637 // `_thinkrank_seo_score` meta cannot be used here: it is written only by
638 // the AI analyze flow and reset to 0 on every save, and ordering by
639 // meta_key INNER JOINs postmeta, which silently drops every post that
640 // lacks the key from the list entirely.
641 add_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10, 2);
642 }
643
644 /**
645 * Order the posts list by each post's latest SEO score.
646 *
647 * LEFT JOIN so posts that have never been scored still appear (they order as
648 * NULL: last when sorting DESC). The subquery collapses the append-only
649 * score history to one row per post — the newest by primary key — so posts
650 * are never duplicated by the join.
651 *
652 * @param array $clauses Query clauses
653 * @param \WP_Query $query Query object
654 * @return array Modified clauses
655 */
656 public function add_seo_score_order_clauses(array $clauses, \WP_Query $query): array {
657 global $wpdb;
658
659 // Leave any other query untouched — and leave the filter attached, since
660 // the query that asked for this ordering may not be the first to run.
661 if ($query->get('orderby') !== 'thinkrank_seo_score') {
662 return $clauses;
663 }
664
665 // Handled — don't touch anything that runs later in the request.
666 remove_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10);
667
668 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
669 $order = strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC';
670
671 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name derives from $wpdb->prefix and $order is whitelisted above
672 $clauses['join'] .= " LEFT JOIN (
673 SELECT s.post_id, s.overall_score
674 FROM `{$table_name}` s
675 INNER JOIN (
676 SELECT post_id, MAX(id) AS latest_id
677 FROM `{$table_name}`
678 GROUP BY post_id
679 ) latest ON latest.latest_id = s.id
680 ) thinkrank_scores ON thinkrank_scores.post_id = {$wpdb->posts}.ID";
681
682 // Tie-break on ID so equal scores keep a stable, deterministic order.
683 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $order is whitelisted above
684 $clauses['orderby'] = "thinkrank_scores.overall_score {$order}, {$wpdb->posts}.ID DESC";
685
686 return $clauses;
687 }
688
689 /**
690 * Enqueue column styles
691 *
692 * @param string $hook_suffix Current admin page hook
693 * @return void
694 */
695 public function enqueue_column_styles(string $hook_suffix): void {
696 // Only load on post list pages (edit.php for all post types)
697 $screen = get_current_screen();
698 if (!$screen || $screen->base !== 'edit') {
699 return;
700 }
701
702 // Enqueue column styles
703 wp_enqueue_style(
704 'thinkrank-admin-columns',
705 THINKRANK_PLUGIN_URL . 'static/css/admin-columns.css',
706 [],
707 THINKRANK_VERSION
708 );
709
710 // Enqueue inline editing script
711 wp_enqueue_script(
712 'thinkrank-focus-keyword-inline-edit',
713 THINKRANK_PLUGIN_URL . 'assets/focus-keyword-inline-edit.js',
714 ['jquery'],
715 THINKRANK_VERSION,
716 true
717 );
718
719 // Enqueue the Quick Edit SEO modal (style + script + config)
720 wp_enqueue_style(
721 'thinkrank-seo-quick-edit',
722 THINKRANK_PLUGIN_URL . 'static/css/seo-quick-edit.css',
723 [],
724 THINKRANK_VERSION
725 );
726
727 wp_enqueue_script(
728 'thinkrank-seo-quick-edit',
729 THINKRANK_PLUGIN_URL . 'assets/seo-quick-edit.js',
730 [],
731 THINKRANK_VERSION,
732 true
733 );
734
735 wp_localize_script(
736 'thinkrank-seo-quick-edit',
737 'thinkrankSeoQuickEdit',
738 [
739 'ajaxUrl' => admin_url('admin-ajax.php'),
740 'nonce' => wp_create_nonce(\ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_nonce_action()),
741 'actionGet' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_get(),
742 'actionSave' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_save(),
743 'titleMax' => 60,
744 'descriptionMax' => 160,
745 'i18n' => [
746 'modalTitle' => __('Quick Edit SEO', 'thinkrank'),
747 'seoTitle' => __('SEO Title', 'thinkrank'),
748 'seoTitleHelp' => __('Keep it under 60 characters for best results.', 'thinkrank'),
749 'metaDescription' => __('Meta Description', 'thinkrank'),
750 'metaDescriptionHelp' => __('Keep it under 160 characters for best results.', 'thinkrank'),
751 'focusKeywords' => __('Focus Keywords', 'thinkrank'),
752 'focusKeywordsHelp' => __('Separate keywords with commas. The first keyword is primary.', 'thinkrank'),
753 'inheritedHint' => __('Leave empty to inherit from your Global SEO pattern.', 'thinkrank'),
754 'save' => __('Save', 'thinkrank'),
755 'saving' => __('Saving…', 'thinkrank'),
756 'cancel' => __('Cancel', 'thinkrank'),
757 'close' => __('Close', 'thinkrank'),
758 'editFull' => __('Open full editor', 'thinkrank'),
759 'loadError' => __('Could not load SEO fields. Please try again.', 'thinkrank'),
760 'saveError' => __('Could not save. Please try again.', 'thinkrank'),
761 ],
762 ]
763 );
764
765 // Localize script with AJAX data
766 wp_localize_script(
767 'thinkrank-focus-keyword-inline-edit',
768 'thinkrankFocusKeyword',
769 [
770 'ajaxUrl' => admin_url('admin-ajax.php'),
771 'nonce' => wp_create_nonce(\ThinkRank\Admin\Focus_Keyword_Ajax::get_nonce_action()),
772 'action' => \ThinkRank\Admin\Focus_Keyword_Ajax::get_ajax_action(),
773 'i18n' => [
774 'notSet' => __('Not Set', 'thinkrank'),
775 'clickToEdit' => __('Click to edit', 'thinkrank'),
776 'pressEnter' => __('Press Enter to save, Esc to cancel', 'thinkrank'),
777 'saving' => __('Saving...', 'thinkrank'),
778 'saved' => __('Saved!', 'thinkrank'),
779 'error' => __('Error updating focus keyword. Please try again.', 'thinkrank'),
780 ]
781 ]
782 );
783 }
784 }
785