| 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 |
* Initialize post list columns |
| 39 |
* |
| 40 |
* @return void |
| 41 |
*/ |
| 42 |
public function init(): void { |
| 43 |
// Get all public post types |
| 44 |
add_action('admin_init', function () { |
| 45 |
$post_types = $this->get_supported_post_types(); |
| 46 |
|
| 47 |
foreach ($post_types as $post_type) { |
| 48 |
// Add column header |
| 49 |
add_filter("manage_{$post_type}_posts_columns", [$this, 'add_column_header'], 10); |
| 50 |
|
| 51 |
// Add column content |
| 52 |
add_action("manage_{$post_type}_posts_custom_column", [$this, 'render_column_content'], 10, 2); |
| 53 |
|
| 54 |
// Make column sortable |
| 55 |
add_filter("manage_edit-{$post_type}_sortable_columns", [$this, 'make_column_sortable'], 10); |
| 56 |
} |
| 57 |
}); |
| 58 |
|
| 59 |
// Handle sorting |
| 60 |
add_action('pre_get_posts', [$this, 'handle_column_sorting']); |
| 61 |
|
| 62 |
// Enqueue admin styles for column |
| 63 |
add_action('admin_enqueue_scripts', [$this, 'enqueue_column_styles']); |
| 64 |
} |
| 65 |
|
| 66 |
/** |
| 67 |
* Get supported post types |
| 68 |
* |
| 69 |
* @return array Array of post type names |
| 70 |
*/ |
| 71 |
private function get_supported_post_types(): array { |
| 72 |
$post_types = get_post_types(['public' => true], 'names'); |
| 73 |
|
| 74 |
// Remove unnecessary post types |
| 75 |
unset($post_types['attachment']); |
| 76 |
unset($post_types['elementor_library']); |
| 77 |
unset($post_types['oceanwp_library']); |
| 78 |
unset($post_types['ae_global_templates']); |
| 79 |
// Divi library + Theme Builder templates. |
| 80 |
unset($post_types['et_pb_layout']); |
| 81 |
unset($post_types['et_theme_builder']); |
| 82 |
unset($post_types['et_template']); |
| 83 |
unset($post_types['et_header_layout']); |
| 84 |
unset($post_types['et_body_layout']); |
| 85 |
unset($post_types['et_footer_layout']); |
| 86 |
|
| 87 |
/** |
| 88 |
* Filter supported post types for ThinkRank SEO Overview column |
| 89 |
* |
| 90 |
* @param array $post_types Array of post type names |
| 91 |
*/ |
| 92 |
return apply_filters('thinkrank_seo_overview_post_types', $post_types); |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Add column header |
| 97 |
* |
| 98 |
* @param array $columns Existing columns |
| 99 |
* @return array Modified columns |
| 100 |
*/ |
| 101 |
public function add_column_header(array $columns): array { |
| 102 |
// Insert column before date column |
| 103 |
$new_columns = []; |
| 104 |
|
| 105 |
foreach ($columns as $key => $value) { |
| 106 |
if ($key === 'date') { |
| 107 |
$new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank'); |
| 108 |
} |
| 109 |
$new_columns[$key] = $value; |
| 110 |
} |
| 111 |
|
| 112 |
// If date column doesn't exist, add at the end |
| 113 |
if (!isset($columns['date'])) { |
| 114 |
$new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank'); |
| 115 |
} |
| 116 |
|
| 117 |
return $new_columns; |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Render column content |
| 122 |
* |
| 123 |
* @param string $column_name Column name |
| 124 |
* @param int $post_id Post ID |
| 125 |
* @return void |
| 126 |
*/ |
| 127 |
public function render_column_content(string $column_name, int $post_id): void { |
| 128 |
if ($column_name !== self::COLUMN_ID) { |
| 129 |
return; |
| 130 |
} |
| 131 |
|
| 132 |
// Get SEO data |
| 133 |
$seo_data = $this->get_seo_data($post_id); |
| 134 |
|
| 135 |
// Render the column content |
| 136 |
$this->render_seo_overview($seo_data, $post_id); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Get SEO data for a post |
| 141 |
* |
| 142 |
* @param int $post_id Post ID |
| 143 |
* @return array SEO data |
| 144 |
*/ |
| 145 |
private function get_seo_data(int $post_id): array { |
| 146 |
// Get SEO score and metrics from database table |
| 147 |
$seo_metrics = $this->get_seo_metrics_from_database($post_id); |
| 148 |
|
| 149 |
// Get focus keyword(s). The column shows only the primary keyword, but |
| 150 |
// the inline editor operates on the full comma-separated set so editing |
| 151 |
// never drops the secondary keywords. |
| 152 |
$focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id); |
| 153 |
$focus_keyword = implode(', ', $focus_keywords); |
| 154 |
$focus_keyword_primary = $focus_keywords[0] ?? ''; |
| 155 |
|
| 156 |
// Get the effective meta title/description. When no custom per-post value |
| 157 |
// is set, ThinkRank still renders these from the Bulk SEO Optimization |
| 158 |
// (Global SEO) patterns, so resolve those inherited values here instead of |
| 159 |
// reporting "Not Set" for content that is actually output on the frontend. |
| 160 |
$raw_title = get_post_meta($post_id, '_thinkrank_seo_title', true); |
| 161 |
$meta_title = $raw_title !== '' |
| 162 |
? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_title, $post_id) |
| 163 |
: \ThinkRank\SEO\Pattern_Resolver::title($post_id); |
| 164 |
|
| 165 |
$raw_description = get_post_meta($post_id, '_thinkrank_meta_description', true); |
| 166 |
$meta_description = $raw_description !== '' |
| 167 |
? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_description, $post_id) |
| 168 |
: \ThinkRank\SEO\Pattern_Resolver::description($post_id); |
| 169 |
|
| 170 |
// Get content quality and readability from database columns |
| 171 |
$content_quality = $seo_metrics['content_quality'] ?? 'Not Analyzed'; |
| 172 |
$readability = $seo_metrics['readability_score'] ?? 'N/A'; |
| 173 |
|
| 174 |
return [ |
| 175 |
'seo_score' => $seo_metrics['seo_score'], |
| 176 |
'seo_grade' => $seo_metrics['seo_grade'], |
| 177 |
'content_quality' => $content_quality, |
| 178 |
'readability' => $readability, |
| 179 |
'focus_keyword' => !empty($focus_keyword), |
| 180 |
'focus_keyword_value' => $focus_keyword, |
| 181 |
'focus_keyword_primary' => $focus_keyword_primary, |
| 182 |
'meta_title' => !empty($meta_title), |
| 183 |
'meta_description' => !empty($meta_description), |
| 184 |
'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1', |
| 185 |
]; |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Check if link suggestions are enabled for a post type |
| 190 |
* |
| 191 |
* @param string $post_type Post type |
| 192 |
* @return bool True if enabled |
| 193 |
*/ |
| 194 |
private function is_link_suggestions_enabled(string $post_type): bool { |
| 195 |
$settings = get_option('thinkrank_global_seo_settings', []); |
| 196 |
|
| 197 |
if (isset($settings[$post_type]['link_suggestions'])) { |
| 198 |
return (bool) $settings[$post_type]['link_suggestions']; |
| 199 |
} |
| 200 |
|
| 201 |
return true; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Cached result of the scores-table existence check. |
| 206 |
* |
| 207 |
* The post-list column renders this method once per row; the table schema |
| 208 |
* cannot change mid-request, so the `SHOW TABLES LIKE` probe is memoized for |
| 209 |
* the lifetime of the request instead of re-running per row. |
| 210 |
* |
| 211 |
* @since 1.16.2 |
| 212 |
* @var bool|null Null until first resolved, then the boolean result. |
| 213 |
*/ |
| 214 |
private static ?bool $scores_table_exists = null; |
| 215 |
|
| 216 |
/** |
| 217 |
* Get SEO metrics from database table |
| 218 |
* |
| 219 |
* @param int $post_id Post ID |
| 220 |
* @return array SEO metrics (seo_score, content_quality, readability_score) |
| 221 |
*/ |
| 222 |
private function get_seo_metrics_from_database(int $post_id): array { |
| 223 |
global $wpdb; |
| 224 |
|
| 225 |
$table_name = $wpdb->prefix . 'thinkrank_seo_scores'; |
| 226 |
|
| 227 |
// Default values |
| 228 |
$default_metrics = [ |
| 229 |
'seo_score' => null, |
| 230 |
'seo_grade' => null, |
| 231 |
'content_quality' => null, |
| 232 |
'readability_score' => null, |
| 233 |
]; |
| 234 |
|
| 235 |
// Check if table exists (memoized per request — the column runs this |
| 236 |
// per row and the schema is stable within a single page load). |
| 237 |
if (null === self::$scores_table_exists) { |
| 238 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access |
| 239 |
self::$scores_table_exists = (bool) $wpdb->get_var($wpdb->prepare( |
| 240 |
"SHOW TABLES LIKE %s", |
| 241 |
$table_name |
| 242 |
)); |
| 243 |
} |
| 244 |
|
| 245 |
if (!self::$scores_table_exists) { |
| 246 |
return $default_metrics; |
| 247 |
} |
| 248 |
|
| 249 |
// Get the most recent metrics from the database |
| 250 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access |
| 251 |
$result = $wpdb->get_row($wpdb->prepare( |
| 252 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped |
| 253 |
"SELECT overall_score, grade, content_quality, readability_score FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1", |
| 254 |
$post_id |
| 255 |
), ARRAY_A); |
| 256 |
|
| 257 |
if (!$result) { |
| 258 |
return $default_metrics; |
| 259 |
} |
| 260 |
|
| 261 |
return [ |
| 262 |
'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null, |
| 263 |
'seo_grade' => $result['grade'] !== null ? $result['grade'] : null, |
| 264 |
'content_quality' => $result['content_quality'], |
| 265 |
'readability_score' => $result['readability_score'], |
| 266 |
]; |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* Render SEO overview content |
| 271 |
* |
| 272 |
* @param array $seo_data SEO data |
| 273 |
* @param int $post_id Post ID |
| 274 |
* @return void |
| 275 |
*/ |
| 276 |
private function render_seo_overview(array $seo_data, int $post_id): void { |
| 277 |
?> |
| 278 |
<div class="thinkrank-seo-overview"> |
| 279 |
<div class="thinkrank-seo-row thinkrank-seo-row-top"> |
| 280 |
<?php if ($seo_data['seo_score'] !== null): ?> |
| 281 |
<div class="thinkrank-seo-badge thinkrank-score-<?php echo esc_attr($this->get_score_class($seo_data['seo_score'])); ?>" |
| 282 |
title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>"> |
| 283 |
<?php echo esc_html($seo_data['seo_score']); ?>% <?php echo esc_html($seo_data['seo_grade']); ?> |
| 284 |
</div> |
| 285 |
<?php else: ?> |
| 286 |
<div class="thinkrank-seo-badge thinkrank-not-set" title="<?php esc_attr_e('SEO Score', 'thinkrank'); ?>"> |
| 287 |
<?php esc_html_e('N/A', 'thinkrank'); ?> |
| 288 |
</div> |
| 289 |
<?php endif; ?> |
| 290 |
|
| 291 |
<div class="thinkrank-seo-separator">•</div> |
| 292 |
|
| 293 |
<div class="thinkrank-seo-badge thinkrank-status-<?php echo esc_attr(sanitize_html_class(strtolower(str_replace(' ', '-', $seo_data['content_quality'])))); ?>" |
| 294 |
title="<?php esc_attr_e('Content Quality', 'thinkrank'); ?>"> |
| 295 |
<?php echo esc_html($seo_data['content_quality']); ?> |
| 296 |
</div> |
| 297 |
|
| 298 |
<?php if ($seo_data['pillar_content']): ?> |
| 299 |
<div class="thinkrank-seo-separator">•</div> |
| 300 |
<div class="thinkrank-seo-badge thinkrank-pillar-icon" title="<?php esc_attr_e('Pillar Content', 'thinkrank'); ?>"> |
| 301 |
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 302 |
<path d="M2.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /> |
| 303 |
<path d="M6 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /> |
| 304 |
<path d="M9.5 11V3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /> |
| 305 |
<path d="M1.5 3.5H10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /> |
| 306 |
<path d="M2 3.5L6 1L10 3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" /> |
| 307 |
<path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /> |
| 308 |
</svg> |
| 309 |
</div> |
| 310 |
<?php endif; ?> |
| 311 |
</div> |
| 312 |
|
| 313 |
<div class="thinkrank-seo-row thinkrank-seo-row-middle"> |
| 314 |
<div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>"> |
| 315 |
<span class="thinkrank-focus-keyword-display <?php echo $seo_data['focus_keyword'] ? 'thinkrank-set' : 'thinkrank-not-set'; ?>" |
| 316 |
data-keyword="<?php echo esc_attr($seo_data['focus_keyword_primary']); ?>" |
| 317 |
title="<?php esc_attr_e('Focus Keyword - Click to edit', 'thinkrank'); ?>"> |
| 318 |
<?php echo $seo_data['focus_keyword'] ? esc_html($seo_data['focus_keyword_primary']) : esc_html__('Not Set', 'thinkrank'); ?> |
| 319 |
</span> |
| 320 |
<svg class="thinkrank-edit-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 321 |
<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" /> |
| 322 |
<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" /> |
| 323 |
</svg> |
| 324 |
<svg class="thinkrank-success-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 325 |
<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" /> |
| 326 |
</svg> |
| 327 |
<span class="thinkrank-focus-keyword-loading" style="display:none;"> |
| 328 |
<span class="spinner is-active" style="float:none;margin:0;"></span> |
| 329 |
</span> |
| 330 |
</div> |
| 331 |
</div> |
| 332 |
|
| 333 |
<div class="thinkrank-seo-row thinkrank-seo-row-bottom"> |
| 334 |
<div class="thinkrank-seo-metric" title="<?php esc_attr_e('Readability', 'thinkrank'); ?>"> |
| 335 |
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 336 |
<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" /> |
| 337 |
<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" /> |
| 338 |
<path d="M0 11.75H1.74999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" /> |
| 339 |
<path d="M6.75 11.75H9.24999" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" /> |
| 340 |
<path d="M14.25 11.75H16" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" /> |
| 341 |
<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" /> |
| 342 |
<path d="M4.875 4.25H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" /> |
| 343 |
<path d="M4.875 6.75H11.125" stroke="#373D44" stroke-width="1.5" stroke-miterlimit="10" /> |
| 344 |
</svg> |
| 345 |
<span class="thinkrank-metric-value"><?php echo esc_html($seo_data['readability']); ?></span> |
| 346 |
</div> |
| 347 |
|
| 348 |
<div class="thinkrank-seo-separator">•</div> |
| 349 |
|
| 350 |
<div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Title', 'thinkrank'); ?>"> |
| 351 |
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 352 |
<path d="M10 3.33398H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 353 |
<path d="M10 8H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 354 |
<path d="M2 12.666H14" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 355 |
<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" /> |
| 356 |
<path d="M2.61328 6.66602H6.71995" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 357 |
</svg> |
| 358 |
<span class="thinkrank-metric-value"><?php echo $seo_data['meta_title'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span> |
| 359 |
</div> |
| 360 |
|
| 361 |
<div class="thinkrank-seo-separator">•</div> |
| 362 |
|
| 363 |
<div class="thinkrank-seo-metric" title="<?php esc_attr_e('Meta Description', 'thinkrank'); ?>"> |
| 364 |
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 365 |
<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" /> |
| 366 |
<path d="M6.66536 6H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 367 |
<path d="M10.6654 8.66602H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 368 |
<path d="M10.6654 11.334H5.33203" stroke="#373D44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> |
| 369 |
</svg> |
| 370 |
<span class="thinkrank-metric-value"><?php echo $seo_data['meta_description'] ? esc_html__('Set', 'thinkrank') : esc_html__('Not Set', 'thinkrank'); ?></span> |
| 371 |
</div> |
| 372 |
</div> |
| 373 |
</div> |
| 374 |
<?php |
| 375 |
} |
| 376 |
|
| 377 |
/** |
| 378 |
* Get score class for styling |
| 379 |
* |
| 380 |
* @param int $score SEO score |
| 381 |
* @return string CSS class |
| 382 |
*/ |
| 383 |
private function get_score_class(int $score): string { |
| 384 |
if ($score >= 90) { |
| 385 |
return 'great'; |
| 386 |
} elseif ($score >= 70) { |
| 387 |
return 'good'; |
| 388 |
} elseif ($score >= 50) { |
| 389 |
return 'medium'; |
| 390 |
} else { |
| 391 |
return 'poor'; |
| 392 |
} |
| 393 |
} |
| 394 |
|
| 395 |
/** |
| 396 |
* Make column sortable |
| 397 |
* |
| 398 |
* @param array $columns Sortable columns |
| 399 |
* @return array Modified sortable columns |
| 400 |
*/ |
| 401 |
public function make_column_sortable(array $columns): array { |
| 402 |
$columns[self::COLUMN_ID] = 'thinkrank_seo_score'; |
| 403 |
return $columns; |
| 404 |
} |
| 405 |
|
| 406 |
/** |
| 407 |
* Handle column sorting |
| 408 |
* |
| 409 |
* @param \WP_Query $query WordPress query object |
| 410 |
* @return void |
| 411 |
*/ |
| 412 |
public function handle_column_sorting(\WP_Query $query): void { |
| 413 |
if (!is_admin() || !$query->is_main_query()) { |
| 414 |
return; |
| 415 |
} |
| 416 |
|
| 417 |
if ($query->get('orderby') !== 'thinkrank_seo_score') { |
| 418 |
return; |
| 419 |
} |
| 420 |
|
| 421 |
// Sort on the scores table — the same source the column renders. The |
| 422 |
// `_thinkrank_seo_score` meta cannot be used here: it is written only by |
| 423 |
// the AI analyze flow and reset to 0 on every save, and ordering by |
| 424 |
// meta_key INNER JOINs postmeta, which silently drops every post that |
| 425 |
// lacks the key from the list entirely. |
| 426 |
add_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10, 2); |
| 427 |
} |
| 428 |
|
| 429 |
/** |
| 430 |
* Order the posts list by each post's latest SEO score. |
| 431 |
* |
| 432 |
* LEFT JOIN so posts that have never been scored still appear (they order as |
| 433 |
* NULL: last when sorting DESC). The subquery collapses the append-only |
| 434 |
* score history to one row per post — the newest by primary key — so posts |
| 435 |
* are never duplicated by the join. |
| 436 |
* |
| 437 |
* @param array $clauses Query clauses |
| 438 |
* @param \WP_Query $query Query object |
| 439 |
* @return array Modified clauses |
| 440 |
*/ |
| 441 |
public function add_seo_score_order_clauses(array $clauses, \WP_Query $query): array { |
| 442 |
global $wpdb; |
| 443 |
|
| 444 |
// Leave any other query untouched — and leave the filter attached, since |
| 445 |
// the query that asked for this ordering may not be the first to run. |
| 446 |
if ($query->get('orderby') !== 'thinkrank_seo_score') { |
| 447 |
return $clauses; |
| 448 |
} |
| 449 |
|
| 450 |
// Handled — don't touch anything that runs later in the request. |
| 451 |
remove_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10); |
| 452 |
|
| 453 |
$table_name = $wpdb->prefix . 'thinkrank_seo_scores'; |
| 454 |
$order = strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC'; |
| 455 |
|
| 456 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name derives from $wpdb->prefix and $order is whitelisted above |
| 457 |
$clauses['join'] .= " LEFT JOIN ( |
| 458 |
SELECT s.post_id, s.overall_score |
| 459 |
FROM `{$table_name}` s |
| 460 |
INNER JOIN ( |
| 461 |
SELECT post_id, MAX(id) AS latest_id |
| 462 |
FROM `{$table_name}` |
| 463 |
GROUP BY post_id |
| 464 |
) latest ON latest.latest_id = s.id |
| 465 |
) thinkrank_scores ON thinkrank_scores.post_id = {$wpdb->posts}.ID"; |
| 466 |
|
| 467 |
// Tie-break on ID so equal scores keep a stable, deterministic order. |
| 468 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $order is whitelisted above |
| 469 |
$clauses['orderby'] = "thinkrank_scores.overall_score {$order}, {$wpdb->posts}.ID DESC"; |
| 470 |
|
| 471 |
return $clauses; |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* Enqueue column styles |
| 476 |
* |
| 477 |
* @param string $hook_suffix Current admin page hook |
| 478 |
* @return void |
| 479 |
*/ |
| 480 |
public function enqueue_column_styles(string $hook_suffix): void { |
| 481 |
// Only load on post list pages (edit.php for all post types) |
| 482 |
$screen = get_current_screen(); |
| 483 |
if (!$screen || $screen->base !== 'edit') { |
| 484 |
return; |
| 485 |
} |
| 486 |
|
| 487 |
// Enqueue column styles |
| 488 |
wp_enqueue_style( |
| 489 |
'thinkrank-admin-columns', |
| 490 |
THINKRANK_PLUGIN_URL . 'static/css/admin-columns.css', |
| 491 |
[], |
| 492 |
THINKRANK_VERSION |
| 493 |
); |
| 494 |
|
| 495 |
// Enqueue inline editing script |
| 496 |
wp_enqueue_script( |
| 497 |
'thinkrank-focus-keyword-inline-edit', |
| 498 |
THINKRANK_PLUGIN_URL . 'assets/focus-keyword-inline-edit.js', |
| 499 |
['jquery'], |
| 500 |
THINKRANK_VERSION, |
| 501 |
true |
| 502 |
); |
| 503 |
|
| 504 |
// Localize script with AJAX data |
| 505 |
wp_localize_script( |
| 506 |
'thinkrank-focus-keyword-inline-edit', |
| 507 |
'thinkrankFocusKeyword', |
| 508 |
[ |
| 509 |
'ajaxUrl' => admin_url('admin-ajax.php'), |
| 510 |
'nonce' => wp_create_nonce(\ThinkRank\Admin\Focus_Keyword_Ajax::get_nonce_action()), |
| 511 |
'action' => \ThinkRank\Admin\Focus_Keyword_Ajax::get_ajax_action(), |
| 512 |
'i18n' => [ |
| 513 |
'notSet' => __('Not Set', 'thinkrank'), |
| 514 |
'clickToEdit' => __('Click to edit', 'thinkrank'), |
| 515 |
'pressEnter' => __('Press Enter to save, Esc to cancel', 'thinkrank'), |
| 516 |
'saving' => __('Saving...', 'thinkrank'), |
| 517 |
'saved' => __('Saved!', 'thinkrank'), |
| 518 |
'error' => __('Error updating focus keyword. Please try again.', 'thinkrank'), |
| 519 |
] |
| 520 |
] |
| 521 |
); |
| 522 |
} |
| 523 |
|
| 524 |
} |
| 525 |
|