| @@ -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,28 @@ | ||
| 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 | + $editor_measured = '' !== trim((string) $live_content); | |
| 305 | + | |
| 306 | + if ($editor_measured && null !== $readability_score) { | |
| 243 | 307 | $score_data['readability_score'] = $readability_score; |
| 244 | 308 | } |
| 245 | - if (!empty($content_quality)) { | |
| 309 | + if ($editor_measured && null !== $content_quality) { | |
| 246 | 310 | $score_data['content_quality'] = $content_quality; |
| 247 | 311 | } |
| 248 | 312 | |
| 249 | 313 | // Save score if requested |
| @@ -283,8 +347,10 @@ | ||
| 283 | 347 | // Get existing score data from database |
| 284 | 348 | $existing_data = $this->calculator->get_existing_score_data($post_id); |
| 285 | 349 | |
| 286 | 350 | if ($existing_data) { |
| 351 | + $existing_data['keywords'] = $this->keyword_placements((int) $post_id); | |
| 352 | + | |
| 287 | 353 | return new WP_REST_Response([ |
| 288 | 354 | 'success' => true, |
| 289 | 355 | 'data' => $existing_data, |
| 290 | 356 | 'message' => __('Existing SEO score retrieved successfully', 'thinkrank') |
| @@ -302,8 +368,27 @@ | ||
| 302 | 368 | } |
| 303 | 369 | } |
| 304 | 370 | |
| 305 | 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 | + /** | |
| 306 | 391 | * Get score history for a post |
| 307 | 392 | * |
| 308 | 393 | * @param WP_REST_Request $request Request object |
| 309 | 394 | * @return WP_REST_Response|WP_Error Response object |
| @@ -338,10 +423,14 @@ | ||
| 338 | 423 | public function get_latest_score(WP_REST_Request $request) { |
| 339 | 424 | try { |
| 340 | 425 | $post_id = $request->get_param('post_id'); |
| 341 | 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. | |
| 342 | 431 | $latest_score = $this->calculator->get_latest_score($post_id); |
| 343 | - | |
| 432 | + | |
| 344 | 433 | return new WP_REST_Response([ |
| 345 | 434 | 'success' => true, |
| 346 | 435 | 'data' => $latest_score, |
| 347 | 436 | ], 200); |