PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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
← All changes | includes/admin/class-post-list-columns.php +377 -317 1.10.02.7.0 View file →
@@ -28,14 +28,48 @@
28 28 class Post_List_Columns {
29 29
30 30 /**
31 31 * Column ID
32 - *
32 + *
33 33 * @var string
34 34 */
35 35 private const COLUMN_ID = 'thinkrank_seo_overview';
36 36
37 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 + /**
38 72 * Initialize post list columns
39 73 *
40 74 * @return void
41 75 */
@@ -75,8 +109,15 @@
75 109 unset($post_types['attachment']);
76 110 unset($post_types['elementor_library']);
77 111 unset($post_types['oceanwp_library']);
78 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']);
79 120
80 121 /**
81 122 * Filter supported post types for ThinkRank SEO Overview column
82 123 *
@@ -135,19 +176,31 @@
135 176 * @param int $post_id Post ID
136 177 * @return array SEO data
137 178 */
138 179 private function get_seo_data(int $post_id): array {
139 - // Get SEO score and metrics from database table
140 - $seo_metrics = $this->get_seo_metrics_from_database($post_id);
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] ?? '';
141 186
142 - // Get focus keyword
143 - $focus_keyword = get_post_meta($post_id, '_thinkrank_focus_keyword', true);
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);
144 193
145 - // Get meta title
146 - $meta_title = get_post_meta($post_id, '_thinkrank_seo_title', true);
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);
147 199
148 - // Get meta description
149 - $meta_description = get_post_meta($post_id, '_thinkrank_meta_description', true);
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);
150 203
151 204 // Get content quality and readability from database columns
152 205 $content_quality = $seo_metrics['content_quality'] ?? 'Not Analyzed';
153 206 $readability = $seo_metrics['readability_score'] ?? 'N/A';
@@ -158,10 +211,15 @@
158 211 'content_quality' => $content_quality,
159 212 'readability' => $readability,
160 213 'focus_keyword' => !empty($focus_keyword),
161 214 'focus_keyword_value' => $focus_keyword,
215 + 'focus_keyword_primary' => $focus_keyword_primary,
162 216 'meta_title' => !empty($meta_title),
163 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,
164 222 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1',
165 223 ];
166 224 }
167 225
@@ -181,8 +239,175 @@
181 239 return true;
182 240 }
183 241
184 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 + /**
185 410 * Get SEO metrics from database table
186 411 *
187 412 * @param int $post_id Post ID
188 413 * @return array SEO metrics (seo_score, content_quality, readability_score)
@@ -197,26 +422,30 @@
197 422 'seo_score' => null,
198 423 'seo_grade' => null,
199 424 'content_quality' => null,
200 425 'readability_score' => null,
426 + 'calculated_at' => null,
201 427 ];
202 428
203 - // Check if table exists
204 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score retrieval requires direct database access
205 - $table_exists = $wpdb->get_var($wpdb->prepare(
206 - "SHOW TABLES LIKE %s",
207 - $table_name
208 - ));
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 + }
209 438
210 - if (!$table_exists) {
439 + if (!self::$scores_table_exists) {
211 440 return $default_metrics;
212 441 }
213 442
214 443 // Get the most recent metrics from the database
215 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score retrieval requires direct database access
444 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
216 445 $result = $wpdb->get_row($wpdb->prepare(
217 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped
218 - "SELECT overall_score, grade, content_quality, readability_score FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1",
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",
219 448 $post_id
220 449 ), ARRAY_A);
221 450
222 451 if (!$result) {
@@ -227,8 +456,9 @@
227 456 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null,
228 457 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null,
229 458 'content_quality' => $result['content_quality'],
230 459 'readability_score' => $result['readability_score'],
460 + 'calculated_at' => $result['created_at'],
231 461 ];
232 462 }
233 463
234 464 /**
@@ -272,16 +502,36 @@
272 502 <path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
273 503 </svg>
274 504 </div>
275 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; ?>
276 526 </div>
277 527
278 528 <div class="thinkrank-seo-row thinkrank-seo-row-middle">
279 529 <div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>">
280 530 <span class="thinkrank-focus-keyword-display <?php echo $seo_data['focus_keyword'] ? 'thinkrank-set' : 'thinkrank-not-set'; ?>"
281 - data-keyword="<?php echo esc_attr($seo_data['focus_keyword_value']); ?>"
531 + data-keyword="<?php echo esc_attr($seo_data['focus_keyword_primary']); ?>"
282 532 title="<?php esc_attr_e('Focus Keyword - Click to edit', 'thinkrank'); ?>">
283 - <?php echo $seo_data['focus_keyword'] ? esc_html($seo_data['focus_keyword_value']) : esc_html__('Not Set', 'thinkrank'); ?>
533 + <?php echo $seo_data['focus_keyword'] ? esc_html($seo_data['focus_keyword_primary']) : esc_html__('Not Set', 'thinkrank'); ?>
284 534 </span>
285 535 <svg class="thinkrank-edit-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
286 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" />
287 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" />
@@ -378,14 +628,63 @@
378 628 if (!is_admin() || !$query->is_main_query()) {
379 629 return;
380 630 }
381 631
382 - $orderby = $query->get('orderby');
632 + if ($query->get('orderby') !== 'thinkrank_seo_score') {
633 + return;
634 + }
383 635
384 - if ($orderby === 'thinkrank_seo_score') {
385 - $query->set('meta_key', '_thinkrank_seo_score');
386 - $query->set('orderby', 'meta_value_num');
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;
387 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;
388 687 }
389 688
390 689 /**
391 690 * Enqueue column styles
@@ -399,10 +698,15 @@
399 698 if (!$screen || $screen->base !== 'edit') {
400 699 return;
401 700 }
402 701
403 - // Add inline styles to common admin styles
404 - wp_add_inline_style('common', $this->get_column_styles());
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 + );
405 709
406 710 // Enqueue inline editing script
407 711 wp_enqueue_script(
408 712 'thinkrank-focus-keyword-inline-edit',
@@ -411,8 +715,54 @@
411 715 THINKRANK_VERSION,
412 716 true
413 717 );
414 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 +
415 765 // Localize script with AJAX data
416 766 wp_localize_script(
417 767 'thinkrank-focus-keyword-inline-edit',
418 768 'thinkrankFocusKeyword',
@@ -429,296 +779,6 @@
429 779 'error' => __('Error updating focus keyword. Please try again.', 'thinkrank'),
430 780 ]
431 781 ]
432 782 );
433 - }
434 -
435 - /**
436 - * Get column CSS styles
437 - *
438 - * @return string CSS styles
439 - */
440 - private function get_column_styles(): string {
441 - return '
442 - .column-thinkrank_seo_overview {
443 - width: 240px;
444 - }
445 -
446 - .thinkrank-seo-overview {
447 - font-size: 13px;
448 - line-height: 1.4;
449 - display: flex;
450 - flex-direction: column;
451 - gap: 6px;
452 - }
453 -
454 - .thinkrank-seo-row {
455 - display: flex;
456 - align-items: center;
457 - flex-wrap: wrap;
458 - }
459 -
460 - .thinkrank-seo-separator {
461 - margin: 0 6px;
462 - color: #c3c4c7;
463 - font-size: 10px;
464 - }
465 -
466 - /* Top Row: Badges */
467 - .thinkrank-seo-badge {
468 - border-radius: 12px;
469 - padding: 2px 8px;
470 - font-weight: 600;
471 - font-size: 12px;
472 - display: inline-flex;
473 - align-items: center;
474 - white-space: nowrap;
475 - position: relative;
476 - }
477 -
478 - .thinkrank-seo-badge::after,
479 - .thinkrank-seo-metric::after {
480 - content: attr(title);
481 - position: absolute;
482 - bottom: calc(100% + 11px);
483 - left: 50%;
484 - transform: translateX(-50%);
485 - background: #00043D;
486 - border-radius: 8px;
487 - padding: 4px 8px;
488 - color: #fff;
489 - font-weight: 400;
490 - box-shadow: 0 1px 2px rgba(17, 17, 17, 0.1);
491 - visibility: hidden;
492 - white-space: nowrap;
493 - z-index: 20;
494 - }
495 -
496 - .thinkrank-seo-badge::before,
497 - .thinkrank-seo-metric::before {
498 - content: "";
499 - position: absolute;
500 - bottom: calc(100% + 7px);
501 - left: calc(50% - 5px);
502 - transform: rotate(45deg);
503 - background: #00043D;
504 - width: 10px;
505 - height: 10px;
506 - border-radius: 2px;
507 - box-shadow: 0 1px 2px rgba(17, 17, 17, 0.1);
508 - visibility: hidden;
509 - z-index: 19;
510 - }
511 -
512 - .thinkrank-seo-badge:hover::before,
513 - .thinkrank-seo-badge:hover::after {
514 - visibility: visible;
515 - }
516 -
517 - /* Apply Z-Index for metric too */
518 - .thinkrank-seo-metric:hover::before,
519 - .thinkrank-seo-metric:hover::after {
520 - visibility: visible;
521 - }
522 -
523 - .thinkrank-score-great {
524 - background-color: #d1fae5;
525 - color: #065f46;
526 - }
527 -
528 - .thinkrank-score-good {
529 - background-color: #dbeafe;
530 - color: #1e40af;
531 - }
532 -
533 - .thinkrank-score-medium {
534 - background-color: #fef3c7;
535 - color: #92400e;
536 - }
537 -
538 - .thinkrank-score-poor {
539 - background-color: #fee2e2;
540 - color: #b91c1c;
541 - }
542 -
543 - .thinkrank-status-good {
544 - background-color: #E5FFF6;
545 - color: #117953;
546 - }
547 -
548 - .thinkrank-status-ok {
549 - background-color: #EBF0FF;
550 - color: #1B42C2;
551 - }
552 -
553 - .thinkrank-status-not-analyzed,
554 - .thinkrank-seo-badge.thinkrank-not-set {
555 - background-color: #E5EBF0;
556 - color: #58677D;
557 - border-radius: 12px;
558 - padding: 2px 8px;
559 - }
560 -
561 - .thinkrank-status-needs-improvement {
562 - background-color: #FFF7EB;
563 - color: #865A13;
564 - }
565 -
566 - .thinkrank-status-no-content {
567 - background-color: #FFEBEE;
568 - color: #EC113A;
569 - }
570 -
571 - .thinkrank-pillar-icon {
572 - background-color: #F0F0F1;
573 - color: #2271b1;
574 - padding: 4px;
575 - line-height: 20px;
576 - }
577 -
578 - /* Middle Row: Focus Keyword */
579 - .thinkrank-focus-keyword-item {
580 - width: 100%;
581 - position: relative;
582 - display: flex;
583 - align-items: center;
584 - background-color: #ECEEEE;
585 - border-radius: 4px;
586 - border: 1px solid #E4E6EC;
587 - padding: 0 8px;
588 - min-height: 24px;
589 - }
590 -
591 - .thinkrank-focus-keyword-display {
592 - cursor: pointer;
593 - flex-grow: 1;
594 - white-space: nowrap;
595 - overflow: hidden;
596 - text-overflow: ellipsis;
597 - color: #1d2327;
598 - font-weight: 500;
599 - }
600 -
601 - .thinkrank-focus-keyword-display.thinkrank-not-set {
602 - color: #646970;
603 - font-style: italic;
604 - }
605 -
606 - .thinkrank-edit-icon {
607 - font-size: 16px;
608 - width: 16px;
609 - height: 16px;
610 - pointer-events: none;
611 - position: absolute;
612 - right: 8px;
613 -
614 - path {
615 - stroke: #A5AAAC;
616 - }
617 - }
618 -
619 - .thinkrank-focus-keyword-item .thinkrank-success-icon {
620 - position: absolute;
621 - right: 8px;
622 - visibility: hidden;
623 - }
624 -
625 - .thinkrank-focus-keyword-item:hover .thinkrank-edit-icon path {
626 - stroke: #4451FF;
627 - }
628 -
629 - .thinkrank-focus-keyword-input[type="text"] {
630 - width: 100%;
631 - padding: 0 8px;
632 - border: none;
633 - border-radius: 4px;
634 - font-size: 13px;
635 - margin: 0;
636 - min-height: 24px;
637 - line-height: normal;
638 - }
639 -
640 - .thinkrank-focus-keyword-item.is-editing {
641 - padding: 0;
642 - background: transparent;
643 - border-color: #5C91FE;
644 - }
645 -
646 - .thinkrank-focus-keyword-item.is-editing .thinkrank-focus-keyword-display,
647 - .thinkrank-focus-keyword-item.is-editing .thinkrank-edit-icon {
648 - display: none;
649 - }
650 -
651 - .thinkrank-focus-keyword-item.is-editing .thinkrank-success-icon {
652 - visibility: visible;
653 - }
654 -
655 - .thinkrank-focus-keyword-item.is-editing.is-saving .thinkrank-success-icon {
656 - visibility: hidden;
657 - }
658 -
659 - /* Bottom Row: Metrics */
660 - .thinkrank-seo-metric {
661 - display: flex;
662 - align-items: center;
663 - gap: 4px;
664 - color: #646970;
665 - font-size: 12px;
666 - line-height: 20px;
667 - position: relative;
668 - }
669 -
670 - .thinkrank-seo-metric:hover::before,
671 - .thinkrank-seo-metric:hover::after {
672 - visibility: visible;
673 - }
674 -
675 - .thinkrank-metric-value {
676 - white-space: nowrap;
677 - color: #505272;
678 - }
679 -
680 - /* Animations */
681 - .thinkrank-focus-keyword-display.thinkrank-feedback-success {
682 - animation: thinkrank-success-flash 0.5s ease-in-out;
683 - }
684 -
685 - .thinkrank-focus-keyword-display.thinkrank-feedback-error {
686 - animation: thinkrank-error-flash 0.5s ease-in-out;
687 - }
688 -
689 - @keyframes thinkrank-success-flash {
690 - 0%, 100% { background-color: transparent; }
691 - 50% { background-color: #d1fae5; }
692 - }
693 -
694 - @keyframes thinkrank-error-flash {
695 - 0%, 100% { background-color: transparent; }
696 - 50% { background-color: #fee2e2; }
697 - }
698 -
699 - .thinkrank-inline-error {
700 - position: absolute;
701 - top: 100%;
702 - left: 0;
703 - z-index: 10;
704 - color: #fff;
705 - font-size: 11px;
706 - margin-top: 4px;
707 - padding: 4px 8px;
708 - background-color: #d63638;
709 - border-radius: 4px;
710 - box-shadow: 0 2px 5px rgba(0,0,0,0.2);
711 - }
712 -
713 - .thinkrank-inline-error::after {
714 - content: "";
715 - position: absolute;
716 - bottom: 100%;
717 - left: 10px;
718 - border-width: 5px;
719 - border-style: solid;
720 - border-color: transparent transparent #d63638 transparent;
721 - }
722 - ';
723 783 }
724 784 }