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 +274 -14 1.26.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 */
@@ -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,12 @@
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 + 'effective_title' => $meta_title,
221 + 'effective_description' => $meta_description,
184 222 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1',
185 223 ];
186 224 }
187 225
@@ -213,8 +251,163 @@
213 251 */
214 252 private static ?bool $scores_table_exists = null;
215 253
216 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 + /**
217 410 * Get SEO metrics from database table
218 411 *
219 412 * @param int $post_id Post ID
220 413 * @return array SEO metrics (seo_score, content_quality, readability_score)
@@ -229,8 +422,9 @@
229 422 'seo_score' => null,
230 423 'seo_grade' => null,
231 424 'content_quality' => null,
232 425 'readability_score' => null,
426 + 'calculated_at' => null,
233 427 ];
234 428
235 429 // Check if table exists (memoized per request — the column runs this
236 430 // per row and the schema is stable within a single page load).
@@ -249,9 +443,9 @@
249 443 // Get the most recent metrics from the database
250 444 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
251 445 $result = $wpdb->get_row($wpdb->prepare(
252 446 // 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",
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",
254 448 $post_id
255 449 ), ARRAY_A);
256 450
257 451 if (!$result) {
@@ -262,8 +456,9 @@
262 456 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null,
263 457 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null,
264 458 'content_quality' => $result['content_quality'],
265 459 'readability_score' => $result['readability_score'],
460 + 'calculated_at' => $result['created_at'],
266 461 ];
267 462 }
268 463
269 464 /**
@@ -307,8 +502,28 @@
307 502 <path d="M1 11H11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
308 503 </svg>
309 504 </div>
310 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; ?>
311 526 </div>
312 527
313 528 <div class="thinkrank-seo-row thinkrank-seo-row-middle">
314 529 <div class="thinkrank-focus-keyword-item" data-post-id="<?php echo esc_attr($post_id); ?>">
@@ -500,8 +715,54 @@
500 715 THINKRANK_VERSION,
501 716 true
502 717 );
503 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 +
504 765 // Localize script with AJAX data
505 766 wp_localize_script(
506 767 'thinkrank-focus-keyword-inline-edit',
507 768 'thinkrankFocusKeyword',
@@ -519,6 +780,5 @@
519 780 ]
520 781 ]
521 782 );
522 783 }
523 -
524 784 }