PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.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 All 53 releases
← All changes | includes/api/class-seo-score-endpoint.php +129 -12 1.25.0 → 2.12.0 View file →
@@ -101,8 +101,41 @@
101 101 'required' => false,
102 102 'type' => 'string',
103 103 'sanitize_callback' => 'wp_kses_post',
104 104 ],
105 + // Unsaved SEO title/description straight from the editor. Both
106 + // are scored factors, and without them an "Apply" that changes
107 + // the title scores against the stale saved value — the score
108 + // only moved once the post was saved, which read as the score
109 + // updating minutes later on its own. Omitted (null) means "use
110 + // what's saved"; an empty string means the user cleared the
111 + // field, which must fall back to the rendered pattern exactly
112 + // as an empty stored value does.
113 + 'live_title' => [
114 + 'required' => false,
115 + 'type' => 'string',
116 + 'sanitize_callback' => 'sanitize_text_field',
117 + ],
118 + 'live_description' => [
119 + 'required' => false,
120 + 'type' => 'string',
121 + 'sanitize_callback' => 'sanitize_text_field',
122 + ],
123 + // The permalink field as it stands in the editor, so an unsaved
124 + // slug edit scores immediately instead of matching the old URL.
125 + 'live_slug' => [
126 + 'required' => false,
127 + 'type' => 'string',
128 + // NOT sanitize_title: REST calls a sanitize_callback as
129 + // ($value, $request, $param), and sanitize_title()'s second
130 + // parameter is $fallback_title — so an empty slug returned
131 + // the WP_REST_Request object, which fataled on the string
132 + // cast below. The editor sends an empty slug whenever the
133 + // permalink field is blank (every draft), so this fired on
134 + // ordinary use. Sanitized with a single-argument
135 + // sanitize_title() where it is consumed instead.
136 + 'sanitize_callback' => 'sanitize_text_field',
137 + ],
105 138 'readability_score' => [
106 139 'required' => false,
107 140 'type' => 'string',
108 141 'sanitize_callback' => 'sanitize_text_field',
@@ -199,17 +232,33 @@
199 232 // Get post metadata. Score against the FINAL rendered SEO title/
200 233 // description — resolve any variable tags in a custom value, and fall
201 234 // back to the rendered Global/Bulk pattern when the field is empty, so
202 235 // the length-based scoring matches what the frontend actually outputs.
203 - $raw_title = get_post_meta($post_id, '_thinkrank_seo_title', true);
204 - $raw_description = get_post_meta($post_id, '_thinkrank_meta_description', true);
236 + // Prefer the editor's live values when the request carried them, so
237 + // an unsaved title/description edit scores immediately instead of
238 + // waiting for the post to be saved. `null` means the request said
239 + // nothing about the field, which keeps the saved value.
240 + $live_title = $request->get_param('live_title');
241 + $live_description = $request->get_param('live_description');
242 +
243 + // Score the slug the editor is showing. Omitted (null) keeps the
244 + // saved slug; an empty string means the field was cleared, which
245 + // falls back to the title-derived slug exactly as an unsaved draft
246 + // does inside the calculator.
247 + $live_slug = $request->get_param('live_slug');
248 + if ($live_slug !== null) {
249 + $content_data['slug'] = sanitize_title((string) $live_slug);
250 + }
251 +
252 + $raw_title = $live_title !== null
253 + ? (string) $live_title
254 + : get_post_meta($post_id, '_thinkrank_seo_title', true);
255 + $raw_description = $live_description !== null
256 + ? (string) $live_description
257 + : get_post_meta($post_id, '_thinkrank_meta_description', true);
205 258 $metadata = [
206 - 'title' => $raw_title !== ''
207 - ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_title, $post_id)
208 - : \ThinkRank\SEO\Pattern_Resolver::title($post_id),
209 - 'description' => $raw_description !== ''
210 - ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_description, $post_id)
211 - : \ThinkRank\SEO\Pattern_Resolver::description($post_id),
259 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_title, $post_id, 'title'),
260 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_description, $post_id, 'description'),
212 261 // Fallback source for the scorer when the request carries no
213 262 // keyword (e.g. a plain "score this post" call). An explicit
214 263 // request keyword still wins, so the editor keeps scoring
215 264 // unsaved keyword edits live.
@@ -237,13 +286,39 @@
237 286 $metadata,
238 287 $score_options
239 288 );
240 289
241 - // Add readability_score and content_quality from frontend if provided
242 - if (!empty($readability_score)) {
290 + // Add readability_score and content_quality from frontend if
291 + // provided. `!== null`, not `!empty()`: 0 is a legitimate score and
292 + // empty() discarded it, so a post the editor scored as 0 kept
293 + // whatever the calculator had produced instead (#394). Both params
294 + // already default to null above, so null means "not sent".
295 + //
296 + // Only when the editor actually measured something, though. Inside a
297 + // page builder there is no editor content to read, so Refresh sent an
298 + // empty `live_content` along with the two labels derived from that
299 + // empty string — the literal words "No content" — and they were
300 + // applied over the correct server-side analysis this request had just
301 + // run, then persisted by save_score(). That is what put "No content"
302 + // on the dashboard for a page with a valid score (#778). With nothing
303 + // measured, the analysis above is the better answer.
304 + //
305 + // Non-empty `live_content` is not on its own proof that the editor
306 + // measured anything: builder markup (Divi 5) is markup the editor
307 + // has plenty of and can read no words from, so it sent that same
308 + // "No content" pair alongside 450 characters of block comments.
309 + // Treat the sentinel itself as "nothing measured" whenever this
310 + // request's own analysis did find words, so a stale bundle cannot
311 + // overwrite a valid analysis either.
312 + $editor_measured = '' !== trim((string) $live_content);
313 + $server_found_content = ($content_data['word_count'] ?? 0) > 0;
314 +
315 + if ($editor_measured && null !== $readability_score
316 + && !($server_found_content && self::is_no_content_label($readability_score))) {
243 317 $score_data['readability_score'] = $readability_score;
244 318 }
245 - if (!empty($content_quality)) {
319 + if ($editor_measured && null !== $content_quality
320 + && !($server_found_content && self::is_no_content_label($content_quality))) {
246 321 $score_data['content_quality'] = $content_quality;
247 322 }
248 323
249 324 // Save score if requested
@@ -270,8 +345,25 @@
270 345 }
271 346 }
272 347
273 348 /**
349 + * Whether an editor-supplied label means "I could not read the content".
350 + *
351 + * `calculateReadabilityScore()` and `calculateContentQuality()` both return
352 + * this exact untranslated string when handed an empty body, so it is the
353 + * editor saying it measured nothing rather than a measurement in its own
354 + * right. Every real label carries a level or a grade.
355 + *
356 + * @since 2.12.0
357 + *
358 + * @param mixed $label Label sent by the editor.
359 + * @return bool True when the label is the empty-content sentinel.
360 + */
361 + private static function is_no_content_label($label): bool {
362 + return is_string($label) && 'no content' === strtolower(trim($label));
363 + }
364 +
365 + /**
274 366 * Get existing SEO score for a post
275 367 *
276 368 * @param WP_REST_Request $request Request object
277 369 * @return WP_REST_Response|WP_Error Response object
@@ -283,8 +375,10 @@
283 375 // Get existing score data from database
284 376 $existing_data = $this->calculator->get_existing_score_data($post_id);
285 377
286 378 if ($existing_data) {
379 + $existing_data['keywords'] = $this->keyword_placements((int) $post_id);
380 +
287 381 return new WP_REST_Response([
288 382 'success' => true,
289 383 'data' => $existing_data,
290 384 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
@@ -302,8 +396,27 @@
302 396 }
303 397 }
304 398
305 399 /**
400 + * Where each focus keyword sits (#729), for a stored score. A stored score
401 + * predates the editor's current keywords and content, so this is read
402 + * from the post as saved now — a text pass, no request, no AI.
403 + *
404 + * @param int $post_id Post ID.
405 + * @return array<int, array<string, mixed>>
406 + */
407 + private function keyword_placements(int $post_id): array {
408 + return $this->calculator->keyword_placements(
409 + $this->calculator->analyze_post_content($post_id),
410 + [
411 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id),
412 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id),
413 + ],
414 + \ThinkRank\SEO\Focus_Keywords::get($post_id)
415 + );
416 + }
417 +
418 + /**
306 419 * Get score history for a post
307 420 *
308 421 * @param WP_REST_Request $request Request object
309 422 * @return WP_REST_Response|WP_Error Response object
@@ -338,10 +451,14 @@
338 451 public function get_latest_score(WP_REST_Request $request) {
339 452 try {
340 453 $post_id = $request->get_param('post_id');
341 454
455 + // The stored row only — no keyword gauges (#729). The dashboard
456 + // asks for five posts at once and reads overall_score; a gauge
457 + // pass would analyse and render each whole post for nothing. The
458 + // editor reads its gauges from /seo-score/get.
342 459 $latest_score = $this->calculator->get_latest_score($post_id);
343 -
460 +
344 461 return new WP_REST_Response([
345 462 'success' => true,
346 463 'data' => $latest_score,
347 464 ], 200);