| 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 |
|