| @@ -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); |