PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 1.0.0 All 52 releases
← All changes | includes/api/class-seo-score-endpoint.php +68 -4 1.30.0 → 2.11.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,13 +286,28 @@
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)) {
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 + $editor_measured = '' !== trim((string) $live_content);
305 +
306 + if ($editor_measured && null !== $readability_score) {
268 307 $score_data['readability_score'] = $readability_score;
269 308 }
270 - if (!empty($content_quality)) {
309 + if ($editor_measured && null !== $content_quality) {
271 310 $score_data['content_quality'] = $content_quality;
272 311 }
273 312
274 313 // Save score if requested
@@ -308,8 +347,10 @@
308 347 // Get existing score data from database
309 348 $existing_data = $this->calculator->get_existing_score_data($post_id);
310 349
311 350 if ($existing_data) {
351 + $existing_data['keywords'] = $this->keyword_placements((int) $post_id);
352 +
312 353 return new WP_REST_Response([
313 354 'success' => true,
314 355 'data' => $existing_data,
315 356 'message' => __('Existing SEO score retrieved successfully', 'thinkrank')
@@ -327,8 +368,27 @@
327 368 }
328 369 }
329 370
330 371 /**
372 + * Where each focus keyword sits (#729), for a stored score. A stored score
373 + * predates the editor's current keywords and content, so this is read
374 + * from the post as saved now — a text pass, no request, no AI.
375 + *
376 + * @param int $post_id Post ID.
377 + * @return array<int, array<string, mixed>>
378 + */
379 + private function keyword_placements(int $post_id): array {
380 + return $this->calculator->keyword_placements(
381 + $this->calculator->analyze_post_content($post_id),
382 + [
383 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id),
384 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id),
385 + ],
386 + \ThinkRank\SEO\Focus_Keywords::get($post_id)
387 + );
388 + }
389 +
390 + /**
331 391 * Get score history for a post
332 392 *
333 393 * @param WP_REST_Request $request Request object
334 394 * @return WP_REST_Response|WP_Error Response object
@@ -363,10 +423,14 @@
363 423 public function get_latest_score(WP_REST_Request $request) {
364 424 try {
365 425 $post_id = $request->get_param('post_id');
366 426
427 + // The stored row only — no keyword gauges (#729). The dashboard
428 + // asks for five posts at once and reads overall_score; a gauge
429 + // pass would analyse and render each whole post for nothing. The
430 + // editor reads its gauges from /seo-score/get.
367 431 $latest_score = $this->calculator->get_latest_score($post_id);
368 -
432 +
369 433 return new WP_REST_Response([
370 434 'success' => true,
371 435 'data' => $latest_score,
372 436 ], 200);