PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.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 All 55 releases
← All changes | includes/api/class-seo-score-endpoint.php +77 -8 1.26.0 → 2.14.0 View file →
@@ -119,8 +119,23 @@
119 119 'required' => false,
120 120 'type' => 'string',
121 121 'sanitize_callback' => 'sanitize_text_field',
122 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 + ],
123 138 'readability_score' => [
124 139 'required' => false,
125 140 'type' => 'string',
126 141 'sanitize_callback' => 'sanitize_text_field',
@@ -224,8 +239,17 @@
224 239 // nothing about the field, which keeps the saved value.
225 240 $live_title = $request->get_param('live_title');
226 241 $live_description = $request->get_param('live_description');
227 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 +
228 252 $raw_title = $live_title !== null
229 253 ? (string) $live_title
230 254 : get_post_meta($post_id, '_thinkrank_seo_title', true);
231 255 $raw_description = $live_description !== null
@@ -262,16 +286,36 @@
262 286 $metadata,
263 287 $score_options
264 288 );
265 289
266 - // Add readability_score and content_quality from frontend if provided
267 - if (!empty($readability_score)) {
268 - $score_data['readability_score'] = $readability_score;
269 - }
270 - if (!empty($content_quality)) {
271 - $score_data['content_quality'] = $content_quality;
272 - }
290 + // The browser's own readability_score and content_quality are no
291 + // longer applied. They used to be written over the labels this
292 + // request had just computed, and then persisted, so the saved
293 + // Readability and Content Quality described the editor text alone
294 + // (#829).
295 + //
296 + // That is wrong whenever the two see different content, which is
297 + // exactly when it matters: `analyze_live_content()` resolves what
298 + // the editor sent AND whatever `thinkrank_analyzable_content`
299 + // contributes on top, so a post with 2 words in the editor and 615
300 + // added by a filter scored 617 words server-side and saved
301 + // "Too short" / "Needs improvement" from the browser's 2.
302 + //
303 + // Nothing is lost by ignoring them: when `live_content` is non-empty
304 + // the server analysed the very text the editor measured, so its
305 + // labels cover the live case too. The parameters stay accepted so an
306 + // older cached bundle still gets a successful response, and they
307 + // stay documented in the route args; they simply no longer win.
308 + //
309 + // The panel keeps computing them in the browser for instant feedback
310 + // between calculations. What it must not do is persist them.
311 + unset($readability_score, $content_quality);
273 312
313 + // The resolved word count, so the panel can show the number this
314 + // score was actually calculated from rather than its own count of
315 + // the editor text (#829).
316 + $score_data['word_count'] = (int) ($content_data['word_count'] ?? 0);
317 +
274 318 // Save score if requested
275 319 if ($save_score) {
276 320 $user_id = get_current_user_id();
277 321 $score_id = $this->calculator->save_score($post_id, $user_id, $score_data);
@@ -308,8 +352,10 @@
308 352 // Get existing score data from database
309 353 $existing_data = $this->calculator->get_existing_score_data($post_id);
310 354
311 355 if ($existing_data) {
356 + $existing_data['keywords'] = $this->keyword_placements((int) $post_id);
357 +
312 358 return new WP_REST_Response([
313 359 'success' => true,
314 360 'data' => $existing_data,
315 361 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
@@ -327,8 +373,27 @@
327 373 }
328 374 }
329 375
330 376 /**
377 + * Where each focus keyword sits (#729), for a stored score. A stored score
378 + * predates the editor's current keywords and content, so this is read
379 + * from the post as saved now — a text pass, no request, no AI.
380 + *
381 + * @param int $post_id Post ID.
382 + * @return array<int, array<string, mixed>>
383 + */
384 + private function keyword_placements(int $post_id): array {
385 + return $this->calculator->keyword_placements(
386 + $this->calculator->analyze_post_content($post_id),
387 + [
388 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id),
389 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id),
390 + ],
391 + \ThinkRank\SEO\Focus_Keywords::get($post_id)
392 + );
393 + }
394 +
395 + /**
331 396 * Get score history for a post
332 397 *
333 398 * @param WP_REST_Request $request Request object
334 399 * @return WP_REST_Response|WP_Error Response object
@@ -363,10 +428,14 @@
363 428 public function get_latest_score(WP_REST_Request $request) {
364 429 try {
365 430 $post_id = $request->get_param('post_id');
366 431
432 + // The stored row only — no keyword gauges (#729). The dashboard
433 + // asks for five posts at once and reads overall_score; a gauge
434 + // pass would analyse and render each whole post for nothing. The
435 + // editor reads its gauges from /seo-score/get.
367 436 $latest_score = $this->calculator->get_latest_score($post_id);
368 -
437 +
369 438 return new WP_REST_Response([
370 439 'success' => true,
371 440 'data' => $latest_score,
372 441 ], 200);