| @@ -80,8 +80,19 @@ | ||
| 80 | 80 | 'required' => false, |
| 81 | 81 | 'type' => 'string', |
| 82 | 82 | 'sanitize_callback' => 'sanitize_text_field', |
| 83 | 83 | ], |
| 84 | + 'target_keywords' => [ | |
| 85 | + 'required' => false, | |
| 86 | + 'type' => 'array', | |
| 87 | + 'items' => ['type' => 'string'], | |
| 88 | + 'sanitize_callback' => function ($value) { | |
| 89 | + if (!is_array($value)) { | |
| 90 | + return []; | |
| 91 | + } | |
| 92 | + return array_map('sanitize_text_field', $value); | |
| 93 | + }, | |
| 94 | + ], | |
| 84 | 95 | 'save_score' => [ |
| 85 | 96 | 'required' => false, |
| 86 | 97 | 'type' => 'boolean', |
| 87 | 98 | 'default' => true, |
| @@ -90,8 +101,51 @@ | ||
| 90 | 101 | 'required' => false, |
| 91 | 102 | 'type' => 'string', |
| 92 | 103 | 'sanitize_callback' => 'wp_kses_post', |
| 93 | 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 | + ], | |
| 138 | + 'readability_score' => [ | |
| 139 | + 'required' => false, | |
| 140 | + 'type' => 'string', | |
| 141 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 142 | + ], | |
| 143 | + 'content_quality' => [ | |
| 144 | + 'required' => false, | |
| 145 | + 'type' => 'string', | |
| 146 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 147 | + ], | |
| 94 | 148 | ], |
| 95 | 149 | ]); |
| 96 | 150 | |
| 97 | 151 | // Get existing SEO score endpoint |
| @@ -145,9 +199,9 @@ | ||
| 145 | 199 | } |
| 146 | 200 | |
| 147 | 201 | /** |
| 148 | 202 | * Calculate SEO score for a post |
| 149 | - * | |
| 203 | + * | |
| 150 | 204 | * @param WP_REST_Request $request Request object |
| 151 | 205 | * @return WP_REST_Response|WP_Error Response object |
| 152 | 206 | */ |
| 153 | 207 | public function calculate_score(WP_REST_Request $request) { |
| @@ -153,10 +207,13 @@ | ||
| 153 | 207 | public function calculate_score(WP_REST_Request $request) { |
| 154 | 208 | try { |
| 155 | 209 | $post_id = $request->get_param('post_id'); |
| 156 | 210 | $target_keyword = $request->get_param('target_keyword') ?? ''; |
| 211 | + $target_keywords = $request->get_param('target_keywords'); | |
| 157 | 212 | $save_score = $request->get_param('save_score') ?? true; |
| 158 | 213 | $live_content = $request->get_param('live_content') ?? ''; |
| 214 | + $readability_score = $request->get_param('readability_score') ?? null; | |
| 215 | + $content_quality = $request->get_param('content_quality') ?? null; | |
| 159 | 216 | |
| 160 | 217 | // Analyze content - use live content if provided, otherwise saved content |
| 161 | 218 | if (!empty($live_content)) { |
| 162 | 219 | $content_data = $this->calculator->analyze_live_content($live_content, $post_id); |
| @@ -162,9 +219,9 @@ | ||
| 162 | 219 | $content_data = $this->calculator->analyze_live_content($live_content, $post_id); |
| 163 | 220 | } else { |
| 164 | 221 | $content_data = $this->calculator->analyze_post_content($post_id); |
| 165 | 222 | } |
| 166 | - | |
| 223 | + | |
| 167 | 224 | if (empty($content_data)) { |
| 168 | 225 | return new WP_Error( |
| 169 | 226 | 'post_not_found', |
| 170 | 227 | 'Post not found or has no content', |
| @@ -170,37 +227,94 @@ | ||
| 170 | 227 | 'Post not found or has no content', |
| 171 | 228 | ['status' => 404] |
| 172 | 229 | ); |
| 173 | 230 | } |
| 174 | - | |
| 175 | - // Get post metadata | |
| 231 | + | |
| 232 | + // Get post metadata. Score against the FINAL rendered SEO title/ | |
| 233 | + // description — resolve any variable tags in a custom value, and fall | |
| 234 | + // back to the rendered Global/Bulk pattern when the field is empty, so | |
| 235 | + // the length-based scoring matches what the frontend actually outputs. | |
| 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); | |
| 176 | 258 | $metadata = [ |
| 177 | - 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true) ?: $content_data['title'], | |
| 178 | - 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true) ?: '', | |
| 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'), | |
| 261 | + // Fallback source for the scorer when the request carries no | |
| 262 | + // keyword (e.g. a plain "score this post" call). An explicit | |
| 263 | + // request keyword still wins, so the editor keeps scoring | |
| 264 | + // unsaved keyword edits live. | |
| 265 | + 'focus_keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id), | |
| 179 | 266 | ]; |
| 180 | - | |
| 181 | - // Calculate score | |
| 267 | + | |
| 268 | + // Calculate score. Prefer the multi-keyword list when provided; the | |
| 269 | + // calculator scores each keyword and returns the highest as final. | |
| 270 | + // | |
| 271 | + // Only forward keyword options when the request actually carried | |
| 272 | + // them. The editor always sends its live keyword state (including | |
| 273 | + // empty, when the user clears the field) and that must be honored | |
| 274 | + // verbatim; a request that mentions no keyword at all leaves the | |
| 275 | + // options untouched so the calculator falls back to the keywords | |
| 276 | + // stored on the post. | |
| 277 | + $score_options = []; | |
| 278 | + if ($request->get_param('target_keyword') !== null) { | |
| 279 | + $score_options['target_keyword'] = (string) $target_keyword; | |
| 280 | + } | |
| 281 | + if (is_array($target_keywords)) { | |
| 282 | + $score_options['target_keywords'] = $target_keywords; | |
| 283 | + } | |
| 182 | 284 | $score_data = $this->calculator->calculate_score( |
| 183 | 285 | $content_data, |
| 184 | 286 | $metadata, |
| 185 | - ['target_keyword' => $target_keyword] | |
| 287 | + $score_options | |
| 186 | 288 | ); |
| 187 | - | |
| 289 | + | |
| 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 | + if (null !== $readability_score) { | |
| 296 | + $score_data['readability_score'] = $readability_score; | |
| 297 | + } | |
| 298 | + if (null !== $content_quality) { | |
| 299 | + $score_data['content_quality'] = $content_quality; | |
| 300 | + } | |
| 301 | + | |
| 188 | 302 | // Save score if requested |
| 189 | 303 | if ($save_score) { |
| 190 | 304 | $user_id = get_current_user_id(); |
| 191 | 305 | $score_id = $this->calculator->save_score($post_id, $user_id, $score_data); |
| 192 | - | |
| 306 | + | |
| 193 | 307 | if ($score_id) { |
| 194 | 308 | $score_data['score_id'] = $score_id; |
| 195 | 309 | } |
| 196 | 310 | } |
| 197 | - | |
| 311 | + | |
| 198 | 312 | return new WP_REST_Response([ |
| 199 | 313 | 'success' => true, |
| 200 | 314 | 'data' => $score_data, |
| 201 | 315 | ], 200); |
| 202 | - | |
| 316 | + | |
| 203 | 317 | } catch (\Exception $e) { |
| 204 | 318 | return new WP_Error( |
| 205 | 319 | 'calculation_failed', |
| 206 | 320 | 'Failed to calculate SEO score: ' . $e->getMessage(), |