PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.27.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.27.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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 1.27.0, at includes/admin/class-post-list-columns.php

525 lines 24.2 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 * 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