PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.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 All 51 releases
← All changes | includes/admin/class-post-list-columns.php +277 -14 1.27.0 → 2.10.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 */
@@ -142,11 +176,8 @@
142 176 * @param int $post_id Post ID
143 177 * @return array SEO data
144 178 */
145 179 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 180 // Get focus keyword(s). The column shows only the primary keyword, but
150 181 // the inline editor operates on the full comma-separated set so editing
151 182 // never drops the secondary keywords.
152 183 $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id);
@@ -156,18 +187,21 @@
156 187 // Get the effective meta title/description. When no custom per-post value
157 188 // is set, ThinkRank still renders these from the Bulk SEO Optimization
158 189 // (Global SEO) patterns, so resolve those inherited values here instead of
159 190 // 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);
191 + $meta_title = \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id);
192 + $meta_description = \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id);
164 193
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);
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);
169 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 +
170 204 // Get content quality and readability from database columns
171 205 $content_quality = $seo_metrics['content_quality'] ?? 'Not Analyzed';
172 206 $readability = $seo_metrics['readability_score'] ?? 'N/A';
173 207
@@ -180,8 +214,15 @@
180 214 'focus_keyword_value' => $focus_keyword,
181 215 'focus_keyword_primary' => $focus_keyword_primary,
182 216 'meta_title' => !empty($meta_title),
183 217 'meta_description' => !empty($meta_description),
218 + 'seo_title_raw' => $seo_title_raw,
219 + 'meta_description_raw' => $meta_description_raw,
220 + // Placeholders in Quick Edit, so decoded to the text the page
221 + // shows: get_the_title() texturizes "&" into `&`, which
222 + // esc_attr() then carried into the field verbatim.
223 + 'effective_title' => \ThinkRank\Core\Seo_Text::as_displayed($meta_title),
224 + 'effective_description' => \ThinkRank\Core\Seo_Text::as_displayed($meta_description),
184 225 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1',
185 226 ];
186 227 }
187 228
@@ -213,8 +254,163 @@
213 254 */
214 255 private static ?bool $scores_table_exists = null;
215 256
216 257 /**
258 + * Resolve the metrics to display for a post.
259 + *
260 + * Prefers the stored analysis, but only while it still describes the post as
261 + * it stands: a score saved before the last edit is stale, and showing it is
262 + * how a rewritten post could keep advertising the score of the draft it
263 + * replaced. When there is no usable stored score the metrics are calculated
264 + * here instead.
265 + *
266 + * That fallback is the difference between a column that reads "N/A / Not
267 + * Analyzed" forever and one that reports a real score straight away. A score
268 + * row is only ever written by an explicit analysis (the editor panel, the
269 + * bulk analyzer, an MCP call or the importer) — nothing scores a post on
270 + * save — so before this fallback existed, editing the SEO title and
271 + * description left the column empty with no indication that a separate,
272 + * manual step was what it was waiting for.
273 + *
274 + * @since 1.28.0
275 + *
276 + * @param int $post_id Post ID.
277 + * @param string $title Effective meta title.
278 + * @param string $description Effective meta description.
279 + * @param string[] $keywords Focus keywords.
280 + * @return array SEO metrics (seo_score, seo_grade, content_quality, readability_score)
281 + */
282 + private function get_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
283 + $stored = $this->get_seo_metrics_from_database($post_id);
284 +
285 + if ($stored['seo_score'] !== null && !$this->is_score_stale($post_id, $stored['calculated_at'] ?? null)) {
286 + return $stored;
287 + }
288 +
289 + return $this->calculate_seo_metrics($post_id, $title, $description, $keywords);
290 + }
291 +
292 + /**
293 + * Whether a stored score predates the post's last modification.
294 + *
295 + * Both timestamps are site-local `Y-m-d H:i:s` — `post_modified` from
296 + * WordPress and `created_at` written with `current_time('mysql')` — so they
297 + * are directly comparable as strings. A missing timestamp counts as stale;
298 + * calculating is cheap and cached, and a score we cannot date is one we
299 + * cannot vouch for.
300 + *
301 + * @since 1.28.0
302 + *
303 + * @param int $post_id Post ID.
304 + * @param string|null $calculated_at When the stored score was written.
305 + * @return bool True when the post changed after the score was calculated.
306 + */
307 + private function is_score_stale(int $post_id, ?string $calculated_at): bool {
308 + if (empty($calculated_at)) {
309 + return true;
310 + }
311 +
312 + $post = get_post($post_id);
313 + if (!$post instanceof \WP_Post || empty($post->post_modified)) {
314 + return false;
315 + }
316 +
317 + return $post->post_modified > $calculated_at;
318 + }
319 +
320 + /**
321 + * Calculate a post's metrics locally, caching the result against the inputs
322 + * that produced it.
323 + *
324 + * The scoring engine is pure PHP — no AI provider and no HTTP calls — so the
325 + * list table can run it directly. The cache is keyed on a fingerprint of
326 + * everything the score depends on (last modification, effective title and
327 + * description, focus keywords, plugin version), which means a Global SEO
328 + * pattern change or an inline focus-keyword edit invalidates it on its own,
329 + * with nothing to hook and no cache to sweep.
330 + *
331 + * @since 1.28.0
332 + *
333 + * @param int $post_id Post ID.
334 + * @param string $title Effective meta title.
335 + * @param string $description Effective meta description.
336 + * @param string[] $keywords Focus keywords.
337 + * @return array SEO metrics; score/grade null when the post could not be scored.
338 + */
339 + private function calculate_seo_metrics(int $post_id, string $title, string $description, array $keywords): array {
340 + $empty = [
341 + 'seo_score' => null,
342 + 'seo_grade' => null,
343 + 'content_quality' => null,
344 + 'readability_score' => null,
345 + ];
346 +
347 + $post = get_post($post_id);
348 + if (!$post instanceof \WP_Post) {
349 + return $empty;
350 + }
351 +
352 + $fingerprint = md5(implode('|', [
353 + (string) $post->post_modified_gmt,
354 + $title,
355 + $description,
356 + implode(',', $keywords),
357 + THINKRANK_VERSION,
358 + ]));
359 +
360 + $cached = get_post_meta($post_id, self::METRICS_CACHE_META, true);
361 + if (is_array($cached) && ($cached['fingerprint'] ?? '') === $fingerprint && isset($cached['metrics'])) {
362 + return $cached['metrics'];
363 + }
364 +
365 + // Past the ceiling the column falls back to the un-scored display rather
366 + // than holding up the page; the next load picks these up.
367 + if (self::$scored_this_request >= self::MAX_SCORED_PER_REQUEST) {
368 + return $empty;
369 + }
370 + self::$scored_this_request++;
371 +
372 + try {
373 + $calculator = new \ThinkRank\AI\SEOScoreCalculator(new \ThinkRank\Core\Database());
374 +
375 + $content_data = $calculator->analyze_post_content($post_id);
376 + if (empty($content_data)) {
377 + return $empty;
378 + }
379 +
380 + // Score against the effective title/description so a post inheriting
381 + // either from a Global pattern is scored the way it actually renders,
382 + // matching the editor panel rather than counting the field as empty.
383 + $score_data = $calculator->calculate_score(
384 + $content_data,
385 + ['title' => $title, 'description' => $description],
386 + ['target_keywords' => $keywords]
387 + );
388 + } catch (\Throwable $e) {
389 + // A broken shortcode in one post must not take down the whole list.
390 + return $empty;
391 + }
392 +
393 + if (!isset($score_data['overall_score'])) {
394 + return $empty;
395 + }
396 +
397 + $metrics = [
398 + 'seo_score' => (int) $score_data['overall_score'],
399 + 'seo_grade' => $score_data['grade'] ?? null,
400 + 'content_quality' => $score_data['content_quality'] ?? null,
401 + 'readability_score' => $score_data['readability_score'] ?? null,
402 + ];
403 +
404 + update_post_meta($post_id, self::METRICS_CACHE_META, [
405 + 'fingerprint' => $fingerprint,
406 + 'metrics' => $metrics,
407 + ]);
408 +
409 + return $metrics;
410 + }
411 +
412 + /**
217 413 * Get SEO metrics from database table
218 414 *
219 415 * @param int $post_id Post ID
220 416 * @return array SEO metrics (seo_score, content_quality, readability_score)
@@ -229,8 +425,9 @@
229 425 'seo_score' => null,
230 426 'seo_grade' => null,
231 427 'content_quality' => null,
232 428 'readability_score' => null,
429 + 'calculated_at' => null,
233 430 ];
234 431
235 432 // Check if table exists (memoized per request — the column runs this
236 433 // per row and the schema is stable within a single page load).
@@ -249,9 +446,9 @@
249 446 // Get the most recent metrics from the database
250 447 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
251 448 $result = $wpdb->get_row($wpdb->prepare(
252 449 // 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",
450 + "SELECT overall_score, grade, content_quality, readability_score, created_at FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1",
254 451 $post_id
255 452 ), ARRAY_A);
256 453
257 454 if (!$result) {
@@ -262,8 +459,9 @@
262 459 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null,
263 460 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null,
264 461 'content_quality' => $result['content_quality'],
265 462 'readability_score' => $result['readability_score'],
463 + 'calculated_at' => $result['created_at'],
266 464 ];
267 465 }
268 466
269 467 /**
@@ -307,8 +505,28 @@
307 505 <path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
308 506 </svg>
309 507 </div>
310 508 <?php endif; ?>
509 +
510 + <?php if (current_user_can('edit_post', $post_id)): ?>
511 + <button type="button"
512 + class="thinkrank-qe-trigger"
513 + data-post-id="<?php echo esc_attr($post_id); ?>"
514 + data-post-title="<?php echo esc_attr(get_the_title($post_id)); ?>"
515 + data-seo-title="<?php echo esc_attr($seo_data['seo_title_raw']); ?>"
516 + data-meta-description="<?php echo esc_attr($seo_data['meta_description_raw']); ?>"
517 + data-effective-title="<?php echo esc_attr($seo_data['effective_title']); ?>"
518 + data-effective-description="<?php echo esc_attr($seo_data['effective_description']); ?>"
519 + data-focus-keywords="<?php echo esc_attr($seo_data['focus_keyword_value']); ?>"
520 + data-edit-link="<?php echo esc_url(get_edit_post_link($post_id, 'raw') ?? ''); ?>"
521 + aria-label="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>"
522 + title="<?php esc_attr_e('Quick Edit SEO', 'thinkrank'); ?>">
523 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
524 + <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" />
525 + <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" />
526 + </svg>
527 + </button>
528 + <?php endif; ?>
311 529 </div>
312 530
313 531 <div class="thinkrank-seo-row thinkrank-seo-row-middle">
314 532 <div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>">
@@ -500,8 +718,54 @@
500 718 THINKRANK_VERSION,
501 719 true
502 720 );
503 721
722 + // Enqueue the Quick Edit SEO modal (style + script + config)
723 + wp_enqueue_style(
724 + 'thinkrank-seo-quick-edit',
725 + THINKRANK_PLUGIN_URL . 'static/css/seo-quick-edit.css',
726 + [],
727 + THINKRANK_VERSION
728 + );
729 +
730 + wp_enqueue_script(
731 + 'thinkrank-seo-quick-edit',
732 + THINKRANK_PLUGIN_URL . 'assets/seo-quick-edit.js',
733 + [],
734 + THINKRANK_VERSION,
735 + true
736 + );
737 +
738 + wp_localize_script(
739 + 'thinkrank-seo-quick-edit',
740 + 'thinkrankSeoQuickEdit',
741 + [
742 + 'ajaxUrl' => admin_url('admin-ajax.php'),
743 + 'nonce' => wp_create_nonce(\ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_nonce_action()),
744 + 'actionGet' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_get(),
745 + 'actionSave' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_save(),
746 + 'titleMax' => 60,
747 + 'descriptionMax' => 160,
748 + 'i18n' => [
749 + 'modalTitle' => __('Quick Edit SEO', 'thinkrank'),
750 + 'seoTitle' => __('SEO Title', 'thinkrank'),
751 + 'seoTitleHelp' => __('Keep it under 60 characters for best results.', 'thinkrank'),
752 + 'metaDescription' => __('Meta Description', 'thinkrank'),
753 + 'metaDescriptionHelp' => __('Keep it under 160 characters for best results.', 'thinkrank'),
754 + 'focusKeywords' => __('Focus Keywords', 'thinkrank'),
755 + 'focusKeywordsHelp' => __('Separate keywords with commas. The first keyword is primary.', 'thinkrank'),
756 + 'inheritedHint' => __('Leave empty to inherit from your Global SEO pattern.', 'thinkrank'),
757 + 'save' => __('Save', 'thinkrank'),
758 + 'saving' => __('Saving…', 'thinkrank'),
759 + 'cancel' => __('Cancel', 'thinkrank'),
760 + 'close' => __('Close', 'thinkrank'),
761 + 'editFull' => __('Open full editor', 'thinkrank'),
762 + 'loadError' => __('Could not load SEO fields. Please try again.', 'thinkrank'),
763 + 'saveError' => __('Could not save. Please try again.', 'thinkrank'),
764 + ],
765 + ]
766 + );
767 +
504 768 // Localize script with AJAX data
505 769 wp_localize_script(
506 770 'thinkrank-focus-keyword-inline-edit',
507 771 'thinkrankFocusKeyword',
@@ -519,6 +783,5 @@
519 783 ]
520 784 ]
521 785 );
522 786 }
523 -
524 787 }