| @@ -286,13 +286,39 @@ | ||
| 286 | 286 | $metadata, |
| 287 | 287 | $score_options |
| 288 | 288 | ); |
| 289 | 289 | |
| 290 | - // Add readability_score and content_quality from frontend if provided | |
| 291 | - 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))) { | |
| 292 | 317 | $score_data['readability_score'] = $readability_score; |
| 293 | 318 | } |
| 294 | - if (!empty($content_quality)) { | |
| 319 | + if ($editor_measured && null !== $content_quality | |
| 320 | + && !($server_found_content && self::is_no_content_label($content_quality))) { | |
| 295 | 321 | $score_data['content_quality'] = $content_quality; |
| 296 | 322 | } |
| 297 | 323 | |
| 298 | 324 | // Save score if requested |
| @@ -319,8 +345,25 @@ | ||
| 319 | 345 | } |
| 320 | 346 | } |
| 321 | 347 | |
| 322 | 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 | + /** | |
| 323 | 366 | * Get existing SEO score for a post |
| 324 | 367 | * |
| 325 | 368 | * @param WP_REST_Request $request Request object |
| 326 | 369 | * @return WP_REST_Response|WP_Error Response object |
| @@ -332,8 +375,10 @@ | ||
| 332 | 375 | // Get existing score data from database |
| 333 | 376 | $existing_data = $this->calculator->get_existing_score_data($post_id); |
| 334 | 377 | |
| 335 | 378 | if ($existing_data) { |
| 379 | + $existing_data['keywords'] = $this->keyword_placements((int) $post_id); | |
| 380 | + | |
| 336 | 381 | return new WP_REST_Response([ |
| 337 | 382 | 'success' => true, |
| 338 | 383 | 'data' => $existing_data, |
| 339 | 384 | 'message' => __('Existing SEO score retrieved successfully', 'thinkrank') |
| @@ -351,8 +396,27 @@ | ||
| 351 | 396 | } |
| 352 | 397 | } |
| 353 | 398 | |
| 354 | 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 | + /** | |
| 355 | 419 | * Get score history for a post |
| 356 | 420 | * |
| 357 | 421 | * @param WP_REST_Request $request Request object |
| 358 | 422 | * @return WP_REST_Response|WP_Error Response object |
| @@ -387,10 +451,14 @@ | ||
| 387 | 451 | public function get_latest_score(WP_REST_Request $request) { |
| 388 | 452 | try { |
| 389 | 453 | $post_id = $request->get_param('post_id'); |
| 390 | 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. | |
| 391 | 459 | $latest_score = $this->calculator->get_latest_score($post_id); |
| 392 | - | |
| 460 | + | |
| 393 | 461 | return new WP_REST_Response([ |
| 394 | 462 | 'success' => true, |
| 395 | 463 | 'data' => $latest_score, |
| 396 | 464 | ], 200); |