| 1 |
<?php |
| 2 |
|
| 3 |
class Meow_MWSEO_Rest |
| 4 |
{ |
| 5 |
private $core = null; |
| 6 |
private $namespace = 'seo-engine/v1'; |
| 7 |
|
| 8 |
public function __construct( $core, $admin ) { |
| 9 |
if ( !current_user_can( 'administrator' ) ) { |
| 10 |
return; |
| 11 |
} |
| 12 |
$this->core = $core; |
| 13 |
add_action( 'rest_api_init', array( $this, 'rest_api_init' ) ); |
| 14 |
} |
| 15 |
|
| 16 |
/** |
| 17 |
* STANDARDIZED API RESPONSE HELPERS |
| 18 |
* Ensures consistent response structure and appropriate HTTP codes |
| 19 |
*/ |
| 20 |
|
| 21 |
/** |
| 22 |
* Return successful response |
| 23 |
* @param mixed $data Response data |
| 24 |
* @param string $message Optional success message |
| 25 |
* @param int $status HTTP status code (default 200) |
| 26 |
*/ |
| 27 |
private function success_response( $data = null, $message = '', $status = 200 ) { |
| 28 |
$response = [ 'success' => true ]; |
| 29 |
if ( $data !== null ) { |
| 30 |
$response['data'] = $data; |
| 31 |
} |
| 32 |
if ( !empty( $message ) ) { |
| 33 |
$response['message'] = $message; |
| 34 |
} |
| 35 |
return new WP_REST_Response( $response, $status ); |
| 36 |
} |
| 37 |
|
| 38 |
/** |
| 39 |
* Return error response |
| 40 |
* @param string $message Error message |
| 41 |
* @param string $code Error code |
| 42 |
* @param int $status HTTP status code (default 400) |
| 43 |
* @param mixed $data Additional error data |
| 44 |
*/ |
| 45 |
private function error_response( $message, $code = 'error', $status = 400, $data = null ) { |
| 46 |
$response = [ |
| 47 |
'success' => false, |
| 48 |
'message' => $message, |
| 49 |
'code' => $code |
| 50 |
]; |
| 51 |
if ( $data !== null ) { |
| 52 |
$response['data'] = $data; |
| 53 |
} |
| 54 |
return new WP_REST_Response( $response, $status ); |
| 55 |
} |
| 56 |
|
| 57 |
function rest_api_init() { |
| 58 |
try { |
| 59 |
#region REST LOGS |
| 60 |
register_rest_route( $this->namespace, '/get_logs', array( |
| 61 |
'methods' => 'GET', |
| 62 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 63 |
'callback' => array( $this, 'rest_get_logs' ) |
| 64 |
) ); |
| 65 |
register_rest_route( $this->namespace, '/clear_logs', array( |
| 66 |
'methods' => 'GET', |
| 67 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 68 |
'callback' => array( $this, 'rest_clear_logs' ) |
| 69 |
) ); |
| 70 |
#endregion |
| 71 |
|
| 72 |
#region REST MAINTENANCE |
| 73 |
register_rest_route( $this->namespace, '/reset_data', array( |
| 74 |
'methods' => 'POST', |
| 75 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 76 |
'callback' => array( $this, 'rest_reset_data' ) |
| 77 |
) ); |
| 78 |
#endregion |
| 79 |
|
| 80 |
#region REST Robots.txt |
| 81 |
register_rest_route( $this->namespace, '/get_robots_txt', array( |
| 82 |
'methods' => 'GET', |
| 83 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 84 |
'callback' => array( $this, 'rest_get_robots_txt' ) |
| 85 |
) ); |
| 86 |
register_rest_route( $this->namespace, '/update_robots_txt', array( |
| 87 |
'methods' => 'POST', |
| 88 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 89 |
'callback' => array( $this, 'rest_update_robots_txt' ) |
| 90 |
) ); |
| 91 |
register_rest_route( $this->namespace, '/ai_generate_robots_txt', array( |
| 92 |
'methods' => 'POST', |
| 93 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 94 |
'callback' => array( $this, 'rest_ai_generate_robots_txt' ) |
| 95 |
) ); |
| 96 |
|
| 97 |
#endregion |
| 98 |
|
| 99 |
#region REST LLMs.txt |
| 100 |
register_rest_route( $this->namespace, '/get_llms_txt', array( |
| 101 |
'methods' => 'GET', |
| 102 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 103 |
'callback' => array( $this, 'rest_get_llms_txt' ) |
| 104 |
) ); |
| 105 |
register_rest_route( $this->namespace, '/update_llms_txt', array( |
| 106 |
'methods' => 'POST', |
| 107 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 108 |
'callback' => array( $this, 'rest_update_llms_txt' ) |
| 109 |
) ); |
| 110 |
register_rest_route( $this->namespace, '/ai_generate_llms_txt', array( |
| 111 |
'methods' => 'POST', |
| 112 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 113 |
'callback' => array( $this, 'rest_ai_generate_llms_txt' ) |
| 114 |
) ); |
| 115 |
register_rest_route( $this->namespace, '/delete_llms_txt', array( |
| 116 |
'methods' => 'POST', |
| 117 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 118 |
'callback' => array( $this, 'rest_delete_llms_txt' ) |
| 119 |
) ); |
| 120 |
|
| 121 |
#endregion |
| 122 |
|
| 123 |
#region REST POSTS |
| 124 |
|
| 125 |
register_rest_route( $this->namespace, '/fetch_posts', array( |
| 126 |
'methods' => 'POST', |
| 127 |
'permission_callback' => array( $this->core, 'can_access_features' ), |
| 128 |
'callback' => array( $this, 'rest_fetch_posts' ), |
| 129 |
'args' => array( |
| 130 |
'search' => array( 'required' => false ), |
| 131 |
'offset' => array( 'required' => false, 'default' => 0 ), |
| 132 |
'limit' => array( 'required' => false, 'default' => 10 ), |
| 133 |
) |
| 134 |
) ); |
| 135 |
|
| 136 |
register_rest_route( $this->namespace, '/post_types', array( |
| 137 |
'methods' => 'GET', |
| 138 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 139 |
'callback' => array( $this, 'rest_post_types' ), |
| 140 |
) ); |
| 141 |
register_rest_route( $this->namespace, '/categories', array( |
| 142 |
'methods' => 'GET', |
| 143 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 144 |
'callback' => array( $this, 'rest_categories' ), |
| 145 |
) ); |
| 146 |
register_rest_route( $this->namespace, '/category_seo', array( |
| 147 |
'methods' => 'GET', |
| 148 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 149 |
'callback' => array( $this, 'rest_get_category_seo' ), |
| 150 |
'args' => array( |
| 151 |
'category_id' => array( 'required' => true ), |
| 152 |
) |
| 153 |
) ); |
| 154 |
register_rest_route( $this->namespace, '/category_seo', array( |
| 155 |
'methods' => 'POST', |
| 156 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 157 |
'callback' => array( $this, 'rest_update_category_seo' ), |
| 158 |
) ); |
| 159 |
register_rest_route( $this->namespace, '/ai_generate_category_seo', array( |
| 160 |
'methods' => 'POST', |
| 161 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 162 |
'callback' => array( $this, 'rest_ai_generate_category_seo' ), |
| 163 |
) ); |
| 164 |
register_rest_route( $this->namespace, '/posts', array( |
| 165 |
'methods' => 'POST', |
| 166 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 167 |
'callback' => array( $this, 'rest_posts' ), |
| 168 |
) ); |
| 169 |
register_rest_route( $this->namespace, '/scored_posts', array( |
| 170 |
'methods' => 'GET', |
| 171 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 172 |
'callback' => array( $this, 'rest_scored_posts' ), |
| 173 |
) ); |
| 174 |
register_rest_route( $this->namespace, '/update_post', array( |
| 175 |
'methods' => 'POST', |
| 176 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 177 |
'callback' => array( $this, 'rest_update_post' ) |
| 178 |
) ); |
| 179 |
register_rest_route( $this->namespace, '/aggregate_issues', array( |
| 180 |
'methods' => 'GET', |
| 181 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 182 |
'callback' => array( $this, 'rest_aggregate_issues' ) |
| 183 |
) ); |
| 184 |
register_rest_route( $this->namespace, '/issue_posts', array( |
| 185 |
'methods' => 'GET', |
| 186 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 187 |
'callback' => array( $this, 'rest_issue_posts' ) |
| 188 |
) ); |
| 189 |
register_rest_route( $this->namespace, '/bulk_robots', array( |
| 190 |
'methods' => 'POST', |
| 191 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 192 |
'callback' => array( $this, 'rest_bulk_robots' ) |
| 193 |
) ); |
| 194 |
register_rest_route( $this->namespace, '/post_action', array( |
| 195 |
'methods' => 'POST', |
| 196 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 197 |
'callback' => array( $this, 'rest_post_action' ) |
| 198 |
) ); |
| 199 |
register_rest_route( $this->namespace, '/ignore_seo_issue', array( |
| 200 |
'methods' => 'POST', |
| 201 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 202 |
'callback' => array( $this, 'rest_ignore_seo_issue' ) |
| 203 |
) ); |
| 204 |
register_rest_route( $this->namespace, '/reset_ignored_issues', array( |
| 205 |
'methods' => 'POST', |
| 206 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 207 |
'callback' => array( $this, 'rest_reset_ignored_issues' ) |
| 208 |
) ); |
| 209 |
register_rest_route( $this->namespace, '/one_or_last_post', array( |
| 210 |
'methods' => 'POST', |
| 211 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 212 |
'callback' => array( $this, 'rest_one_or_last_post' ) |
| 213 |
) ); |
| 214 |
|
| 215 |
register_rest_route( $this->namespace, '/get_ai_keywords', array( |
| 216 |
'methods' => 'POST', |
| 217 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 218 |
'callback' => array( $this, 'rest_get_ai_keywords' ) |
| 219 |
) ); |
| 220 |
|
| 221 |
register_rest_route( $this->namespace, '/get_score_factors', array( |
| 222 |
'methods' => 'GET', |
| 223 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 224 |
'callback' => array( $this, 'rest_get_score_factors' ) |
| 225 |
) ); |
| 226 |
|
| 227 |
register_rest_route( $this->namespace, '/get_post_statuses', array( |
| 228 |
'methods' => 'POST', |
| 229 |
'args' => array( |
| 230 |
'type' => array( 'required' => true ), |
| 231 |
), |
| 232 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 233 |
'callback' => array( $this, 'rest_get_post_statuses' ) |
| 234 |
) ); |
| 235 |
|
| 236 |
#endregion |
| 237 |
|
| 238 |
#region REST SETTINGS |
| 239 |
register_rest_route( $this->namespace, '/settings/update', array( |
| 240 |
'methods' => 'POST', |
| 241 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 242 |
'callback' => array( $this, 'rest_settings_update' ) |
| 243 |
) ); |
| 244 |
register_rest_route( $this->namespace, '/settings/list', array( |
| 245 |
'methods' => 'GET', |
| 246 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 247 |
'callback' => array( $this, 'rest_settings_list' ), |
| 248 |
) ); |
| 249 |
register_rest_route( $this->namespace, '/settings/reset', array( |
| 250 |
'methods' => 'POST', |
| 251 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 252 |
'callback' => array( $this, 'rest_settings_reset' ), |
| 253 |
) ); |
| 254 |
|
| 255 |
|
| 256 |
register_rest_route( $this->namespace, '/update_skip_option', array( |
| 257 |
'methods' => 'POST', |
| 258 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 259 |
'callback' => array( $this, 'rest_update_skip_option' ) |
| 260 |
) ); |
| 261 |
|
| 262 |
register_rest_route( $this->namespace, '/import_data', array( |
| 263 |
'methods' => 'POST', |
| 264 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 265 |
'callback' => array( $this, 'rest_import_data' ) |
| 266 |
) ); |
| 267 |
|
| 268 |
register_rest_route( $this->namespace, '/clear_ai_cache', array( |
| 269 |
'methods' => 'POST', |
| 270 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 271 |
'callback' => array( $this, 'rest_clear_ai_cache' ) |
| 272 |
) ); |
| 273 |
|
| 274 |
#endregion |
| 275 |
|
| 276 |
#region REST Performance Insights |
| 277 |
register_rest_route( $this->namespace, '/get_insights', array( |
| 278 |
'methods' => 'POST', |
| 279 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 280 |
'callback' => array( $this, 'rest_get_insights' ) |
| 281 |
) ); |
| 282 |
|
| 283 |
register_rest_route( $this->namespace, '/get_last_insights', array( |
| 284 |
'methods' => 'GET', |
| 285 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 286 |
'callback' => array( $this, 'rest_get_last_insights' ) |
| 287 |
) ); |
| 288 |
|
| 289 |
#endregion |
| 290 |
|
| 291 |
#region REST WooCommerce |
| 292 |
|
| 293 |
register_rest_route( $this->namespace, '/generate_fields', array( |
| 294 |
'methods' => 'POST', |
| 295 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 296 |
'callback' => array( $this, 'rest_generate_fields' ) |
| 297 |
) ); |
| 298 |
|
| 299 |
register_rest_route( $this->namespace, '/apply_woo_fields', array( |
| 300 |
'methods' => 'POST', |
| 301 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 302 |
'callback' => array( $this, 'rest_apply_woo_fields' ) |
| 303 |
) ); |
| 304 |
|
| 305 |
// SEO |
| 306 |
register_rest_route( $this->namespace, '/start_analysis', array( |
| 307 |
'methods' => 'POST', |
| 308 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 309 |
'callback' => array( $this, 'rest_start_analysis' ) |
| 310 |
) ); |
| 311 |
register_rest_route( $this->namespace, '/analysis/baseline', array( |
| 312 |
'methods' => 'POST', |
| 313 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 314 |
'callback' => array( $this, 'rest_analysis_baseline' ) |
| 315 |
) ); |
| 316 |
register_rest_route( $this->namespace, '/analysis/ai-step', array( |
| 317 |
'methods' => 'POST', |
| 318 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 319 |
'callback' => array( $this, 'rest_analysis_ai_step' ) |
| 320 |
) ); |
| 321 |
// NEW UNIFIED ANALYSIS API |
| 322 |
register_rest_route( $this->namespace, '/analysis/init', array( |
| 323 |
'methods' => 'POST', |
| 324 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 325 |
'callback' => array( $this, 'rest_analysis_init' ) |
| 326 |
) ); |
| 327 |
register_rest_route( $this->namespace, '/analysis/tech-step', array( |
| 328 |
'methods' => 'POST', |
| 329 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 330 |
'callback' => array( $this, 'rest_analysis_tech_step' ) |
| 331 |
) ); |
| 332 |
register_rest_route( $this->namespace, '/get_all_ids', array( |
| 333 |
'methods' => 'GET', |
| 334 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 335 |
'callback' => array( $this, 'rest_get_all_ids' ) |
| 336 |
) ); |
| 337 |
|
| 338 |
#endregion |
| 339 |
|
| 340 |
#region REST AI Engine |
| 341 |
register_rest_route( $this->namespace, '/ai_suggestion', array( |
| 342 |
'methods' => 'POST', |
| 343 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 344 |
'callback' => array( $this, 'rest_ai_suggest' ) |
| 345 |
) ); |
| 346 |
register_rest_route( $this->namespace, '/magic_fix_generate', array( |
| 347 |
'methods' => 'POST', |
| 348 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 349 |
'callback' => array( $this, 'rest_magic_fix_generate' ) |
| 350 |
) ); |
| 351 |
register_rest_route( $this->namespace, '/magic_fix_apply', array( |
| 352 |
'methods' => 'POST', |
| 353 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 354 |
'callback' => array( $this, 'rest_magic_fix_apply' ) |
| 355 |
) ); |
| 356 |
|
| 357 |
// Internal Links Magic Fix - Multi-step endpoints |
| 358 |
register_rest_route( $this->namespace, '/magic_fix_internal_links_step1', array( |
| 359 |
'methods' => 'POST', |
| 360 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 361 |
'callback' => array( $this, 'rest_magic_fix_internal_links_step1' ) |
| 362 |
) ); |
| 363 |
register_rest_route( $this->namespace, '/magic_fix_internal_links_step2', array( |
| 364 |
'methods' => 'POST', |
| 365 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 366 |
'callback' => array( $this, 'rest_magic_fix_internal_links_step2' ) |
| 367 |
) ); |
| 368 |
register_rest_route( $this->namespace, '/magic_fix_internal_links_step3', array( |
| 369 |
'methods' => 'POST', |
| 370 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 371 |
'callback' => array( $this, 'rest_magic_fix_internal_links_step3' ) |
| 372 |
) ); |
| 373 |
register_rest_route( $this->namespace, '/magic_fix_internal_links_step4', array( |
| 374 |
'methods' => 'POST', |
| 375 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 376 |
'callback' => array( $this, 'rest_magic_fix_internal_links_step4' ) |
| 377 |
) ); |
| 378 |
register_rest_route( $this->namespace, '/ai_improvement_plan', array( |
| 379 |
'methods' => 'POST', |
| 380 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 381 |
'callback' => array( $this, 'rest_ai_improvement_plan' ) |
| 382 |
) ); |
| 383 |
|
| 384 |
register_rest_route( $this->namespace, '/generate_daily_insight', array( |
| 385 |
'methods' => 'POST', |
| 386 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 387 |
'callback' => array( $this, 'rest_generate_daily_insight' ) |
| 388 |
) ); |
| 389 |
|
| 390 |
register_rest_route( $this->namespace, '/get_daily_insight', array( |
| 391 |
'methods' => 'GET', |
| 392 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 393 |
'callback' => array( $this, 'rest_get_daily_insight' ) |
| 394 |
) ); |
| 395 |
|
| 396 |
#region REST Languages |
| 397 |
register_rest_route( $this->namespace, '/get_languages', array( |
| 398 |
'methods' => 'GET', |
| 399 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 400 |
'callback' => array( $this, 'rest_get_languages' ) |
| 401 |
) ); |
| 402 |
|
| 403 |
#endregion |
| 404 |
|
| 405 |
#region REST Analytics |
| 406 |
register_rest_route( $this->namespace, '/analytics/data', array( |
| 407 |
'methods' => 'POST', |
| 408 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 409 |
'callback' => array( $this, 'rest_get_analytics_data' ) |
| 410 |
) ); |
| 411 |
register_rest_route( $this->namespace, '/analytics/posts_visitor_series', array( |
| 412 |
'methods' => 'POST', |
| 413 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 414 |
'callback' => array( $this, 'rest_get_posts_visitor_series' ) |
| 415 |
) ); |
| 416 |
register_rest_route( $this->namespace, '/analytics/summary', array( |
| 417 |
'methods' => 'POST', |
| 418 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 419 |
'callback' => array( $this, 'rest_get_analytics_summary' ) |
| 420 |
) ); |
| 421 |
register_rest_route( $this->namespace, '/analytics/realtime', array( |
| 422 |
'methods' => 'POST', |
| 423 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 424 |
'callback' => array( $this, 'rest_get_analytics_realtime' ) |
| 425 |
) ); |
| 426 |
register_rest_route( $this->namespace, '/analytics/top_posts', array( |
| 427 |
'methods' => 'POST', |
| 428 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 429 |
'callback' => array( $this, 'rest_get_top_posts' ) |
| 430 |
) ); |
| 431 |
register_rest_route( $this->namespace, '/analytics/ai_agents_summary', array( |
| 432 |
'methods' => 'POST', |
| 433 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 434 |
'callback' => array( $this, 'rest_get_ai_agents_summary' ) |
| 435 |
) ); |
| 436 |
register_rest_route( $this->namespace, '/analytics/ai_agent_details', array( |
| 437 |
'methods' => 'POST', |
| 438 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 439 |
'callback' => array( $this, 'rest_get_ai_agent_details' ) |
| 440 |
) ); |
| 441 |
|
| 442 |
register_rest_route( $this->namespace, '/analytics/ai_agents_by_post', array( |
| 443 |
'methods' => 'POST', |
| 444 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 445 |
'callback' => array( $this, 'rest_get_ai_agents_by_post' ) |
| 446 |
) ); |
| 447 |
|
| 448 |
#endregion |
| 449 |
|
| 450 |
#region REST Google Analytics |
| 451 |
|
| 452 |
register_rest_route( $this->namespace, '/google-analytics/check_auth', array( |
| 453 |
'methods' => 'GET', |
| 454 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 455 |
'callback' => array( $this, 'rest_check_google_analytics_authenticated' ) |
| 456 |
) ); |
| 457 |
|
| 458 |
register_rest_route( $this->namespace, '/google-analytics/get_auth', array( |
| 459 |
'methods' => 'GET', |
| 460 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 461 |
'callback' => array( $this, 'rest_get_google_analytics_auth' ) |
| 462 |
) ); |
| 463 |
register_rest_route( $this->namespace, '/google-analytics/unlink', array( |
| 464 |
'methods' => 'GET', |
| 465 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 466 |
'callback' => array( $this, 'rest_unlink_google_analytics' ) |
| 467 |
) ); |
| 468 |
register_rest_route( $this->namespace, '/google-analytics/data', array( |
| 469 |
'methods' => 'POST', |
| 470 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 471 |
'callback' => array( $this, 'rest_get_google_analytics_data' ) |
| 472 |
) ); |
| 473 |
register_rest_route( $this->namespace, '/google-analytics/summary', array( |
| 474 |
'methods' => 'POST', |
| 475 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 476 |
'callback' => array( $this, 'rest_get_google_analytics_summary' ) |
| 477 |
) ); |
| 478 |
register_rest_route( $this->namespace, '/google-analytics/top_posts', array( |
| 479 |
'methods' => 'POST', |
| 480 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 481 |
'callback' => array( $this, 'rest_get_google_analytics_top_posts' ) |
| 482 |
) ); |
| 483 |
register_rest_route( $this->namespace, '/google-analytics/realtime', array( |
| 484 |
'methods' => 'POST', |
| 485 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 486 |
'callback' => array( $this, 'rest_get_google_analytics_realtime' ) |
| 487 |
) ); |
| 488 |
|
| 489 |
#endregion |
| 490 |
|
| 491 |
#region REST Google Search Console |
| 492 |
|
| 493 |
register_rest_route( $this->namespace, '/google-search-console/check_auth', array( |
| 494 |
'methods' => 'GET', |
| 495 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 496 |
'callback' => array( $this, 'rest_check_gsc_authenticated' ) |
| 497 |
) ); |
| 498 |
register_rest_route( $this->namespace, '/google-search-console/get_auth', array( |
| 499 |
'methods' => 'GET', |
| 500 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 501 |
'callback' => array( $this, 'rest_get_gsc_auth' ) |
| 502 |
) ); |
| 503 |
register_rest_route( $this->namespace, '/google-search-console/unlink', array( |
| 504 |
'methods' => 'GET', |
| 505 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 506 |
'callback' => array( $this, 'rest_unlink_gsc' ) |
| 507 |
) ); |
| 508 |
register_rest_route( $this->namespace, '/google-search-console/list_properties', array( |
| 509 |
'methods' => 'GET', |
| 510 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 511 |
'callback' => array( $this, 'rest_list_gsc_properties' ) |
| 512 |
) ); |
| 513 |
register_rest_route( $this->namespace, '/google-search-console/set_property', array( |
| 514 |
'methods' => 'POST', |
| 515 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 516 |
'callback' => array( $this, 'rest_set_gsc_property' ) |
| 517 |
) ); |
| 518 |
register_rest_route( $this->namespace, '/google-search-console/toggle_tracked', array( |
| 519 |
'methods' => 'POST', |
| 520 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 521 |
'callback' => array( $this, 'rest_toggle_gsc_tracked' ) |
| 522 |
) ); |
| 523 |
register_rest_route( $this->namespace, '/google-search-console/timeseries', array( |
| 524 |
'methods' => 'POST', |
| 525 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 526 |
'callback' => array( $this, 'rest_get_gsc_timeseries' ) |
| 527 |
) ); |
| 528 |
register_rest_route( $this->namespace, '/google-search-console/movers', array( |
| 529 |
'methods' => 'POST', |
| 530 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 531 |
'callback' => array( $this, 'rest_get_gsc_movers' ) |
| 532 |
) ); |
| 533 |
register_rest_route( $this->namespace, '/google-search-console/summary', array( |
| 534 |
'methods' => 'POST', |
| 535 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 536 |
'callback' => array( $this, 'rest_get_gsc_summary' ) |
| 537 |
) ); |
| 538 |
register_rest_route( $this->namespace, '/google-search-console/quick_wins', array( |
| 539 |
'methods' => 'POST', |
| 540 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 541 |
'callback' => array( $this, 'rest_get_gsc_quick_wins' ) |
| 542 |
) ); |
| 543 |
register_rest_route( $this->namespace, '/google-search-console/top_pages', array( |
| 544 |
'methods' => 'POST', |
| 545 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 546 |
'callback' => array( $this, 'rest_get_gsc_top_pages' ) |
| 547 |
) ); |
| 548 |
register_rest_route( $this->namespace, '/google-search-console/top_queries', array( |
| 549 |
'methods' => 'POST', |
| 550 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 551 |
'callback' => array( $this, 'rest_get_gsc_top_queries' ) |
| 552 |
) ); |
| 553 |
register_rest_route( $this->namespace, '/google-search-console/post_metrics_map', array( |
| 554 |
'methods' => 'GET', |
| 555 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 556 |
'callback' => array( $this, 'rest_get_gsc_post_metrics_map' ) |
| 557 |
) ); |
| 558 |
register_rest_route( $this->namespace, '/google-search-console/post_pulse', array( |
| 559 |
'methods' => 'GET', |
| 560 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 561 |
'callback' => array( $this, 'rest_get_gsc_post_pulse' ) |
| 562 |
) ); |
| 563 |
register_rest_route( $this->namespace, '/google-search-console/breakdown', array( |
| 564 |
'methods' => 'POST', |
| 565 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 566 |
'callback' => array( $this, 'rest_get_gsc_breakdown' ) |
| 567 |
) ); |
| 568 |
register_rest_route( $this->namespace, '/google-search-console/pages_with_issues', array( |
| 569 |
'methods' => 'POST', |
| 570 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 571 |
'callback' => array( $this, 'rest_get_gsc_pages_with_issues' ) |
| 572 |
) ); |
| 573 |
|
| 574 |
#endregion |
| 575 |
|
| 576 |
#region REST Sitemap |
| 577 |
register_rest_route( $this->namespace, '/sitemap/generate', array( |
| 578 |
'methods' => 'GET', |
| 579 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 580 |
'callback' => array( $this, 'rest_sitemap_generate' ) |
| 581 |
) ); |
| 582 |
|
| 583 |
#endregion |
| 584 |
|
| 585 |
#region REST Redirects + 404 |
| 586 |
register_rest_route( $this->namespace, '/redirects/list', array( |
| 587 |
'methods' => 'POST', |
| 588 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 589 |
'callback' => array( $this, 'rest_redirects_list' ) |
| 590 |
) ); |
| 591 |
register_rest_route( $this->namespace, '/redirects/save', array( |
| 592 |
'methods' => 'POST', |
| 593 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 594 |
'callback' => array( $this, 'rest_redirects_save' ) |
| 595 |
) ); |
| 596 |
register_rest_route( $this->namespace, '/redirects/delete', array( |
| 597 |
'methods' => 'POST', |
| 598 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 599 |
'callback' => array( $this, 'rest_redirects_delete' ) |
| 600 |
) ); |
| 601 |
register_rest_route( $this->namespace, '/redirects/bulk', array( |
| 602 |
'methods' => 'POST', |
| 603 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 604 |
'callback' => array( $this, 'rest_redirects_bulk' ) |
| 605 |
) ); |
| 606 |
register_rest_route( $this->namespace, '/redirects/404/convert', array( |
| 607 |
'methods' => 'POST', |
| 608 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 609 |
'callback' => array( $this, 'rest_redirects_404_convert' ) |
| 610 |
) ); |
| 611 |
register_rest_route( $this->namespace, '/redirects/404/ignore', array( |
| 612 |
'methods' => 'POST', |
| 613 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 614 |
'callback' => array( $this, 'rest_redirects_404_ignore' ) |
| 615 |
) ); |
| 616 |
register_rest_route( $this->namespace, '/redirects/404/clear', array( |
| 617 |
'methods' => 'POST', |
| 618 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 619 |
'callback' => array( $this, 'rest_redirects_404_clear' ) |
| 620 |
) ); |
| 621 |
#endregion |
| 622 |
|
| 623 |
#region REST AI Visibility |
| 624 |
register_rest_route( $this->namespace, '/ai_visibility/config', array( |
| 625 |
'methods' => 'GET', |
| 626 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 627 |
'callback' => array( $this, 'rest_ai_visibility_config' ) |
| 628 |
) ); |
| 629 |
register_rest_route( $this->namespace, '/ai_visibility/surfaces', array( |
| 630 |
'methods' => 'POST', |
| 631 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 632 |
'callback' => array( $this, 'rest_ai_visibility_save_surfaces' ) |
| 633 |
) ); |
| 634 |
register_rest_route( $this->namespace, '/ai_visibility/brands', array( |
| 635 |
'methods' => 'GET', |
| 636 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 637 |
'callback' => array( $this, 'rest_ai_visibility_brands' ) |
| 638 |
) ); |
| 639 |
register_rest_route( $this->namespace, '/ai_visibility/brand/save', array( |
| 640 |
'methods' => 'POST', |
| 641 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 642 |
'callback' => array( $this, 'rest_ai_visibility_save_brand' ) |
| 643 |
) ); |
| 644 |
register_rest_route( $this->namespace, '/ai_visibility/brand/delete', array( |
| 645 |
'methods' => 'POST', |
| 646 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 647 |
'callback' => array( $this, 'rest_ai_visibility_delete_brand' ) |
| 648 |
) ); |
| 649 |
register_rest_route( $this->namespace, '/ai_visibility/brand/detail', array( |
| 650 |
'methods' => 'POST', |
| 651 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 652 |
'callback' => array( $this, 'rest_ai_visibility_brand_detail' ) |
| 653 |
) ); |
| 654 |
register_rest_route( $this->namespace, '/ai_visibility/generate_queries', array( |
| 655 |
'methods' => 'POST', |
| 656 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 657 |
'callback' => array( $this, 'rest_ai_visibility_generate_queries' ) |
| 658 |
) ); |
| 659 |
register_rest_route( $this->namespace, '/ai_visibility/scan/plan', array( |
| 660 |
'methods' => 'POST', |
| 661 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 662 |
'callback' => array( $this, 'rest_ai_visibility_scan_plan' ) |
| 663 |
) ); |
| 664 |
register_rest_route( $this->namespace, '/ai_visibility/scan/one', array( |
| 665 |
'methods' => 'POST', |
| 666 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 667 |
'callback' => array( $this, 'rest_ai_visibility_scan_one' ) |
| 668 |
) ); |
| 669 |
register_rest_route( $this->namespace, '/ai_visibility/scan/finalize', array( |
| 670 |
'methods' => 'POST', |
| 671 |
'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 672 |
'callback' => array( $this, 'rest_ai_visibility_scan_finalize' ) |
| 673 |
) ); |
| 674 |
#endregion |
| 675 |
|
| 676 |
} |
| 677 |
catch (Exception $e) { |
| 678 |
var_dump($e); |
| 679 |
} |
| 680 |
} |
| 681 |
|
| 682 |
#region General |
| 683 |
function get_param( $request, $key, $default = null ) { |
| 684 |
$params = $request->get_json_params(); |
| 685 |
return ( array_key_exists( $key, $params ) ) ? $params[$key] : $default; |
| 686 |
} |
| 687 |
#endregion |
| 688 |
|
| 689 |
#region Logs |
| 690 |
function rest_get_logs() { |
| 691 |
$logs = $this->core->get_logs(); |
| 692 |
return new WP_REST_Response( [ 'success' => true, 'data' => $logs ], 200 ); |
| 693 |
} |
| 694 |
|
| 695 |
function rest_clear_logs() { |
| 696 |
$this->core->clear_logs(); |
| 697 |
return new WP_REST_Response( [ 'success' => true ], 200 ); |
| 698 |
} |
| 699 |
|
| 700 |
#endregion |
| 701 |
|
| 702 |
#region Update Options |
| 703 |
|
| 704 |
function rest_settings_list() { |
| 705 |
|
| 706 |
// Actually refresh dynamic options (related to Wordpress' settings). |
| 707 |
$this->core->sanitized_options(); |
| 708 |
|
| 709 |
|
| 710 |
return new WP_REST_Response( [ |
| 711 |
'success' => true, |
| 712 |
'options' => $this->core->get_all_options() |
| 713 |
], 200 ); |
| 714 |
} |
| 715 |
|
| 716 |
function rest_settings_update( $request ) { |
| 717 |
try { |
| 718 |
$params = $request->get_json_params(); |
| 719 |
$value = $params['options'] ?? null; |
| 720 |
// An empty payload (e.g. an old {} settings export) would otherwise wipe every option. |
| 721 |
if ( !is_array( $value ) || empty( $value ) ) { |
| 722 |
return new WP_REST_Response([ 'success' => false, 'message' => __( 'No settings were provided.', 'seo-engine' ) ], 400 ); |
| 723 |
} |
| 724 |
$options = $this->core->update_options( $value ); |
| 725 |
$success = !!$options; |
| 726 |
$message = __( $success ? 'OK' : "Could not update options.", 'seo-engine' ); |
| 727 |
return new WP_REST_Response([ 'success' => $success, 'message' => $message, 'options' => $options ], 200 ); |
| 728 |
} |
| 729 |
catch ( Exception $e ) { |
| 730 |
$message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); |
| 731 |
return new WP_REST_Response([ 'success' => false, 'message' => $message ], 500 ); |
| 732 |
} |
| 733 |
} |
| 734 |
|
| 735 |
function rest_settings_reset() { |
| 736 |
try { |
| 737 |
$options = $this->core->reset_options(); |
| 738 |
$success = !!$options; |
| 739 |
$message = __( $success ? 'OK' : "Could not reset options.", 'seo-engine' ); |
| 740 |
return new WP_REST_Response([ 'success' => $success, 'message' => $message, 'options' => $options ], 200 ); |
| 741 |
} |
| 742 |
catch ( Exception $e ) { |
| 743 |
$message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); |
| 744 |
return new WP_REST_Response([ 'success' => false, 'message' => $message ], 500 ); |
| 745 |
} |
| 746 |
} |
| 747 |
|
| 748 |
#endregion |
| 749 |
|
| 750 |
#region Posts |
| 751 |
function rest_post_types() { |
| 752 |
$data = $this->core->make_post_type_list( $this->core->get_post_types() ); |
| 753 |
return new WP_REST_Response( [ |
| 754 |
'success' => true, |
| 755 |
'data' => $data, |
| 756 |
], 200 ); |
| 757 |
} |
| 758 |
|
| 759 |
function rest_categories() { |
| 760 |
$data = $this->core->make_category_list( $this->core->get_categories() ); |
| 761 |
return new WP_REST_Response( [ |
| 762 |
'success' => true, |
| 763 |
'data' => $data, |
| 764 |
], 200 ); |
| 765 |
} |
| 766 |
|
| 767 |
function rest_get_category_seo( $request ) { |
| 768 |
$category_id = $request->get_param( 'category_id' ); |
| 769 |
if ( empty( $category_id ) ) { |
| 770 |
return $this->error_response( 'Category ID is required.', 'missing_category_id' ); |
| 771 |
} |
| 772 |
|
| 773 |
$category = get_term( $category_id ); |
| 774 |
if ( !$category || is_wp_error( $category ) ) { |
| 775 |
return $this->error_response( 'Category not found.', 'category_not_found', 404 ); |
| 776 |
} |
| 777 |
|
| 778 |
$seo_title = get_term_meta( $category_id, '_mwseo_title', true ); |
| 779 |
$seo_description = get_term_meta( $category_id, '_mwseo_description', true ); |
| 780 |
|
| 781 |
return $this->success_response( [ |
| 782 |
'category_id' => $category_id, |
| 783 |
'name' => $category->name, |
| 784 |
'slug' => $category->slug, |
| 785 |
'description' => $category->description, |
| 786 |
'count' => $category->count, |
| 787 |
'seo_title' => $seo_title ?: '', |
| 788 |
'seo_description' => $seo_description ?: '', |
| 789 |
] ); |
| 790 |
} |
| 791 |
|
| 792 |
function rest_update_category_seo( $request ) { |
| 793 |
$params = $request->get_json_params(); |
| 794 |
$category_ids = isset( $params['category_ids'] ) ? array_map( 'intval', $params['category_ids'] ) : []; |
| 795 |
$seo_title = isset( $params['seo_title'] ) ? sanitize_text_field( $params['seo_title'] ) : ''; |
| 796 |
$seo_description = isset( $params['seo_description'] ) ? sanitize_textarea_field( $params['seo_description'] ) : ''; |
| 797 |
|
| 798 |
if ( empty( $category_ids ) ) { |
| 799 |
return $this->error_response( 'Category ID is required.', 'missing_category_id' ); |
| 800 |
} |
| 801 |
|
| 802 |
foreach ( $category_ids as $category_id ) { |
| 803 |
$category = get_term( $category_id ); |
| 804 |
if ( !$category || is_wp_error( $category ) ) { |
| 805 |
return $this->error_response( 'Category not found.', 'category_not_found', 404 ); |
| 806 |
} |
| 807 |
|
| 808 |
update_term_meta( $category_id, '_mwseo_title', $seo_title ); |
| 809 |
update_term_meta( $category_id, '_mwseo_description', $seo_description ); |
| 810 |
} |
| 811 |
|
| 812 |
return $this->success_response( [ |
| 813 |
'category_ids' => $category_ids, |
| 814 |
'seo_title' => $seo_title, |
| 815 |
'seo_description' => $seo_description, |
| 816 |
], __( 'Category SEO settings saved successfully.', 'seo-engine' ) ); |
| 817 |
} |
| 818 |
|
| 819 |
function rest_ai_generate_category_seo( $request ) { |
| 820 |
try { |
| 821 |
$params = $request->get_json_params(); |
| 822 |
$individual = isset( $params['individual'] ) ? boolval( $params['individual'] ) : false; |
| 823 |
$category_ids = isset( $params['category_ids'] ) ? array_map( 'intval', $params['category_ids'] ) : []; |
| 824 |
|
| 825 |
if ( empty( $category_ids ) ) { |
| 826 |
return $this->error_response( 'Category ID is required.', 'missing_category_id' ); |
| 827 |
} |
| 828 |
|
| 829 |
global $mwai; |
| 830 |
if ( is_null( $mwai ) || !isset( $mwai ) ) { |
| 831 |
return $this->error_response( 'AI Engine is required for this feature.', 'missing_ai_engine' ); |
| 832 |
} |
| 833 |
|
| 834 |
$site_name = get_bloginfo( 'name' ); |
| 835 |
$site_description = get_bloginfo( 'description' ); |
| 836 |
|
| 837 |
// Helper function to generate SEO for a single category |
| 838 |
$generate_for_category = function( $category_id ) use ( $mwai, $site_name, $site_description ) { |
| 839 |
$category = get_term( $category_id ); |
| 840 |
if ( !$category || is_wp_error( $category ) ) { |
| 841 |
return [ 'error' => 'Category not found', 'category_id' => $category_id ]; |
| 842 |
} |
| 843 |
|
| 844 |
$category_name = $category->name; |
| 845 |
$category_description = $category->description; |
| 846 |
$category_slug = $category->slug; |
| 847 |
$category_count = $category->count; |
| 848 |
|
| 849 |
// Get some posts from this category for context |
| 850 |
$posts = get_posts( [ |
| 851 |
'category' => $category_id, |
| 852 |
'numberposts' => 5, |
| 853 |
'post_status' => 'publish', |
| 854 |
] ); |
| 855 |
$post_titles = array_map( function( $post ) { |
| 856 |
return $post->post_title; |
| 857 |
}, $posts ); |
| 858 |
|
| 859 |
$instructions = "Generate SEO metadata for a WordPress category page. Provide a compelling SEO title (max 60 characters) and SEO description (max 160 characters) that will appear in search engine results.\n\n"; |
| 860 |
$instructions .= "Website: {$site_name}\n"; |
| 861 |
$instructions .= "Website description: {$site_description}\n\n"; |
| 862 |
$instructions .= "Category name: {$category_name}\n"; |
| 863 |
$instructions .= "Category slug: {$category_slug}\n"; |
| 864 |
$instructions .= "Category description: " . ( $category_description ?: 'No description' ) . "\n"; |
| 865 |
$instructions .= "Number of posts: {$category_count}\n"; |
| 866 |
if ( !empty( $post_titles ) ) { |
| 867 |
$instructions .= "Sample post titles: " . implode( ', ', $post_titles ) . "\n"; |
| 868 |
} |
| 869 |
$instructions .= "\nRespond ONLY with valid JSON in this exact format (no markdown, no code blocks):\n"; |
| 870 |
$instructions .= '{"seo_title": "Your SEO title here", "seo_description": "Your SEO description here"}'; |
| 871 |
|
| 872 |
$ai_response = $mwai->simpleTextQuery( $instructions, [ 'scope' => 'seo' ] ); |
| 873 |
if ( empty( $ai_response ) ) { |
| 874 |
return [ 'error' => 'AI failed to generate content', 'category_id' => $category_id ]; |
| 875 |
} |
| 876 |
|
| 877 |
// Parse JSON response |
| 878 |
$ai_response = trim( $ai_response ); |
| 879 |
// Remove potential markdown code blocks |
| 880 |
$ai_response = preg_replace( '/^```json\s*/i', '', $ai_response ); |
| 881 |
$ai_response = preg_replace( '/```$/', '', $ai_response ); |
| 882 |
$ai_response = trim( $ai_response ); |
| 883 |
|
| 884 |
$parsed = json_decode( $ai_response, true ); |
| 885 |
if ( json_last_error() !== JSON_ERROR_NONE || !isset( $parsed['seo_title'] ) || !isset( $parsed['seo_description'] ) ) { |
| 886 |
return [ 'error' => 'Failed to parse AI response', 'category_id' => $category_id ]; |
| 887 |
} |
| 888 |
|
| 889 |
return [ |
| 890 |
'category_id' => $category_id, |
| 891 |
'category_name' => $category_name, |
| 892 |
'seo_title' => sanitize_text_field( $parsed['seo_title'] ), |
| 893 |
'seo_description' => sanitize_textarea_field( $parsed['seo_description'] ), |
| 894 |
]; |
| 895 |
}; |
| 896 |
|
| 897 |
// If individual mode with multiple categories, generate for each and return all propositions |
| 898 |
if ( $individual && count( $category_ids ) > 1 ) { |
| 899 |
$propositions = []; |
| 900 |
foreach ( $category_ids as $category_id ) { |
| 901 |
$result = $generate_for_category( $category_id ); |
| 902 |
$propositions[] = $result; |
| 903 |
} |
| 904 |
return $this->success_response( [ |
| 905 |
'individual' => true, |
| 906 |
'propositions' => $propositions, |
| 907 |
] ); |
| 908 |
} |
| 909 |
|
| 910 |
// Single category or non-individual mode: generate for first category only |
| 911 |
$category_id = $category_ids[0]; |
| 912 |
$result = $generate_for_category( $category_id ); |
| 913 |
|
| 914 |
if ( isset( $result['error'] ) ) { |
| 915 |
return $this->error_response( $result['error'], 'generation_error' ); |
| 916 |
} |
| 917 |
|
| 918 |
return $this->success_response( [ |
| 919 |
'individual' => false, |
| 920 |
'seo_title' => $result['seo_title'], |
| 921 |
'seo_description' => $result['seo_description'], |
| 922 |
] ); |
| 923 |
|
| 924 |
} catch ( Exception $e ) { |
| 925 |
return $this->error_response( $e->getMessage(), 'exception' ); |
| 926 |
} |
| 927 |
} |
| 928 |
|
| 929 |
function rest_scored_posts( ) { |
| 930 |
$scored_posts = $this->core->get_all_posts_with_seo_score(); |
| 931 |
|
| 932 |
return new WP_REST_Response( [ |
| 933 |
'success' => true, |
| 934 |
'data' => $scored_posts, |
| 935 |
], 200); |
| 936 |
} |
| 937 |
|
| 938 |
function rest_posts($request) { |
| 939 |
// The old implementation hydrated the whole library (full posts + all meta) before |
| 940 |
// slicing one page out: ~110MB for 61 pages, an OOM crash on 128M hosts. It now |
| 941 |
// works in three passes (ids -> targeted meta for counts/sort -> hydrate one page). |
| 942 |
// The raise stays as belt-and-braces for the hosts that allow it; REST requests |
| 943 |
// don't get the admin bump WordPress gives wp-admin. |
| 944 |
wp_raise_memory_limit( 'admin' ); |
| 945 |
$post_type = $this->core->get_option('default_post_type', 'post'); |
| 946 |
|
| 947 |
// When 'any' is selected, use only the enabled post types from settings |
| 948 |
if ( $post_type === 'any' ) { |
| 949 |
$post_type = $this->core->get_option( 'select_post_types', ['post', 'page'] ); |
| 950 |
} |
| 951 |
|
| 952 |
$params = $request->get_json_params(); |
| 953 |
|
| 954 |
// Optional post_type override. The standalone Surgical SEO manager scopes itself with |
| 955 |
// it, and the dashboard sends it too (optimistically, before the shared |
| 956 |
// default_post_type option finishes saving, so the list never queries stale filters). |
| 957 |
if ( !empty( $params['post_type'] ) ) { |
| 958 |
$allowed = (array) $this->core->get_option( 'select_post_types', ['post', 'page'] ); |
| 959 |
if ( $params['post_type'] === 'any' ) { |
| 960 |
$post_type = $allowed; |
| 961 |
} |
| 962 |
else if ( in_array( $params['post_type'], $allowed, true ) ) { |
| 963 |
$post_type = $params['post_type']; |
| 964 |
} |
| 965 |
} |
| 966 |
|
| 967 |
$sort = $params['sort']; |
| 968 |
$page = $params['page']; |
| 969 |
$limit = $params['limit']; |
| 970 |
$offset = ($page - 1) * $limit; |
| 971 |
|
| 972 |
$search = isset($params['search']) ? $params['search'] : null; |
| 973 |
$filter = isset($params['filterBy']) ? $params['filterBy'] : null; |
| 974 |
// Opportunity views are metric-based (Search Console), not status-based: they see |
| 975 |
// every non-skipped post and get filtered after the rows are enriched below. |
| 976 |
$opportunity = in_array( $filter, ['quick_wins', 'low_ctr', 'invisible'], true ) ? $filter : null; |
| 977 |
$show_all = $filter == 'all' || $opportunity !== null; |
| 978 |
$filter = $show_all ? null : $filter; |
| 979 |
|
| 980 |
// Get language filter. An explicit 'all' from the client means every language; only |
| 981 |
// fall back to the saved option when the request doesn't say (otherwise picking |
| 982 |
// "All Languages" could never win over a saved default_language). |
| 983 |
$language = isset($params['language']) && $params['language'] !== '' ? $params['language'] : null; |
| 984 |
if ($language === null) { |
| 985 |
$language = $this->core->get_option('default_language', 'all'); |
| 986 |
} |
| 987 |
|
| 988 |
// Get the status filter |
| 989 |
$status = $this->core->get_option('default_post_status', 'publish'); |
| 990 |
|
| 991 |
// Allow the request to override the saved status (the standalone manager keeps its |
| 992 |
// status in local state instead of writing the shared option). Enables the Trash view. |
| 993 |
if ( !empty( $params['post_status'] ) ) { |
| 994 |
$allowed_status = ['publish', 'future', 'draft', 'pending', 'private', 'trash', 'any']; |
| 995 |
if ( in_array( $params['post_status'], $allowed_status, true ) ) { |
| 996 |
$status = $params['post_status']; |
| 997 |
} |
| 998 |
} |
| 999 |
|
| 1000 |
$total_counts = [ |
| 1001 |
'pending' => 0, |
| 1002 |
'issue' => 0, |
| 1003 |
'skip' => 0, |
| 1004 |
'ok' => 0, |
| 1005 |
'all' => 0, |
| 1006 |
]; |
| 1007 |
|
| 1008 |
// ---- Pass 1: matching IDs only ---- |
| 1009 |
// The library is queried as bare IDs (the DB handles the cheap sorts), statuses and |
| 1010 |
// counts come from targeted meta queries below, and only the current page is hydrated |
| 1011 |
// into full post objects. Loading every matching post with content + full meta cache |
| 1012 |
// sat around 110MB for 61 pages on a 128M host, and guaranteed OOM on big libraries. |
| 1013 |
$accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : null; |
| 1014 |
$sort_dir = isset( $sort['by'] ) && strtolower( (string) $sort['by'] ) === 'asc' ? 'ASC' : 'DESC'; |
| 1015 |
|
| 1016 |
$args = [ |
| 1017 |
'post_type' => $post_type, |
| 1018 |
'posts_per_page' => -1, |
| 1019 |
'post_status' => $status, |
| 1020 |
'fields' => 'ids', |
| 1021 |
'no_found_rows' => true, |
| 1022 |
]; |
| 1023 |
|
| 1024 |
// id and title order straight from the DB; metric sorts run on the id list below. |
| 1025 |
if ( $accessor === 'id' ) { |
| 1026 |
$args['orderby'] = 'ID'; |
| 1027 |
$args['order'] = $sort_dir; |
| 1028 |
} |
| 1029 |
else if ( $accessor === 'title' ) { |
| 1030 |
$args['orderby'] = 'title'; |
| 1031 |
$args['order'] = $sort_dir; |
| 1032 |
} |
| 1033 |
|
| 1034 |
// Restrict to one language (Polylang / Bogo) when one is selected. |
| 1035 |
$args = $this->core->apply_language_filter( $args, $language ); |
| 1036 |
|
| 1037 |
if ($search) { |
| 1038 |
// Search in post title/content AND also in meta fields |
| 1039 |
$args['s'] = $search; |
| 1040 |
$args['_kiss_meta_search'] = $search; // Custom flag for our filter |
| 1041 |
|
| 1042 |
// Add filter to extend search to meta fields |
| 1043 |
add_filter('posts_where', function($where, $wp_query) use ($search) { |
| 1044 |
global $wpdb; |
| 1045 |
if ($wp_query->get('_kiss_meta_search')) { |
| 1046 |
$search_term = '%' . $wpdb->esc_like($search) . '%'; |
| 1047 |
$where = preg_replace( |
| 1048 |
"/\(\s*{$wpdb->posts}.post_title\s+LIKE\s*(\'[^\']+\')\s*\)/", |
| 1049 |
"({$wpdb->posts}.post_title LIKE $1 OR {$wpdb->posts}.ID IN ( |
| 1050 |
SELECT post_id FROM {$wpdb->postmeta} |
| 1051 |
WHERE (meta_key = '_kiss_seo_excerpt' OR meta_key = '_kiss_seo_title') |
| 1052 |
AND meta_value LIKE '" . esc_sql($search_term) . "' |
| 1053 |
))", |
| 1054 |
$where |
| 1055 |
); |
| 1056 |
} |
| 1057 |
return $where; |
| 1058 |
}, 10, 2); |
| 1059 |
} |
| 1060 |
|
| 1061 |
$query = new WP_Query($args); |
| 1062 |
$post_ids = array_map( 'intval', $query->posts ); |
| 1063 |
|
| 1064 |
$excluded_posts = $this->core->get_option( 'sitemap_excluded_post_ids', [] ); |
| 1065 |
$excluded_posts = array_map( 'intval', $excluded_posts ); |
| 1066 |
$excluded_lookup = array_flip( $excluded_posts ); |
| 1067 |
|
| 1068 |
// ---- Pass 2: skip status + overall score for every match (scalar metas only) ---- |
| 1069 |
// The pre-migration keys are read as fallbacks so never-migrated posts still count |
| 1070 |
// right; the real key migration happens below, only for the hydrated page. The |
| 1071 |
// analysis arrays themselves are NOT pulled here: they can be heavy, and only the |
| 1072 |
// scalar overall is needed for counts and the score sort. |
| 1073 |
global $wpdb; |
| 1074 |
$skip_map = []; |
| 1075 |
$overall_map = []; |
| 1076 |
$has_analysis = []; |
| 1077 |
foreach ( array_chunk( $post_ids, 5000 ) as $chunk ) { |
| 1078 |
$in = implode( ',', $chunk ); |
| 1079 |
$rows = $wpdb->get_results( |
| 1080 |
"SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} |
| 1081 |
WHERE post_id IN ($in) |
| 1082 |
AND meta_key IN ('_mwseo_status', '_seo_status', '_mwseo_overall', '_seo_engine_overall')", |
| 1083 |
ARRAY_A |
| 1084 |
); |
| 1085 |
foreach ( (array) $rows as $r ) { |
| 1086 |
$pid = (int) $r['post_id']; |
| 1087 |
if ( $r['meta_key'] === '_mwseo_status' || $r['meta_key'] === '_seo_status' ) { |
| 1088 |
if ( $r['meta_value'] === 'skip' ) $skip_map[ $pid ] = true; |
| 1089 |
} |
| 1090 |
else if ( $r['meta_value'] !== '' && $r['meta_value'] !== null ) { |
| 1091 |
$overall_map[ $pid ] = (int) $r['meta_value']; |
| 1092 |
} |
| 1093 |
} |
| 1094 |
// Which posts have an analysis at all: ids only, never the values. |
| 1095 |
$with_analysis = $wpdb->get_col( |
| 1096 |
"SELECT DISTINCT post_id FROM {$wpdb->postmeta} |
| 1097 |
WHERE post_id IN ($in) AND meta_key IN ('_mwseo_analysis', '_seo_engine_data')" |
| 1098 |
); |
| 1099 |
foreach ( (array) $with_analysis as $pid ) { $has_analysis[ (int) $pid ] = true; } |
| 1100 |
} |
| 1101 |
// Analyses that predate the scalar overall meta: read just those arrays, in small |
| 1102 |
// batches, to recover the score. |
| 1103 |
$missing_overall = array_values( array_diff( array_keys( $has_analysis ), array_keys( $overall_map ) ) ); |
| 1104 |
foreach ( array_chunk( $missing_overall, 500 ) as $chunk ) { |
| 1105 |
$in = implode( ',', $chunk ); |
| 1106 |
$rows = $wpdb->get_results( |
| 1107 |
"SELECT post_id, meta_value FROM {$wpdb->postmeta} |
| 1108 |
WHERE post_id IN ($in) AND meta_key IN ('_mwseo_analysis', '_seo_engine_data')", |
| 1109 |
ARRAY_A |
| 1110 |
); |
| 1111 |
foreach ( (array) $rows as $r ) { |
| 1112 |
$pid = (int) $r['post_id']; |
| 1113 |
if ( isset( $overall_map[ $pid ] ) ) continue; |
| 1114 |
$analysis = maybe_unserialize( $r['meta_value'] ); |
| 1115 |
if ( is_array( $analysis ) && isset( $analysis['overall'] ) ) { |
| 1116 |
$overall_map[ $pid ] = (int) $analysis['overall']; |
| 1117 |
} |
| 1118 |
unset( $analysis ); |
| 1119 |
} |
| 1120 |
} |
| 1121 |
|
| 1122 |
// Work status per post: same rules as the row rendering (skip wins; a score only |
| 1123 |
// counts when an analysis actually exists; everything else is pending). |
| 1124 |
$statuses = []; |
| 1125 |
foreach ( $post_ids as $pid ) { |
| 1126 |
if ( isset( $skip_map[ $pid ] ) ) { |
| 1127 |
$statuses[ $pid ] = 'skip'; |
| 1128 |
} |
| 1129 |
else if ( isset( $overall_map[ $pid ] ) && isset( $has_analysis[ $pid ] ) ) { |
| 1130 |
$statuses[ $pid ] = $overall_map[ $pid ] <= 50 ? 'issue' : 'ok'; |
| 1131 |
} |
| 1132 |
else { |
| 1133 |
$statuses[ $pid ] = 'pending'; |
| 1134 |
} |
| 1135 |
$total_counts[ $statuses[ $pid ] ]++; |
| 1136 |
if ( $statuses[ $pid ] !== 'skip' ) { |
| 1137 |
$total_counts['all']++; |
| 1138 |
} |
| 1139 |
} |
| 1140 |
|
| 1141 |
// ---- Audience metrics (Search Console) ---- |
| 1142 |
// cached_only: the list never blocks on a cold GSC round trip; a live call is only |
| 1143 |
// allowed when the user explicitly picks a metric sort or an opportunity view. |
| 1144 |
$needs_gsc_live = in_array( $accessor, ['impressions', 'clicks', 'position'], true ) || $opportunity !== null; |
| 1145 |
$gsc_map = $this->core->get_gsc_post_metrics_map( $needs_gsc_live ? [] : [ 'cached_only' => true ] ); |
| 1146 |
$has_gsc_data = !empty( $gsc_map ); |
| 1147 |
|
| 1148 |
// One rule set shared by the per-post counting below and the view filtering after the loop. |
| 1149 |
$opportunity_match = function( $view, $g, $no_index ) { |
| 1150 |
if ( $view === 'invisible' ) { |
| 1151 |
// No-index posts are invisible on purpose; don't flag them. |
| 1152 |
return !$no_index && ( empty( $g ) || (int) $g['impressions'] === 0 ); |
| 1153 |
} |
| 1154 |
if ( empty( $g ) ) return false; |
| 1155 |
if ( $view === 'quick_wins' ) { |
| 1156 |
return $g['position'] >= 5 && $g['position'] <= 15 && $g['impressions'] >= 50; |
| 1157 |
} |
| 1158 |
if ( $view === 'low_ctr' ) { |
| 1159 |
return $g['impressions'] >= 100 && $g['position'] <= 12 && $g['ctr_pct'] < 1.0; |
| 1160 |
} |
| 1161 |
return false; |
| 1162 |
}; |
| 1163 |
$opportunity_counts = [ 'quick_wins' => 0, 'low_ctr' => 0, 'invisible' => 0 ]; |
| 1164 |
$quick_wins_potential = 0; |
| 1165 |
|
| 1166 |
// Opportunity view counts cover every non-skipped post, whatever filter is active, |
| 1167 |
// so the chips always show the library-wide picture. Status filters and opportunity |
| 1168 |
// views are applied on the id list in the same pass. |
| 1169 |
$filtered_ids = []; |
| 1170 |
foreach ( $post_ids as $pid ) { |
| 1171 |
$post_status_view = $statuses[ $pid ]; |
| 1172 |
$gsc_row = isset( $gsc_map[ $pid ] ) ? $gsc_map[ $pid ] : null; |
| 1173 |
$no_index = isset( $excluded_lookup[ $pid ] ); |
| 1174 |
|
| 1175 |
if ( $has_gsc_data && $post_status_view !== 'skip' ) { |
| 1176 |
foreach ( [ 'quick_wins', 'low_ctr', 'invisible' ] as $view ) { |
| 1177 |
if ( $opportunity_match( $view, $gsc_row, $no_index ) ) { |
| 1178 |
$opportunity_counts[ $view ]++; |
| 1179 |
if ( $view === 'quick_wins' ) { |
| 1180 |
$quick_wins_potential += $this->core->estimate_gsc_quick_win_potential( $gsc_row['impressions'], $gsc_row['position'] ); |
| 1181 |
} |
| 1182 |
} |
| 1183 |
} |
| 1184 |
} |
| 1185 |
|
| 1186 |
// Apply filter (All excludes skip) |
| 1187 |
if ( $show_all && $post_status_view === 'skip' ) continue; |
| 1188 |
if ( $filter && $post_status_view !== $filter ) continue; |
| 1189 |
if ( $opportunity !== null && !$opportunity_match( $opportunity, $gsc_row, $no_index ) ) continue; |
| 1190 |
|
| 1191 |
$filtered_ids[] = $pid; |
| 1192 |
} |
| 1193 |
|
| 1194 |
// ---- Metric maps for the sorts that need them (batched, filtered ids only) ---- |
| 1195 |
$visitor_totals = []; |
| 1196 |
if ( $accessor === 'visitors' ) { |
| 1197 |
// Analytics must never take the posts list down (the GA4 client throws on API |
| 1198 |
// errors); the sort simply degrades to zeros. |
| 1199 |
try { |
| 1200 |
$visitor_totals = $this->core->get_posts_visitor_totals( $filtered_ids, 30 ); |
| 1201 |
} |
| 1202 |
catch ( Exception $e ) { |
| 1203 |
$visitor_totals = array(); |
| 1204 |
} |
| 1205 |
} |
| 1206 |
$aibots_totals = []; |
| 1207 |
if ( $accessor === 'aibots' ) { |
| 1208 |
$aibots_totals = $this->core->get_ai_bots_totals_by_posts( $filtered_ids, 30 ); |
| 1209 |
} |
| 1210 |
|
| 1211 |
// ---- Sort the id list (id and title were already ordered by the DB) ---- |
| 1212 |
if ( in_array( $accessor, [ 'score', 'impressions', 'clicks', 'position', 'visitors', 'aibots' ], true ) ) { |
| 1213 |
$value_of = function( $pid ) use ( $accessor, $overall_map, $has_analysis, $gsc_map, $visitor_totals, $aibots_totals ) { |
| 1214 |
if ( $accessor === 'score' ) { |
| 1215 |
return ( isset( $overall_map[ $pid ] ) && isset( $has_analysis[ $pid ] ) ) ? $overall_map[ $pid ] : null; |
| 1216 |
} |
| 1217 |
if ( $accessor === 'visitors' ) { |
| 1218 |
return isset( $visitor_totals[ $pid ] ) ? (int) $visitor_totals[ $pid ] : 0; |
| 1219 |
} |
| 1220 |
if ( $accessor === 'aibots' ) { |
| 1221 |
return isset( $aibots_totals[ $pid ] ) ? (int) $aibots_totals[ $pid ] : 0; |
| 1222 |
} |
| 1223 |
$v = isset( $gsc_map[ $pid ][ $accessor ] ) ? (float) $gsc_map[ $pid ][ $accessor ] : null; |
| 1224 |
// A zero position means "no ranking data", not "rank zero". |
| 1225 |
if ( $accessor === 'position' && $v !== null && $v <= 0 ) $v = null; |
| 1226 |
return $v; |
| 1227 |
}; |
| 1228 |
$asc = $sort_dir === 'ASC'; |
| 1229 |
usort( $filtered_ids, function( $a, $b ) use ( $value_of, $asc ) { |
| 1230 |
$va = $value_of( $a ); |
| 1231 |
$vb = $value_of( $b ); |
| 1232 |
// Posts without data always go to the end, whatever the direction. |
| 1233 |
if ( $va === null && $vb === null ) return 0; |
| 1234 |
if ( $va === null ) return 1; |
| 1235 |
if ( $vb === null ) return -1; |
| 1236 |
if ( $va == $vb ) return 0; |
| 1237 |
if ( $asc ) return ( $va < $vb ) ? -1 : 1; |
| 1238 |
return ( $va > $vb ) ? -1 : 1; |
| 1239 |
} ); |
| 1240 |
} |
| 1241 |
|
| 1242 |
// Library-wide opportunity counts for the view chips (null when GSC has no data yet). |
| 1243 |
$total_counts['opportunities'] = $has_gsc_data ? [ |
| 1244 |
'quick_wins' => $opportunity_counts['quick_wins'], |
| 1245 |
'low_ctr' => $opportunity_counts['low_ctr'], |
| 1246 |
'invisible' => $opportunity_counts['invisible'], |
| 1247 |
'quick_wins_potential' => (int) $quick_wins_potential, |
| 1248 |
] : null; |
| 1249 |
|
| 1250 |
// The real result count after every filter (status, search, opportunity views), so the |
| 1251 |
// pagination reflects what the user is actually looking at, not the whole library. |
| 1252 |
$total_counts['results'] = count( $filtered_ids ); |
| 1253 |
|
| 1254 |
$page_ids = array_slice( $filtered_ids, $offset, $limit ); |
| 1255 |
|
| 1256 |
// ---- Pass 3: hydrate the visible page only ---- |
| 1257 |
$data = []; |
| 1258 |
if ( !empty( $page_ids ) ) { |
| 1259 |
_prime_post_caches( $page_ids, false, true ); |
| 1260 |
|
| 1261 |
foreach ( $page_ids as $pid ) { |
| 1262 |
$post = get_post( $pid ); |
| 1263 |
if ( !$post ) continue; |
| 1264 |
|
| 1265 |
// Migrate old meta keys to new ones (runs once per post) |
| 1266 |
$this->core->migrate_post_meta_keys( $post->ID ); |
| 1267 |
|
| 1268 |
$score_data = get_post_meta( $post->ID, '_mwseo_analysis', true ); |
| 1269 |
$has_score = !empty( $score_data ); |
| 1270 |
|
| 1271 |
$gsc_row = isset( $gsc_map[ $post->ID ] ) ? $gsc_map[ $post->ID ] : null; |
| 1272 |
$no_index = isset( $excluded_lookup[ $post->ID ] ); |
| 1273 |
|
| 1274 |
$ignored_tests = get_post_meta($post->ID, '_mwseo_ignored_tests', true); |
| 1275 |
$magic_fixes_applied = get_post_meta($post->ID, '_mwseo_issues_fixed', true); |
| 1276 |
// Get AI agents data for this post |
| 1277 |
$ai_agents_data = $this->core->get_ai_agents_by_post($post->ID, 30); |
| 1278 |
|
| 1279 |
// TODO: meta_key_seo_title and meta_key_seo_excerpt should migrate to _mwseo_title and _mwseo_excerpt |
| 1280 |
$row = [ |
| 1281 |
'id' => $post->ID, |
| 1282 |
'title' => $post->post_title, |
| 1283 |
'excerpt' => $post->post_excerpt, |
| 1284 |
'slug' => $post->post_name, |
| 1285 |
'permalink' => get_permalink($post->ID), |
| 1286 |
'status' => $this->core->get_seo_engine_post_meta($post), |
| 1287 |
'publish_date' => $post->post_date, |
| 1288 |
'featured_image' => get_the_post_thumbnail_url($post->ID, 'medium'), |
| 1289 |
'seo_title' => get_post_meta($post->ID, $this->core->meta_key_seo_title, true), |
| 1290 |
'seo_excerpt' => get_post_meta($post->ID, $this->core->meta_key_seo_excerpt, true), |
| 1291 |
'rendered_title' => $this->core->build_title($post), |
| 1292 |
'rendered_excerpt' => $this->core->build_excerpt($post), |
| 1293 |
'post_type' => $post->post_type, |
| 1294 |
'post_status' => $post->post_status, |
| 1295 |
'author_name' => get_the_author_meta('display_name', $post->post_author), |
| 1296 |
'edit_url' => get_edit_post_link($post->ID, 'raw'), |
| 1297 |
'can_edit' => current_user_can('edit_post', $post->ID), |
| 1298 |
'can_delete' => current_user_can('delete_post', $post->ID), |
| 1299 |
'language' => $this->core->get_post_language_slug( $post->ID ), |
| 1300 |
'score' => $has_score ? $score_data : null, |
| 1301 |
'ignored_tests' => is_array($ignored_tests) ? $ignored_tests : [], |
| 1302 |
'fixed' => is_array($magic_fixes_applied) ? $magic_fixes_applied : [], |
| 1303 |
'ai_agents' => $ai_agents_data, |
| 1304 |
'ai_bots_total' => is_array( $ai_agents_data['grouped'] ?? null ) ? array_sum( $ai_agents_data['grouped'] ) : 0, |
| 1305 |
'gsc' => $gsc_row, |
| 1306 |
'no_index' => $no_index, |
| 1307 |
'canonical_url' => get_post_meta($post->ID, '_mwseo_canonical', true), |
| 1308 |
]; |
| 1309 |
if ( $accessor === 'visitors' ) { |
| 1310 |
$row['visitors_30d'] = isset( $visitor_totals[ $post->ID ] ) ? (int) $visitor_totals[ $post->ID ] : 0; |
| 1311 |
} |
| 1312 |
$data[] = $row; |
| 1313 |
} |
| 1314 |
} |
| 1315 |
|
| 1316 |
return new WP_REST_Response([ |
| 1317 |
'success' => true, |
| 1318 |
'posts' => $data, |
| 1319 |
'total' => $total_counts, |
| 1320 |
], 200); |
| 1321 |
} |
| 1322 |
|
| 1323 |
function rest_get_all_ids( $request = null ) { |
| 1324 |
$post_type = $this->core->get_option( 'default_post_type', 'post' ); |
| 1325 |
$language = $this->core->get_option( 'default_language', 'all' ); |
| 1326 |
|
| 1327 |
// When 'any' is selected, use only the enabled post types from settings |
| 1328 |
if ( $post_type === 'any' ) { |
| 1329 |
$post_type = $this->core->get_option( 'select_post_types', ['post', 'page'] ); |
| 1330 |
} |
| 1331 |
|
| 1332 |
// Optional post_type override. 'any' = every enabled type (used by Bulk SEO's analysis, |
| 1333 |
// so it covers pages too); a single valid type is used by the Surgical SEO manager. |
| 1334 |
$req_type = $request ? $request->get_param( 'post_type' ) : null; |
| 1335 |
if ( $req_type === 'any' ) { |
| 1336 |
$post_type = (array) $this->core->get_option( 'select_post_types', ['post', 'page'] ); |
| 1337 |
} else if ( !empty( $req_type ) ) { |
| 1338 |
$allowed = (array) $this->core->get_option( 'select_post_types', ['post', 'page'] ); |
| 1339 |
if ( in_array( $req_type, $allowed, true ) ) { |
| 1340 |
$post_type = $req_type; |
| 1341 |
} |
| 1342 |
} |
| 1343 |
|
| 1344 |
// Optional language override, used by Bulk SEO so its scan can be scoped without |
| 1345 |
// touching the saved default_language option. |
| 1346 |
$req_lang = $request ? $request->get_param( 'language' ) : null; |
| 1347 |
if ( !empty( $req_lang ) ) { |
| 1348 |
$language = $req_lang; |
| 1349 |
} |
| 1350 |
|
| 1351 |
$args = [ |
| 1352 |
'post_type' => $post_type, |
| 1353 |
'posts_per_page' => -1, // Get all posts |
| 1354 |
'fields' => 'ids', |
| 1355 |
// Cover the same statuses the analyzer counts (not just published), so a Bulk |
| 1356 |
// Analysis leaves nothing unscanned. |
| 1357 |
'post_status' => ['publish', 'future', 'draft', 'pending', 'private'], |
| 1358 |
]; |
| 1359 |
|
| 1360 |
// Restrict to one language (Polylang / Bogo) when one is selected. |
| 1361 |
$args = $this->core->apply_language_filter( $args, $language ); |
| 1362 |
|
| 1363 |
$query = new WP_Query($args); |
| 1364 |
$posts = $query->posts; // Get all posts |
| 1365 |
|
| 1366 |
wp_reset_postdata(); |
| 1367 |
|
| 1368 |
return new WP_REST_Response([ |
| 1369 |
'success' => true, |
| 1370 |
'ids' => $posts, |
| 1371 |
], 200); |
| 1372 |
} |
| 1373 |
|
| 1374 |
function rest_one_or_last_post( $request ) { |
| 1375 |
|
| 1376 |
$post_id = $this->get_param( $request, 'id', $this->core->get_option( 'preview_post_id', null ) ); |
| 1377 |
$this->core->update_option( 'preview_post_id', $post_id ); |
| 1378 |
|
| 1379 |
$has_featured_image = $this->get_param( $request, 'has_featured_image', false ); |
| 1380 |
|
| 1381 |
$post = get_post( $post_id ); |
| 1382 |
|
| 1383 |
if ( !$post ) { |
| 1384 |
|
| 1385 |
$post_search = [ |
| 1386 |
'post_type' => 'post', |
| 1387 |
'posts_per_page' => 1, |
| 1388 |
'orderby' => 'date', |
| 1389 |
'order' => 'DESC', |
| 1390 |
]; |
| 1391 |
|
| 1392 |
if ( $has_featured_image ) { |
| 1393 |
$post_search['meta_query'] = [ |
| 1394 |
[ |
| 1395 |
'key' => '_thumbnail_id', |
| 1396 |
], |
| 1397 |
]; |
| 1398 |
} |
| 1399 |
|
| 1400 |
$post = get_posts( $post_search )[0]; |
| 1401 |
|
| 1402 |
if ( !$post && $has_featured_image ) { |
| 1403 |
unset( $post_search['meta_query'] ); |
| 1404 |
$post = get_posts( $post_search )[0]; |
| 1405 |
} |
| 1406 |
|
| 1407 |
} |
| 1408 |
|
| 1409 |
$featured_image = get_the_post_thumbnail_url( $post->ID, 'full' ); |
| 1410 |
$featured_image = $featured_image ? $featured_image : "https://placehold.co/1200x630?text=No+Featured+Image"; |
| 1411 |
$featured_image = apply_filters( 'mwseo_sns_featured_image', $featured_image, $post->ID ); |
| 1412 |
|
| 1413 |
|
| 1414 |
|
| 1415 |
return new WP_REST_Response( [ |
| 1416 |
'success' => true, |
| 1417 |
'data' => [ |
| 1418 |
|
| 1419 |
'title' => $post->post_title, |
| 1420 |
'excerpt' => $post->post_excerpt, |
| 1421 |
'featured' => $featured_image, |
| 1422 |
'domain' => preg_replace( '/^https?:\/\/(www\.)?/', '', get_site_url() ), |
| 1423 |
|
| 1424 |
], |
| 1425 |
], 200 ); |
| 1426 |
} |
| 1427 |
|
| 1428 |
function rest_start_analysis( $request ) { |
| 1429 |
$params = $request->get_json_params(); |
| 1430 |
$post_ids = $params['ids'] ?? [$params['id']]; |
| 1431 |
$analysis_type = $params['type'] ?? 'full'; // 'quick' or 'full' |
| 1432 |
|
| 1433 |
$results = []; |
| 1434 |
$updated_posts = []; |
| 1435 |
$success = true; |
| 1436 |
$message = 'OK'; |
| 1437 |
|
| 1438 |
foreach ($post_ids as $post_id) { |
| 1439 |
$post = get_post( $post_id ); |
| 1440 |
if ( !$post ) { |
| 1441 |
$success = false; |
| 1442 |
$message = 'Post not found for ID: ' . $post_id; |
| 1443 |
break; |
| 1444 |
} |
| 1445 |
|
| 1446 |
$score = $this->core->calculate_seo_score( $post, $analysis_type ); |
| 1447 |
$results[$post_id] = $score; |
| 1448 |
|
| 1449 |
// Build the complete post object (same structure as rest_posts) |
| 1450 |
$score_data = get_post_meta($post_id, '_mwseo_analysis', true); |
| 1451 |
$has_score = !empty($score_data); |
| 1452 |
$skip_status = get_post_meta($post_id, '_mwseo_status', true); |
| 1453 |
$ignored_tests = get_post_meta($post_id, '_mwseo_ignored_tests', true); |
| 1454 |
$magic_fixes_applied = get_post_meta($post_id, '_mwseo_issues_fixed', true); |
| 1455 |
$ai_agents_data = $this->core->get_ai_agents_by_post($post_id, 30); |
| 1456 |
|
| 1457 |
$updated_posts[$post_id] = [ |
| 1458 |
'id' => $post_id, |
| 1459 |
'title' => $post->post_title, |
| 1460 |
'excerpt' => $post->post_excerpt, |
| 1461 |
'slug' => $post->post_name, |
| 1462 |
'permalink' => get_permalink($post_id), |
| 1463 |
'status' => $this->core->get_seo_engine_post_meta($post), |
| 1464 |
'publish_date' => $post->post_date, |
| 1465 |
'featured_image' => get_the_post_thumbnail_url($post_id, 'medium'), |
| 1466 |
'seo_title' => get_post_meta($post_id, $this->core->meta_key_seo_title, true), |
| 1467 |
'seo_excerpt' => get_post_meta($post_id, $this->core->meta_key_seo_excerpt, true), |
| 1468 |
'rendered_title' => $this->core->build_title($post), |
| 1469 |
'rendered_excerpt' => $this->core->build_excerpt($post), |
| 1470 |
'post_type' => $post->post_type, |
| 1471 |
'language' => $this->core->get_post_language_slug( $post_id ), |
| 1472 |
'score' => $has_score ? $score_data : null, |
| 1473 |
'ignored_tests' => is_array($ignored_tests) ? $ignored_tests : [], |
| 1474 |
'fixed' => is_array($magic_fixes_applied) ? $magic_fixes_applied : [], |
| 1475 |
'ai_agents' => $ai_agents_data, |
| 1476 |
]; |
| 1477 |
} |
| 1478 |
|
| 1479 |
return new WP_REST_Response( [ |
| 1480 |
'success' => $success, |
| 1481 |
'message' => $message, |
| 1482 |
'data' => [ |
| 1483 |
'results' => $results, |
| 1484 |
'updated_posts' => $updated_posts, |
| 1485 |
] |
| 1486 |
], $success ? 200 : 404 ); |
| 1487 |
} |
| 1488 |
|
| 1489 |
function rest_update_post( $request ) { |
| 1490 |
$params = $request->get_json_params(); |
| 1491 |
// Validation |
| 1492 |
if ( !isset( $params['id'] ) || !isset( $params['title'] ) || !isset( $params['excerpt'] ) || !isset( $params['slug'] )) { |
| 1493 |
return new WP_REST_Response( [ |
| 1494 |
'success' => false, |
| 1495 |
'message' => 'Missing some parameters. Required: id, title, excerpt and slug.', |
| 1496 |
], 200 ); |
| 1497 |
} |
| 1498 |
|
| 1499 |
// Update the post. |
| 1500 |
$post_id = $params['id']; |
| 1501 |
$post = [ |
| 1502 |
'ID' => $post_id, |
| 1503 |
'post_title' => $params['title'], |
| 1504 |
'post_excerpt' => $params['excerpt'], |
| 1505 |
'post_name' => $params['slug'], |
| 1506 |
]; |
| 1507 |
$result = wp_update_post( $post ); |
| 1508 |
if ( $result === 0 ) { |
| 1509 |
return new WP_REST_Response( [ |
| 1510 |
'success' => false, |
| 1511 |
'message' => 'Failed to update the post.', |
| 1512 |
], 200 ); |
| 1513 |
} |
| 1514 |
|
| 1515 |
// Update the AI keywords. |
| 1516 |
$ai_keywords = $params['ai_keywords'] == '' ? null : explode(' ', $params['ai_keywords'] ); |
| 1517 |
$this->update_or_delete_post_meta( $post_id, '_mwseo_keywords', $ai_keywords ); |
| 1518 |
|
| 1519 |
|
| 1520 |
// Update the post metadata. |
| 1521 |
// TODO: meta_key_seo_title and meta_key_seo_excerpt should migrate to _mwseo_title and _mwseo_excerpt |
| 1522 |
$seo_title = $params['seo_title'] ?? null; |
| 1523 |
$seo_excerpt = $params['seo_excerpt'] ?? null; |
| 1524 |
if ( $seo_title !== null ) { |
| 1525 |
$this->update_or_delete_post_meta( $post_id, $this->core->meta_key_seo_title, $seo_title ); |
| 1526 |
} |
| 1527 |
if ( $seo_excerpt !== null ) { |
| 1528 |
$this->update_or_delete_post_meta( $post_id, $this->core->meta_key_seo_excerpt, $seo_excerpt ); |
| 1529 |
} |
| 1530 |
|
| 1531 |
// Update the no index |
| 1532 |
$no_index = isset( $params['no_index'] ) ? boolVal( $params['no_index'] ) : null; |
| 1533 |
if ( $no_index !== null ) { |
| 1534 |
|
| 1535 |
$excluded_posts = $this->core->get_option( 'sitemap_excluded_post_ids', [] ); |
| 1536 |
$excluded_posts = array_map( 'intval', $excluded_posts ); |
| 1537 |
|
| 1538 |
if ( $no_index ) { |
| 1539 |
if ( !in_array( $post_id, $excluded_posts ) ) { |
| 1540 |
$excluded_posts[] = $post_id; |
| 1541 |
} |
| 1542 |
} else { |
| 1543 |
if ( in_array( $post_id, $excluded_posts ) ) { |
| 1544 |
$excluded_posts = array_diff( $excluded_posts, [ $post_id ] ); |
| 1545 |
} |
| 1546 |
} |
| 1547 |
|
| 1548 |
$this->core->update_option( 'sitemap_excluded_post_ids', $excluded_posts ); |
| 1549 |
} |
| 1550 |
|
| 1551 |
// Update the canonical URL |
| 1552 |
$canonical_url = $params['canonical_url'] ?? null; |
| 1553 |
if ( $canonical_url !== null ) { |
| 1554 |
$this->update_or_delete_post_meta( $post_id, '_mwseo_canonical', $canonical_url ); |
| 1555 |
} |
| 1556 |
|
| 1557 |
|
| 1558 |
return new WP_REST_Response( [ |
| 1559 |
'success' => true, |
| 1560 |
], 200 ); |
| 1561 |
} |
| 1562 |
|
| 1563 |
/** |
| 1564 |
* Site-wide issue aggregation for the Bulk SEO experience. Delegates to the shared |
| 1565 |
* Score::aggregate_issues(). Computed fresh each call (a single indexed query, admin-only |
| 1566 |
* and on-demand) so the view reflects fixes/scans immediately; React Query caches client-side. |
| 1567 |
*/ |
| 1568 |
function rest_aggregate_issues( $request ) { |
| 1569 |
global $mwseo_score; |
| 1570 |
if ( !$mwseo_score ) { |
| 1571 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 1572 |
} |
| 1573 |
|
| 1574 |
$args = []; |
| 1575 |
$post_type = $request->get_param( 'post_type' ); |
| 1576 |
$status = $request->get_param( 'status' ); |
| 1577 |
$language = $request->get_param( 'language' ); |
| 1578 |
if ( !empty( $post_type ) ) $args['post_type'] = is_array( $post_type ) ? $post_type : explode( ',', $post_type ); |
| 1579 |
if ( !empty( $status ) ) $args['status'] = $status; |
| 1580 |
if ( !empty( $language ) ) $args['language'] = $language; |
| 1581 |
|
| 1582 |
return $this->success_response( $mwseo_score->aggregate_issues( $args ) ); |
| 1583 |
} |
| 1584 |
|
| 1585 |
/** |
| 1586 |
* Returns the analyzed posts failing a specific test (the Bulk SEO work-list). |
| 1587 |
*/ |
| 1588 |
function rest_issue_posts( $request ) { |
| 1589 |
global $mwseo_score; |
| 1590 |
if ( !$mwseo_score ) { |
| 1591 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 1592 |
} |
| 1593 |
$test = $request->get_param( 'test' ); |
| 1594 |
if ( empty( $test ) ) { |
| 1595 |
return $this->error_response( 'Missing test parameter', 'no_test' ); |
| 1596 |
} |
| 1597 |
$args = []; |
| 1598 |
$post_type = $request->get_param( 'post_type' ); |
| 1599 |
$status = $request->get_param( 'status' ); |
| 1600 |
$language = $request->get_param( 'language' ); |
| 1601 |
$limit = $request->get_param( 'limit' ); |
| 1602 |
if ( !empty( $post_type ) ) $args['post_type'] = is_array( $post_type ) ? $post_type : explode( ',', $post_type ); |
| 1603 |
if ( !empty( $status ) ) $args['status'] = $status; |
| 1604 |
if ( !empty( $language ) ) $args['language'] = $language; |
| 1605 |
if ( !empty( $limit ) ) $args['limit'] = (int) $limit; |
| 1606 |
|
| 1607 |
$data = $mwseo_score->get_posts_failing_test( sanitize_key( $test ), $args ); |
| 1608 |
return $this->success_response( $data ); |
| 1609 |
} |
| 1610 |
|
| 1611 |
/** |
| 1612 |
* Bulk-set the robots meta on a set of posts (the free, non-AI "hide thin pages from |
| 1613 |
* search" action). Writes the same _mwseo_robots key the scorer reads. |
| 1614 |
*/ |
| 1615 |
function rest_bulk_robots( $request ) { |
| 1616 |
$params = $request->get_json_params(); |
| 1617 |
$post_ids = ( isset( $params['post_ids'] ) && is_array( $params['post_ids'] ) ) ? array_map( 'intval', $params['post_ids'] ) : []; |
| 1618 |
$value = isset( $params['value'] ) ? (string) $params['value'] : 'noindex,follow'; |
| 1619 |
|
| 1620 |
if ( empty( $post_ids ) ) { |
| 1621 |
return $this->error_response( 'No posts provided', 'no_posts' ); |
| 1622 |
} |
| 1623 |
|
| 1624 |
// Whitelist robots directives to avoid storing arbitrary values. |
| 1625 |
$allowed = ['noindex', 'index', 'nofollow', 'follow', 'noarchive', 'nosnippet']; |
| 1626 |
$parts = array_filter( array_map( 'trim', explode( ',', $value ) ) ); |
| 1627 |
foreach ( $parts as $p ) { |
| 1628 |
if ( !in_array( $p, $allowed, true ) ) { |
| 1629 |
return $this->error_response( 'Invalid robots value', 'bad_value' ); |
| 1630 |
} |
| 1631 |
} |
| 1632 |
$value = implode( ',', $parts ); |
| 1633 |
|
| 1634 |
$updated = 0; |
| 1635 |
foreach ( $post_ids as $pid ) { |
| 1636 |
if ( !current_user_can( 'edit_post', $pid ) ) continue; |
| 1637 |
update_post_meta( $pid, '_mwseo_robots', $value ); |
| 1638 |
$updated++; |
| 1639 |
} |
| 1640 |
|
| 1641 |
return $this->success_response( [ 'updated' => $updated ] ); |
| 1642 |
} |
| 1643 |
|
| 1644 |
/** |
| 1645 |
* Post management actions for the standalone Surgical SEO manager: trash, restore, |
| 1646 |
* permanent delete, and quick status change. Per-post capability checks for defense in depth. |
| 1647 |
*/ |
| 1648 |
function rest_post_action( $request ) { |
| 1649 |
$params = $request->get_json_params(); |
| 1650 |
$post_id = isset( $params['post_id'] ) ? intval( $params['post_id'] ) : 0; |
| 1651 |
$action = isset( $params['action'] ) ? sanitize_key( $params['action'] ) : ''; |
| 1652 |
|
| 1653 |
$post = get_post( $post_id ); |
| 1654 |
if ( !$post ) { |
| 1655 |
return $this->error_response( 'Post not found', 'not_found', 404 ); |
| 1656 |
} |
| 1657 |
|
| 1658 |
$cap_map = [ |
| 1659 |
'trash' => 'delete_post', |
| 1660 |
'restore' => 'delete_post', |
| 1661 |
'delete' => 'delete_post', |
| 1662 |
'set_status' => 'edit_post', |
| 1663 |
]; |
| 1664 |
if ( !isset( $cap_map[ $action ] ) ) { |
| 1665 |
return $this->error_response( 'Unknown action', 'bad_action' ); |
| 1666 |
} |
| 1667 |
if ( !current_user_can( $cap_map[ $action ], $post_id ) ) { |
| 1668 |
return $this->error_response( 'You are not allowed to do this.', 'forbidden', 403 ); |
| 1669 |
} |
| 1670 |
|
| 1671 |
switch ( $action ) { |
| 1672 |
case 'trash': |
| 1673 |
$ok = (bool) wp_trash_post( $post_id ); |
| 1674 |
break; |
| 1675 |
case 'restore': |
| 1676 |
$ok = (bool) wp_untrash_post( $post_id ); |
| 1677 |
break; |
| 1678 |
case 'delete': |
| 1679 |
$ok = (bool) wp_delete_post( $post_id, true ); |
| 1680 |
break; |
| 1681 |
case 'set_status': |
| 1682 |
$status = ( isset( $params['status'] ) && in_array( $params['status'], ['publish', 'draft', 'pending', 'private'], true ) ) |
| 1683 |
? $params['status'] : null; |
| 1684 |
if ( !$status ) { |
| 1685 |
return $this->error_response( 'Invalid status', 'bad_status' ); |
| 1686 |
} |
| 1687 |
$res = wp_update_post( [ 'ID' => $post_id, 'post_status' => $status ], true ); |
| 1688 |
$ok = !is_wp_error( $res ); |
| 1689 |
break; |
| 1690 |
default: |
| 1691 |
$ok = false; |
| 1692 |
} |
| 1693 |
|
| 1694 |
if ( !$ok ) { |
| 1695 |
return $this->error_response( 'Action failed', 'action_failed', 500 ); |
| 1696 |
} |
| 1697 |
return $this->success_response( [ 'post_id' => $post_id, 'action' => $action ] ); |
| 1698 |
} |
| 1699 |
|
| 1700 |
function rest_ignore_seo_issue( $request ) { |
| 1701 |
$params = $request->get_json_params(); |
| 1702 |
|
| 1703 |
// Validation |
| 1704 |
if ( !isset( $params['id'] ) || !isset( $params['test'] ) ) { |
| 1705 |
return new WP_REST_Response( [ |
| 1706 |
'success' => false, |
| 1707 |
'message' => 'Missing parameters. Required: id, test.', |
| 1708 |
], 400 ); |
| 1709 |
} |
| 1710 |
|
| 1711 |
$post_id = $params['id']; |
| 1712 |
$test_name = $params['test']; |
| 1713 |
|
| 1714 |
// Get current ignored tests |
| 1715 |
$ignored_tests = get_post_meta( $post_id, '_mwseo_ignored_tests', true ); |
| 1716 |
if ( !is_array( $ignored_tests ) ) { |
| 1717 |
$ignored_tests = []; |
| 1718 |
} |
| 1719 |
|
| 1720 |
// Add test to ignored list if not already there |
| 1721 |
if ( !in_array( $test_name, $ignored_tests ) ) { |
| 1722 |
$ignored_tests[] = $test_name; |
| 1723 |
update_post_meta( $post_id, '_mwseo_ignored_tests', $ignored_tests ); |
| 1724 |
} |
| 1725 |
|
| 1726 |
// Recalculate score with ignored tests |
| 1727 |
$post = get_post( $post_id ); |
| 1728 |
if ( $post ) { |
| 1729 |
// Get current analysis type from last run (default to 'quick' to preserve AI data) |
| 1730 |
$score = $this->core->calculate_seo_score( $post, 'quick' ); |
| 1731 |
} |
| 1732 |
|
| 1733 |
return new WP_REST_Response( [ |
| 1734 |
'success' => true, |
| 1735 |
'message' => 'Issue ignored successfully.', |
| 1736 |
'data' => [ |
| 1737 |
'ignored_tests' => $ignored_tests, |
| 1738 |
'new_score' => $score ?? null |
| 1739 |
] |
| 1740 |
], 200 ); |
| 1741 |
} |
| 1742 |
|
| 1743 |
function rest_reset_ignored_issues( $request ) { |
| 1744 |
$params = $request->get_json_params(); |
| 1745 |
|
| 1746 |
// Validation |
| 1747 |
if ( !isset( $params['id'] ) ) { |
| 1748 |
return new WP_REST_Response( [ |
| 1749 |
'success' => false, |
| 1750 |
'message' => 'Missing parameter. Required: id.', |
| 1751 |
], 400 ); |
| 1752 |
} |
| 1753 |
|
| 1754 |
$post_id = $params['id']; |
| 1755 |
|
| 1756 |
// Delete ignored tests meta |
| 1757 |
delete_post_meta( $post_id, '_mwseo_ignored_tests' ); |
| 1758 |
|
| 1759 |
// Recalculate score without ignored tests |
| 1760 |
$post = get_post( $post_id ); |
| 1761 |
if ( $post ) { |
| 1762 |
$score = $this->core->calculate_seo_score( $post, 'quick' ); |
| 1763 |
} |
| 1764 |
|
| 1765 |
return new WP_REST_Response( [ |
| 1766 |
'success' => true, |
| 1767 |
'message' => 'Ignored issues reset successfully.', |
| 1768 |
'data' => [ |
| 1769 |
'new_score' => $score ?? null |
| 1770 |
] |
| 1771 |
], 200 ); |
| 1772 |
} |
| 1773 |
|
| 1774 |
function rest_update_skip_option( $request ) { |
| 1775 |
$params = $request->get_json_params(); |
| 1776 |
// Validation |
| 1777 |
if ( !isset( $params['id'] ) || !isset( $params['skip'] )) { |
| 1778 |
return new WP_REST_Response( [ |
| 1779 |
'success' => false, |
| 1780 |
'message' => 'Missing some parameters. Required: id and skip.', |
| 1781 |
], 200 ); |
| 1782 |
} |
| 1783 |
|
| 1784 |
$post_id = $params['id']; |
| 1785 |
$skip = boolVal( $params['skip'] ); |
| 1786 |
|
| 1787 |
$this->update_or_delete_post_meta( $post_id, '_mwseo_status', $skip ? 'skip' : 'pending' ); |
| 1788 |
$this->update_or_delete_post_meta( $post_id, '_mwseo_message', $skip ? 'This post has been skipped. No SEO score.' : null ); |
| 1789 |
$this->update_or_delete_post_meta( $post_id, '_mwseo_score', null ); |
| 1790 |
|
| 1791 |
return new WP_REST_Response( [ |
| 1792 |
'success' => true, |
| 1793 |
], 200 ); |
| 1794 |
} |
| 1795 |
|
| 1796 |
function update_or_delete_post_meta( $post_id, $meta_key, $meta_value ) { |
| 1797 |
//add post meta if non-existent |
| 1798 |
if ( !get_post_meta( $post_id, $meta_key ) ) { |
| 1799 |
add_post_meta( $post_id, $meta_key, $meta_value ); |
| 1800 |
return; |
| 1801 |
} |
| 1802 |
|
| 1803 |
if ( $meta_value ) { |
| 1804 |
update_post_meta( $post_id, $meta_key, $meta_value ); |
| 1805 |
} |
| 1806 |
else { |
| 1807 |
delete_post_meta( $post_id, $meta_key, $meta_value ); |
| 1808 |
} |
| 1809 |
} |
| 1810 |
|
| 1811 |
function rest_fetch_posts( $request ) { |
| 1812 |
try { |
| 1813 |
$params = $request->get_json_params(); |
| 1814 |
$search = isset($params['search']) ? $params['search'] : ''; |
| 1815 |
$offset = isset($params['offset']) ? intval($params['offset']) : 0; |
| 1816 |
$limit = isset($params['limit']) ? intval($params['limit']) : 10; |
| 1817 |
|
| 1818 |
global $wpdb; |
| 1819 |
$searchPlaceholder = $search ? '%' . $search . '%' : ''; |
| 1820 |
$where_search_clause = $search ? $wpdb->prepare( |
| 1821 |
"AND ( p.post_title LIKE %s OR p.post_content LIKE %s OR p.post_name LIKE %s ) ", |
| 1822 |
$searchPlaceholder, |
| 1823 |
$searchPlaceholder, |
| 1824 |
$searchPlaceholder |
| 1825 |
) : ''; |
| 1826 |
|
| 1827 |
$posts = $wpdb->get_results( |
| 1828 |
$wpdb->prepare( |
| 1829 |
"SELECT p.ID, p.post_title, p.post_date, p.post_status, u.display_name as author |
| 1830 |
FROM $wpdb->posts p |
| 1831 |
LEFT JOIN $wpdb->users u ON p.post_author = u.ID |
| 1832 |
WHERE p.post_type = 'post' |
| 1833 |
AND p.post_status IN ('publish', 'draft', 'private') |
| 1834 |
$where_search_clause |
| 1835 |
ORDER BY p.post_date DESC |
| 1836 |
LIMIT %d, %d", |
| 1837 |
$offset, |
| 1838 |
$limit |
| 1839 |
), |
| 1840 |
OBJECT |
| 1841 |
); |
| 1842 |
|
| 1843 |
$posts_count = (int)$wpdb->get_var( |
| 1844 |
"SELECT COUNT(*) |
| 1845 |
FROM $wpdb->posts p |
| 1846 |
WHERE p.post_type = 'post' |
| 1847 |
AND p.post_status IN ('publish', 'draft', 'private') |
| 1848 |
$where_search_clause" |
| 1849 |
); |
| 1850 |
|
| 1851 |
$data = array_map(function($post) { |
| 1852 |
return [ |
| 1853 |
'id' => $post->ID, |
| 1854 |
'title' => $post->post_title, |
| 1855 |
'date' => $post->post_date, |
| 1856 |
'author' => $post->author, |
| 1857 |
'status' => $post->post_status |
| 1858 |
]; |
| 1859 |
}, $posts); |
| 1860 |
|
| 1861 |
return new WP_REST_Response([ |
| 1862 |
'success' => true, |
| 1863 |
'data' => $data, |
| 1864 |
'total' => $posts_count |
| 1865 |
], 200); |
| 1866 |
} catch (Exception $e) { |
| 1867 |
return new WP_REST_Response(['success' => false, 'message' => $e->getMessage()], 500); |
| 1868 |
} |
| 1869 |
} |
| 1870 |
#endregion |
| 1871 |
|
| 1872 |
#region Performance Insights |
| 1873 |
|
| 1874 |
function rest_get_last_insights( ) { |
| 1875 |
$result = $this->core->get_last_insights( ); |
| 1876 |
|
| 1877 |
if ( $result === false ) { |
| 1878 |
return new WP_REST_Response([ |
| 1879 |
'success' => false, |
| 1880 |
'message' => 'Failed to retrieve last insights.', |
| 1881 |
], 500 ); |
| 1882 |
} |
| 1883 |
|
| 1884 |
return new WP_REST_Response([ |
| 1885 |
'success' => true, |
| 1886 |
'data' => $result |
| 1887 |
], 200); |
| 1888 |
} |
| 1889 |
|
| 1890 |
function rest_get_insights( $request ) { |
| 1891 |
try { |
| 1892 |
$params = $request->get_json_params(); |
| 1893 |
$post_id = isset( $params['post'] ) ? $params['post'] : null; |
| 1894 |
|
| 1895 |
if ( !$post_id ) { |
| 1896 |
return new WP_REST_Response([ |
| 1897 |
'success' => false, |
| 1898 |
'message' => 'Post ID is required.', |
| 1899 |
], 400 ); |
| 1900 |
} |
| 1901 |
|
| 1902 |
// Check if post exists |
| 1903 |
$post = get_post( $post_id ); |
| 1904 |
if ( !$post && $post_id !== 'main' && $post_id !== 'delete' ) { |
| 1905 |
return new WP_REST_Response([ |
| 1906 |
'success' => false, |
| 1907 |
'message' => 'Invalid Post ID.', |
| 1908 |
], 404 ); |
| 1909 |
} |
| 1910 |
|
| 1911 |
$result = $this->core->get_speed_and_vitals( $post_id ); |
| 1912 |
|
| 1913 |
if ( $result === false ) { |
| 1914 |
return new WP_REST_Response([ |
| 1915 |
'success' => false, |
| 1916 |
'message' => 'Failed to retrieve insights.', |
| 1917 |
], 500 ); |
| 1918 |
} |
| 1919 |
|
| 1920 |
return new WP_REST_Response([ |
| 1921 |
'success' => true, |
| 1922 |
'data' => $result |
| 1923 |
], 200); |
| 1924 |
} catch (Exception $e) { |
| 1925 |
$this->core->log( 'Error in rest_get_insights: ' . $e->getMessage() ); |
| 1926 |
return new WP_REST_Response(['success' => false, 'message' => 'An unexpected error occurred: ' . $e->getMessage()], 500); |
| 1927 |
} |
| 1928 |
} |
| 1929 |
|
| 1930 |
|
| 1931 |
#endregion |
| 1932 |
|
| 1933 |
#region WooCommerce |
| 1934 |
|
| 1935 |
function rest_generate_fields( $request ) { |
| 1936 |
try { |
| 1937 |
|
| 1938 |
$params = $request->get_json_params(); |
| 1939 |
$meta = $this->core->generate_woocommerce_fields( $params ); |
| 1940 |
|
| 1941 |
return new WP_REST_Response([ |
| 1942 |
'success' => true, |
| 1943 |
'message' => 'OK', |
| 1944 |
'data' => $meta, |
| 1945 |
], 200 ); |
| 1946 |
} |
| 1947 |
catch( Exception $e) |
| 1948 |
{ |
| 1949 |
return new WP_REST_Response([ |
| 1950 |
'success' => false, |
| 1951 |
'message' => $e->getMessage(), |
| 1952 |
], 500 ); |
| 1953 |
} |
| 1954 |
} |
| 1955 |
|
| 1956 |
function rest_apply_woo_fields( $request ) { |
| 1957 |
try { |
| 1958 |
$params = $request->get_json_params(); |
| 1959 |
$this->core->apply_woocommerce_fields( $params ); |
| 1960 |
|
| 1961 |
return new WP_REST_Response([ |
| 1962 |
'success' => true, |
| 1963 |
'message' => 'OK', |
| 1964 |
], 200 ); |
| 1965 |
} |
| 1966 |
catch( Exception $e ) |
| 1967 |
{ |
| 1968 |
return new WP_REST_Response([ |
| 1969 |
'success' => false, |
| 1970 |
'message' => $e->getMessage(), |
| 1971 |
], 500 ); |
| 1972 |
} |
| 1973 |
} |
| 1974 |
|
| 1975 |
#endregion |
| 1976 |
|
| 1977 |
// Builds a short, post-specific action plan for a content-level issue (originality, |
| 1978 |
// completeness, readability...). The diagnosis already exists (stored analysis feedback, |
| 1979 |
// passed in by the client); this turns it into concrete next steps for THIS post. |
| 1980 |
function rest_ai_improvement_plan( $request ) { |
| 1981 |
try { |
| 1982 |
$params = $request->get_json_params(); |
| 1983 |
$post_id = isset( $params['id'] ) ? (int) $params['id'] : 0; |
| 1984 |
$test = isset( $params['test'] ) ? sanitize_key( $params['test'] ) : ''; |
| 1985 |
$issue_title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : ''; |
| 1986 |
$diagnosis = isset( $params['description'] ) ? wp_strip_all_tags( (string) $params['description'] ) : ''; |
| 1987 |
|
| 1988 |
$post = $post_id ? get_post( $post_id ) : null; |
| 1989 |
if ( !$post || empty( $test ) ) { |
| 1990 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Post not found.' ], 404 ); |
| 1991 |
} |
| 1992 |
|
| 1993 |
// Plans are cached per test: the diagnosis only changes when the post is re-analyzed, |
| 1994 |
// and re-analysis clears the cache via the timestamp check below. |
| 1995 |
$cache = get_post_meta( $post_id, '_mwseo_improve_plans', true ); |
| 1996 |
$cache = is_array( $cache ) ? $cache : []; |
| 1997 |
if ( !empty( $cache[ $test ]['actions'] ) && !empty( $cache[ $test ]['time'] ) |
| 1998 |
&& ( time() - (int) $cache[ $test ]['time'] ) < 7 * DAY_IN_SECONDS ) { |
| 1999 |
return new WP_REST_Response([ |
| 2000 |
'success' => true, |
| 2001 |
'data' => [ 'actions' => $cache[ $test ]['actions'], 'cached' => true ], |
| 2002 |
], 200 ); |
| 2003 |
} |
| 2004 |
|
| 2005 |
global $mwai; |
| 2006 |
if ( is_null( $mwai ) || !isset( $mwai ) ) { |
| 2007 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Missing AI Engine.' ], 500 ); |
| 2008 |
} |
| 2009 |
|
| 2010 |
$content = wp_strip_all_tags( strip_shortcodes( (string) $post->post_content ) ); |
| 2011 |
$content = mb_substr( $content, 0, 4000 ); |
| 2012 |
|
| 2013 |
$prompt = "You are an SEO content coach helping improve one specific post.\n" |
| 2014 |
. "Post title: " . $post->post_title . "\n" |
| 2015 |
. "Issue: " . $issue_title . "\n" |
| 2016 |
. ( $diagnosis ? "Diagnosis from the analysis: " . $diagnosis . "\n" : '' ) |
| 2017 |
. "Beginning of the post content (plain text):\n" . $content . "\n\n" |
| 2018 |
. "Give 3 to 5 concrete, specific actions the author should take in the editor to fix this issue for this exact post. " |
| 2019 |
. "Reference actual sections or phrases from the post where possible. Write in the same language as the post content. " |
| 2020 |
. "Each action must be one short sentence. " |
| 2021 |
. "Reply with a JSON object: {\"actions\": [\"...\", \"...\"]}."; |
| 2022 |
|
| 2023 |
$result = $mwai->simpleJsonQuery( $prompt ); |
| 2024 |
$actions = []; |
| 2025 |
if ( is_array( $result ) && !empty( $result['actions'] ) && is_array( $result['actions'] ) ) { |
| 2026 |
foreach ( array_slice( $result['actions'], 0, 6 ) as $action ) { |
| 2027 |
if ( is_string( $action ) && trim( $action ) !== '' ) { |
| 2028 |
$actions[] = sanitize_text_field( $action ); |
| 2029 |
} |
| 2030 |
} |
| 2031 |
} |
| 2032 |
if ( empty( $actions ) ) { |
| 2033 |
return new WP_REST_Response([ 'success' => false, 'message' => 'The plan could not be generated.' ], 400 ); |
| 2034 |
} |
| 2035 |
|
| 2036 |
$cache[ $test ] = [ 'actions' => $actions, 'time' => time() ]; |
| 2037 |
update_post_meta( $post_id, '_mwseo_improve_plans', $cache ); |
| 2038 |
|
| 2039 |
return new WP_REST_Response([ |
| 2040 |
'success' => true, |
| 2041 |
'data' => [ 'actions' => $actions, 'cached' => false ], |
| 2042 |
], 200 ); |
| 2043 |
} |
| 2044 |
catch ( Exception $e ) { |
| 2045 |
return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2046 |
} |
| 2047 |
} |
| 2048 |
|
| 2049 |
function rest_ai_suggest( $request ) { |
| 2050 |
try { |
| 2051 |
|
| 2052 |
$params = $request->get_json_params(); |
| 2053 |
$post = get_post( $params[ 'id' ] ); |
| 2054 |
|
| 2055 |
if ( !$post ) { |
| 2056 |
return new WP_REST_Response([ |
| 2057 |
'success' => false, |
| 2058 |
'message' => 'Post not found.', |
| 2059 |
], 404 ); |
| 2060 |
} |
| 2061 |
|
| 2062 |
global $mwai; |
| 2063 |
if (is_null( $mwai ) || !isset( $mwai ) ) { |
| 2064 |
return new WP_REST_Response([ |
| 2065 |
'success' => false, |
| 2066 |
'message' => 'Missing AI Engine.', |
| 2067 |
], 500 ); |
| 2068 |
}else{ |
| 2069 |
$ai_suggestion = Meow_MWSEO_Modules_Suggestions::prompt( $post, $params[ 'field' ] ); |
| 2070 |
} |
| 2071 |
|
| 2072 |
if (empty($ai_suggestion) || is_null($ai_suggestion)) { |
| 2073 |
return new WP_REST_Response([ |
| 2074 |
'success' => false, |
| 2075 |
'message' => 'AI suggestion is invalid.', |
| 2076 |
], 400 ); |
| 2077 |
} |
| 2078 |
|
| 2079 |
return new WP_REST_Response([ |
| 2080 |
'success' => true, |
| 2081 |
'message' => 'OK', |
| 2082 |
'data' => str_replace('"', '', $ai_suggestion), |
| 2083 |
], 200 ); |
| 2084 |
|
| 2085 |
} |
| 2086 |
catch( Exception $e) |
| 2087 |
{ |
| 2088 |
return new WP_REST_Response([ |
| 2089 |
'success' => false, |
| 2090 |
'message' => $e->getMessage(), |
| 2091 |
], 500 ); |
| 2092 |
} |
| 2093 |
} |
| 2094 |
|
| 2095 |
/** |
| 2096 |
* Generate a solution for a specific SEO issue |
| 2097 |
*/ |
| 2098 |
function rest_magic_fix_generate( $request ) { |
| 2099 |
try { |
| 2100 |
$params = $request->get_json_params(); |
| 2101 |
$post_id = $params['post_id']; |
| 2102 |
$issue_type = $params['issue_type']; |
| 2103 |
|
| 2104 |
$post = get_post( $post_id ); |
| 2105 |
if ( !$post ) { |
| 2106 |
return new WP_REST_Response([ |
| 2107 |
'success' => false, |
| 2108 |
'message' => 'Post not found.', |
| 2109 |
], 404 ); |
| 2110 |
} |
| 2111 |
|
| 2112 |
// Check if AI Engine is available |
| 2113 |
global $mwai; |
| 2114 |
if ( is_null( $mwai ) || !isset( $mwai ) ) { |
| 2115 |
return new WP_REST_Response([ |
| 2116 |
'success' => false, |
| 2117 |
'message' => 'AI Engine is not available. Please make sure AI Engine plugin is installed and activated.', |
| 2118 |
], 500 ); |
| 2119 |
} |
| 2120 |
|
| 2121 |
// Use premium Magic Fix class |
| 2122 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2123 |
return new WP_REST_Response([ |
| 2124 |
'success' => false, |
| 2125 |
'message' => 'Magic Fix is not available. This is a premium feature.', |
| 2126 |
], 500 ); |
| 2127 |
} |
| 2128 |
|
| 2129 |
$result = $this->core->pro->magic_fix->generate_solution( $post, $issue_type ); |
| 2130 |
|
| 2131 |
if ( $result === false ) { |
| 2132 |
return new WP_REST_Response([ |
| 2133 |
'success' => false, |
| 2134 |
'message' => 'Could not generate solution. Please try again.', |
| 2135 |
], 200 ); |
| 2136 |
} |
| 2137 |
|
| 2138 |
return new WP_REST_Response([ |
| 2139 |
'success' => true, |
| 2140 |
'data' => $result |
| 2141 |
], 200 ); |
| 2142 |
|
| 2143 |
} catch( Exception $e ) { |
| 2144 |
$this->core->log('❌ ' . $e->getMessage()); |
| 2145 |
return new WP_REST_Response([ |
| 2146 |
'success' => false, |
| 2147 |
'message' => $e->getMessage(), |
| 2148 |
], 500 ); |
| 2149 |
} |
| 2150 |
} |
| 2151 |
|
| 2152 |
/** |
| 2153 |
* Apply a solution to fix a specific SEO issue |
| 2154 |
*/ |
| 2155 |
function rest_magic_fix_apply( $request ) { |
| 2156 |
try { |
| 2157 |
$params = $request->get_json_params(); |
| 2158 |
$post_id = $params['post_id']; |
| 2159 |
$issue_type = $params['issue_type']; |
| 2160 |
$solution = $params['solution']; |
| 2161 |
|
| 2162 |
$post = get_post( $post_id ); |
| 2163 |
if ( !$post ) { |
| 2164 |
return new WP_REST_Response([ |
| 2165 |
'success' => false, |
| 2166 |
'message' => 'Post not found.', |
| 2167 |
], 404 ); |
| 2168 |
} |
| 2169 |
|
| 2170 |
// Use premium Magic Fix class |
| 2171 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2172 |
return new WP_REST_Response([ |
| 2173 |
'success' => false, |
| 2174 |
'message' => 'Magic Fix is not available. This is a premium feature.', |
| 2175 |
], 500 ); |
| 2176 |
} |
| 2177 |
|
| 2178 |
$result = $this->core->pro->magic_fix->apply_solution( $post, $issue_type, $solution ); |
| 2179 |
|
| 2180 |
if ( $result === false ) { |
| 2181 |
return new WP_REST_Response([ |
| 2182 |
'success' => false, |
| 2183 |
'message' => 'Could not apply solution.', |
| 2184 |
], 200 ); |
| 2185 |
} |
| 2186 |
|
| 2187 |
// Check if result contains an error |
| 2188 |
if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] === false ) { |
| 2189 |
return new WP_REST_Response([ |
| 2190 |
'success' => false, |
| 2191 |
'message' => $result['error'] ?? 'Could not apply solution.', |
| 2192 |
], 200 ); |
| 2193 |
} |
| 2194 |
|
| 2195 |
// Don't recalculate score here - let user run analysis when ready |
| 2196 |
// This preserves the _mwseo_issues_fixed markers |
| 2197 |
|
| 2198 |
return new WP_REST_Response([ |
| 2199 |
'success' => true, |
| 2200 |
'message' => 'Solution applied successfully.', |
| 2201 |
'data' => $result |
| 2202 |
], 200 ); |
| 2203 |
|
| 2204 |
} catch( Exception $e ) { |
| 2205 |
$this->core->log('❌ ' . $e->getMessage()); |
| 2206 |
return new WP_REST_Response([ |
| 2207 |
'success' => false, |
| 2208 |
'message' => $e->getMessage(), |
| 2209 |
], 500 ); |
| 2210 |
} |
| 2211 |
} |
| 2212 |
|
| 2213 |
/** |
| 2214 |
* Internal Links Step 1: Extract keywords from current post |
| 2215 |
*/ |
| 2216 |
function rest_magic_fix_internal_links_step1( $request ) { |
| 2217 |
try { |
| 2218 |
$params = $request->get_json_params(); |
| 2219 |
$post_id = $params['post_id']; |
| 2220 |
|
| 2221 |
$post = get_post( $post_id ); |
| 2222 |
if ( !$post ) { |
| 2223 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Post not found.' ], 404 ); |
| 2224 |
} |
| 2225 |
|
| 2226 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2227 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Magic Fix is not available.' ], 500 ); |
| 2228 |
} |
| 2229 |
|
| 2230 |
// Call the magic_fix method for step 1 |
| 2231 |
$result = $this->core->pro->magic_fix->internal_links_step1( $post ); |
| 2232 |
|
| 2233 |
return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200 ); |
| 2234 |
|
| 2235 |
} catch( Exception $e ) { |
| 2236 |
$this->core->log('❌ Step 1 error: ' . $e->getMessage()); |
| 2237 |
return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2238 |
} |
| 2239 |
} |
| 2240 |
|
| 2241 |
/** |
| 2242 |
* Internal Links Step 2: Build candidate list |
| 2243 |
*/ |
| 2244 |
function rest_magic_fix_internal_links_step2( $request ) { |
| 2245 |
try { |
| 2246 |
$params = $request->get_json_params(); |
| 2247 |
$post_id = $params['post_id']; |
| 2248 |
$keywords = $params['keywords']; |
| 2249 |
|
| 2250 |
$post = get_post( $post_id ); |
| 2251 |
if ( !$post ) { |
| 2252 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Post not found.' ], 404 ); |
| 2253 |
} |
| 2254 |
|
| 2255 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2256 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Magic Fix is not available.' ], 500 ); |
| 2257 |
} |
| 2258 |
|
| 2259 |
$result = $this->core->pro->magic_fix->internal_links_step2( $post, $keywords ); |
| 2260 |
|
| 2261 |
return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200 ); |
| 2262 |
|
| 2263 |
} catch( Exception $e ) { |
| 2264 |
$this->core->log('❌ Step 2 error: ' . $e->getMessage()); |
| 2265 |
return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2266 |
} |
| 2267 |
} |
| 2268 |
|
| 2269 |
/** |
| 2270 |
* Internal Links Step 3: AI selects top posts |
| 2271 |
*/ |
| 2272 |
function rest_magic_fix_internal_links_step3( $request ) { |
| 2273 |
try { |
| 2274 |
$params = $request->get_json_params(); |
| 2275 |
$post_id = $params['post_id']; |
| 2276 |
$candidates = $params['candidates']; |
| 2277 |
|
| 2278 |
$post = get_post( $post_id ); |
| 2279 |
if ( !$post ) { |
| 2280 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Post not found.' ], 404 ); |
| 2281 |
} |
| 2282 |
|
| 2283 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2284 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Magic Fix is not available.' ], 500 ); |
| 2285 |
} |
| 2286 |
|
| 2287 |
$result = $this->core->pro->magic_fix->internal_links_step3( $post, $candidates ); |
| 2288 |
|
| 2289 |
return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200 ); |
| 2290 |
|
| 2291 |
} catch( Exception $e ) { |
| 2292 |
$this->core->log('❌ Step 3 error: ' . $e->getMessage()); |
| 2293 |
return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2294 |
} |
| 2295 |
} |
| 2296 |
|
| 2297 |
/** |
| 2298 |
* Internal Links Step 4: Generate placements for a single post |
| 2299 |
*/ |
| 2300 |
function rest_magic_fix_internal_links_step4( $request ) { |
| 2301 |
try { |
| 2302 |
$params = $request->get_json_params(); |
| 2303 |
$post_id = $params['post_id']; |
| 2304 |
$target_id = $params['target_id']; |
| 2305 |
|
| 2306 |
$post = get_post( $post_id ); |
| 2307 |
$target_post = get_post( $target_id ); |
| 2308 |
|
| 2309 |
if ( !$post || !$target_post ) { |
| 2310 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Post not found.' ], 404 ); |
| 2311 |
} |
| 2312 |
|
| 2313 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2314 |
return new WP_REST_Response([ 'success' => false, 'message' => 'Magic Fix is not available.' ], 500 ); |
| 2315 |
} |
| 2316 |
|
| 2317 |
$result = $this->core->pro->magic_fix->internal_links_step4( $post, $target_post ); |
| 2318 |
|
| 2319 |
return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200 ); |
| 2320 |
|
| 2321 |
} catch( Exception $e ) { |
| 2322 |
$this->core->log('❌ Step 4 error: ' . $e->getMessage()); |
| 2323 |
return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2324 |
} |
| 2325 |
} |
| 2326 |
|
| 2327 |
function rest_generate_daily_insight( $request ) { |
| 2328 |
try { |
| 2329 |
// Check if AI Engine is available |
| 2330 |
if ( !class_exists( 'Meow_MWAI_Core' ) ) { |
| 2331 |
return $this->error_response( 'AI Engine is not available.', 'ai_engine_unavailable', 503 ); |
| 2332 |
} |
| 2333 |
|
| 2334 |
global $mwai; |
| 2335 |
|
| 2336 |
// Get the 20 posts with worst scores |
| 2337 |
$args = array( |
| 2338 |
'post_type' => $this->core->get_option( 'select_post_types', ['post'] ), |
| 2339 |
'posts_per_page' => 20, |
| 2340 |
'orderby' => 'meta_value_num', |
| 2341 |
'order' => 'ASC', |
| 2342 |
'meta_key' => '_mwseo_score', |
| 2343 |
'meta_query' => array( |
| 2344 |
array( |
| 2345 |
'key' => '_mwseo_score', |
| 2346 |
'compare' => 'EXISTS' |
| 2347 |
), |
| 2348 |
array( |
| 2349 |
'key' => '_mwseo_score', |
| 2350 |
'value' => 0, |
| 2351 |
'compare' => '>=', |
| 2352 |
'type' => 'NUMERIC' |
| 2353 |
) |
| 2354 |
) |
| 2355 |
); |
| 2356 |
|
| 2357 |
$posts = get_posts( $args ); |
| 2358 |
|
| 2359 |
if ( empty( $posts ) ) { |
| 2360 |
return $this->error_response( 'No analyzed posts found.', 'no_posts', 404 ); |
| 2361 |
} |
| 2362 |
|
| 2363 |
// Prime meta cache |
| 2364 |
$post_ids = wp_list_pluck( $posts, 'ID' ); |
| 2365 |
update_meta_cache( 'post', $post_ids ); |
| 2366 |
|
| 2367 |
// Build data for AI |
| 2368 |
$posts_data = []; |
| 2369 |
foreach ( $posts as $post ) { |
| 2370 |
$score = get_post_meta( $post->ID, '_mwseo_score', true ); |
| 2371 |
$codes = get_post_meta( $post->ID, '_mwseo_codes', true ); |
| 2372 |
$edit_link = get_edit_post_link( $post->ID, 'raw' ); |
| 2373 |
|
| 2374 |
// Format issue codes to be human-readable |
| 2375 |
$issues = []; |
| 2376 |
if ( is_array( $codes ) && !empty( $codes ) ) { |
| 2377 |
foreach ( $codes as $code ) { |
| 2378 |
// Convert snake_case to Title Case |
| 2379 |
$issues[] = ucwords( str_replace( '_', ' ', $code ) ); |
| 2380 |
} |
| 2381 |
} |
| 2382 |
|
| 2383 |
$posts_data[] = [ |
| 2384 |
'title' => $post->post_title, |
| 2385 |
'score' => $score, |
| 2386 |
'issues' => $issues, |
| 2387 |
'edit_link' => $edit_link |
| 2388 |
]; |
| 2389 |
} |
| 2390 |
|
| 2391 |
// Create AI prompt |
| 2392 |
$prompt = "You are an SEO consultant. Below are the 20 posts with the lowest SEO scores from a website. Each post has a score (0-100), a list of SEO issues, and an edit link.\n\n"; |
| 2393 |
|
| 2394 |
foreach ( $posts_data as $idx => $data ) { |
| 2395 |
$prompt .= sprintf( |
| 2396 |
"Post %d: \"%s\" (Score: %d)\n", |
| 2397 |
$idx + 1, |
| 2398 |
$data['title'], |
| 2399 |
$data['score'] |
| 2400 |
); |
| 2401 |
if ( !empty( $data['issues'] ) ) { |
| 2402 |
$prompt .= "Issues: " . implode( ', ', $data['issues'] ) . "\n"; |
| 2403 |
} |
| 2404 |
$prompt .= "Edit link: " . $data['edit_link'] . "\n\n"; |
| 2405 |
} |
| 2406 |
|
| 2407 |
$prompt .= "Based on these posts, write TWO short paragraphs:\n\n"; |
| 2408 |
$prompt .= "Paragraph 1: Start with a friendly greeting (Hi, Hello, Hey). Address the user directly (you, your). Identify the 2-3 main SEO issues affecting these posts and quick fixes. Be specific and actionable. Keep it under 3 sentences.\n\n"; |
| 2409 |
$prompt .= "Paragraph 2: Give 3 specific examples using short references. For each, create a markdown link using a brief topic/keyword from the title (2-4 words max), not the full title. Format: 'the posts about [topic](edit_link), [topic](edit_link), and [topic](edit_link)'. Keep it under 2 sentences.\n\n"; |
| 2410 |
$prompt .= "Use markdown for **bold** emphasis on issue names. Keep the tone friendly, personal, and encouraging. Address the user directly throughout. Total length: 5 sentences maximum across both paragraphs."; |
| 2411 |
|
| 2412 |
// Get AI response |
| 2413 |
$insight_text = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 2414 |
|
| 2415 |
// Store insight with timestamp |
| 2416 |
$now = current_time( 'mysql' ); |
| 2417 |
$this->core->update_option( 'daily_insight_text', $insight_text ); |
| 2418 |
$this->core->update_option( 'daily_insight_generated_at', $now ); |
| 2419 |
|
| 2420 |
return $this->success_response( [ |
| 2421 |
'text' => $insight_text, |
| 2422 |
'generated_at' => $now, |
| 2423 |
'generated_at_ts' => strtotime( get_gmt_from_date( $now ) . ' UTC' ) |
| 2424 |
] ); |
| 2425 |
|
| 2426 |
} catch ( Exception $e ) { |
| 2427 |
$this->core->log( '❌ Daily Insight error: ' . $e->getMessage() ); |
| 2428 |
return $this->error_response( $e->getMessage(), 'generation_failed', 500 ); |
| 2429 |
} |
| 2430 |
} |
| 2431 |
|
| 2432 |
function rest_get_daily_insight( $request ) { |
| 2433 |
$text = $this->core->get_option( 'daily_insight_text', null ); |
| 2434 |
$generated_at = $this->core->get_option( 'daily_insight_generated_at', null ); |
| 2435 |
|
| 2436 |
if ( !$text ) { |
| 2437 |
return $this->success_response( [ |
| 2438 |
'text' => null, |
| 2439 |
'generated_at' => null, |
| 2440 |
'generated_at_ts' => null |
| 2441 |
] ); |
| 2442 |
} |
| 2443 |
|
| 2444 |
// generated_at is a local "mysql" datetime; convert it to a real UTC timestamp so the |
| 2445 |
// frontend's ago() helper (which compares against Date.now()) is timezone-correct. |
| 2446 |
$ts = $generated_at ? strtotime( get_gmt_from_date( $generated_at ) . ' UTC' ) : null; |
| 2447 |
|
| 2448 |
return $this->success_response( [ |
| 2449 |
'text' => $text, |
| 2450 |
'generated_at' => $generated_at, |
| 2451 |
'generated_at_ts' => $ts |
| 2452 |
] ); |
| 2453 |
} |
| 2454 |
|
| 2455 |
function rest_import_data( $request ) { |
| 2456 |
try { |
| 2457 |
$params = $request->get_json_params(); |
| 2458 |
$plugin = $params[ 'plugin' ]; |
| 2459 |
|
| 2460 |
switch ( $plugin ) { |
| 2461 |
case 'rankmath': |
| 2462 |
$status = $this->core->import_rank_math(); |
| 2463 |
break; |
| 2464 |
case 'yoast': |
| 2465 |
$status = $this->core->import_yoast(); |
| 2466 |
break; |
| 2467 |
default: |
| 2468 |
return new WP_REST_Response([ |
| 2469 |
'success' => true, |
| 2470 |
'message' => 'Invalid plugin.', |
| 2471 |
], 200 ); |
| 2472 |
} |
| 2473 |
|
| 2474 |
$posts = isset( $status['posts'] ) ? (int) $status['posts'] : 0; |
| 2475 |
$redirects = isset( $status['redirects'] ) ? (int) $status['redirects'] : 0; |
| 2476 |
|
| 2477 |
// Build a separate message for posts and redirections. |
| 2478 |
$messages = []; |
| 2479 |
if ( $posts > 0 ) { |
| 2480 |
$messages[] = "$posts post(s) SEO data imported."; |
| 2481 |
} |
| 2482 |
if ( $redirects > 0 ) { |
| 2483 |
$messages[] = "$redirects redirection(s) imported."; |
| 2484 |
} |
| 2485 |
if ( empty( $messages ) ) { |
| 2486 |
$messages[] = 'No posts or redirections found to import.'; |
| 2487 |
} |
| 2488 |
|
| 2489 |
return new WP_REST_Response([ |
| 2490 |
'success' => true, |
| 2491 |
'message' => implode( ' ', $messages ), |
| 2492 |
'data' => [ |
| 2493 |
'posts' => $posts, |
| 2494 |
'redirects' => $redirects, |
| 2495 |
], |
| 2496 |
], 200 ); |
| 2497 |
|
| 2498 |
} |
| 2499 |
catch( Exception $e) |
| 2500 |
{ |
| 2501 |
return new WP_REST_Response([ |
| 2502 |
'success' => false, |
| 2503 |
'message' => $e->getMessage(), |
| 2504 |
], 500 ); |
| 2505 |
} |
| 2506 |
} |
| 2507 |
|
| 2508 |
/** |
| 2509 |
* Transient prefixes holding score data. The AI analysis of a post is cached for a |
| 2510 |
* week under seo_engine_ai_*, the JS-rendering probe for a day under mwseo_js_render_*, |
| 2511 |
* and image vision results for a week under mwseo_vision_*. |
| 2512 |
*/ |
| 2513 |
private static $score_transient_prefixes = [ 'seo_engine_ai_', 'mwseo_js_render_', 'mwseo_vision_' ]; |
| 2514 |
|
| 2515 |
/** |
| 2516 |
* Delete every transient whose name starts with one of the given prefixes. |
| 2517 |
* Only transients stored in the options table can be enumerated this way, which is |
| 2518 |
* why callers also bump the score cache version — that covers external object caches. |
| 2519 |
*/ |
| 2520 |
private function delete_transients_by_prefix( $prefixes ) { |
| 2521 |
global $wpdb; |
| 2522 |
$deleted = 0; |
| 2523 |
|
| 2524 |
foreach ( $prefixes as $prefix ) { |
| 2525 |
$names = $wpdb->get_col( $wpdb->prepare( |
| 2526 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 2527 |
$wpdb->esc_like( '_transient_' . $prefix ) . '%' |
| 2528 |
) ); |
| 2529 |
|
| 2530 |
foreach ( $names as $name ) { |
| 2531 |
// Go through delete_transient() rather than deleting the rows directly, so |
| 2532 |
// the timeout row and any object cache entry go with it. |
| 2533 |
if ( delete_transient( substr( $name, strlen( '_transient_' ) ) ) ) { |
| 2534 |
$deleted++; |
| 2535 |
} |
| 2536 |
} |
| 2537 |
} |
| 2538 |
|
| 2539 |
return $deleted; |
| 2540 |
} |
| 2541 |
|
| 2542 |
function rest_clear_ai_cache( $request ) { |
| 2543 |
try { |
| 2544 |
global $wpdb; |
| 2545 |
|
| 2546 |
// Get all posts with SEO analysis data |
| 2547 |
$results = $wpdb->get_results( |
| 2548 |
"SELECT post_id, meta_value |
| 2549 |
FROM {$wpdb->postmeta} |
| 2550 |
WHERE meta_key = '_mwseo_analysis'", |
| 2551 |
ARRAY_A |
| 2552 |
); |
| 2553 |
|
| 2554 |
$cleared_count = 0; |
| 2555 |
|
| 2556 |
foreach ( $results as $row ) { |
| 2557 |
$post_id = $row['post_id']; |
| 2558 |
$analysis = maybe_unserialize( $row['meta_value'] ); |
| 2559 |
|
| 2560 |
// If analysis has AI data, remove it |
| 2561 |
if ( is_array( $analysis ) && isset( $analysis['ai'] ) ) { |
| 2562 |
unset( $analysis['ai'] ); |
| 2563 |
update_post_meta( $post_id, '_mwseo_analysis', $analysis ); |
| 2564 |
$cleared_count++; |
| 2565 |
} |
| 2566 |
} |
| 2567 |
|
| 2568 |
// Stored analysis is only half of it: the AI results also sit in transients, |
| 2569 |
// keyed by a hash of the content. Without this, a re-analysis of unchanged |
| 2570 |
// content would come straight back out of the cache. |
| 2571 |
$transients_count = $this->delete_transients_by_prefix( self::$score_transient_prefixes ); |
| 2572 |
Meow_MWSEO_Score::bump_cache_version(); |
| 2573 |
|
| 2574 |
$message = ( $cleared_count > 0 || $transients_count > 0 ) |
| 2575 |
? "Score cache cleared ($cleared_count post(s), $transients_count cached result(s))." |
| 2576 |
: "No score cache found to clear."; |
| 2577 |
|
| 2578 |
return $this->success_response( |
| 2579 |
[ 'cleared_count' => $cleared_count, 'transients_count' => $transients_count ], |
| 2580 |
$message |
| 2581 |
); |
| 2582 |
|
| 2583 |
} catch ( Exception $e ) { |
| 2584 |
$this->core->log( '❌ Clear AI cache error: ' . $e->getMessage() ); |
| 2585 |
return $this->error_response( $e->getMessage(), 'clear_cache_failed', 500 ); |
| 2586 |
} |
| 2587 |
} |
| 2588 |
|
| 2589 |
function rest_get_ai_keywords( $request ){ |
| 2590 |
try{ |
| 2591 |
$params = $request->get_json_params(); |
| 2592 |
$post = get_post( $params[ 'id' ] ); |
| 2593 |
|
| 2594 |
if ( !$post ) { |
| 2595 |
return new WP_REST_Response([ |
| 2596 |
'success' => false, |
| 2597 |
'message' => 'Post not found.', |
| 2598 |
], 404 ); |
| 2599 |
} |
| 2600 |
|
| 2601 |
$keywords = get_post_meta( $post->ID, '_mwseo_keywords', true ); |
| 2602 |
|
| 2603 |
return new WP_REST_Response([ |
| 2604 |
'success' => true, |
| 2605 |
'message' => 'OK', |
| 2606 |
'data' => [ |
| 2607 |
'id_received' => $params[ 'id' ], |
| 2608 |
'keywords' => $keywords == '' ? [] : $keywords, |
| 2609 |
] |
| 2610 |
], 200 ); |
| 2611 |
|
| 2612 |
} |
| 2613 |
catch( Exception $e) |
| 2614 |
{ |
| 2615 |
return new WP_REST_Response([ |
| 2616 |
'success' => false, |
| 2617 |
'message' => $e->getMessage(), |
| 2618 |
], 500 ); |
| 2619 |
} |
| 2620 |
} |
| 2621 |
|
| 2622 |
function rest_get_post_statuses( $request ) { |
| 2623 |
$post_type = $request->get_param('type'); |
| 2624 |
|
| 2625 |
if( empty($post_type) ) { |
| 2626 |
return new WP_REST_Response([ |
| 2627 |
'success' => true, |
| 2628 |
'data' => [], |
| 2629 |
], 200 ); |
| 2630 |
} |
| 2631 |
|
| 2632 |
$type_to_domain = [ |
| 2633 |
'product' => 'woocommerce', |
| 2634 |
]; |
| 2635 |
|
| 2636 |
$statuses = get_post_stati( [] , 'objects' ); |
| 2637 |
|
| 2638 |
$status_list = []; |
| 2639 |
foreach ( $statuses as $status ) { |
| 2640 |
$domain = $status->label_count["domain"] ?? 'core'; |
| 2641 |
|
| 2642 |
if( $domain === 'core' || ( isset($type_to_domain[$post_type]) && $domain === $type_to_domain[$post_type] ) ) { |
| 2643 |
$status_list[] = [ |
| 2644 |
'slug' => $status->name, |
| 2645 |
'name' => $status->label, |
| 2646 |
]; |
| 2647 |
} |
| 2648 |
} |
| 2649 |
|
| 2650 |
return new WP_REST_Response([ |
| 2651 |
'success' => true, |
| 2652 |
'data' => $status_list |
| 2653 |
], 200 ); |
| 2654 |
} |
| 2655 |
|
| 2656 |
function rest_get_score_factors() { |
| 2657 |
try { |
| 2658 |
global $mwseo_score; |
| 2659 |
|
| 2660 |
if ( !$mwseo_score ) { |
| 2661 |
return new WP_REST_Response([ |
| 2662 |
'success' => false, |
| 2663 |
'message' => 'Score module not initialized.', |
| 2664 |
], 500 ); |
| 2665 |
} |
| 2666 |
|
| 2667 |
$factors = $mwseo_score->get_score_factors(); |
| 2668 |
|
| 2669 |
return new WP_REST_Response([ |
| 2670 |
'success' => true, |
| 2671 |
'data' => $factors |
| 2672 |
], 200 ); |
| 2673 |
} |
| 2674 |
catch( Exception $e ) { |
| 2675 |
return new WP_REST_Response([ |
| 2676 |
'success' => false, |
| 2677 |
'message' => $e->getMessage(), |
| 2678 |
], 500 ); |
| 2679 |
} |
| 2680 |
} |
| 2681 |
|
| 2682 |
function rest_get_languages() { |
| 2683 |
// Polylang / Bogo; empty when no supported multilingual plugin is active. |
| 2684 |
return new WP_REST_Response([ |
| 2685 |
'success' => true, |
| 2686 |
'languages' => $this->core->get_available_languages(), |
| 2687 |
], 200 ); |
| 2688 |
} |
| 2689 |
|
| 2690 |
#region Robots.txt |
| 2691 |
function rest_get_robots_txt() { |
| 2692 |
$robots = $this->core->get_robots_txt(); |
| 2693 |
|
| 2694 |
$robotsTxt = $robots['content'] ?? ''; |
| 2695 |
$source = $robots['source'] ?? 'default'; |
| 2696 |
|
| 2697 |
|
| 2698 |
return new WP_REST_Response( [ 'success' => true, 'data' => $robotsTxt, 'source' => $source ], 200 ); |
| 2699 |
} |
| 2700 |
|
| 2701 |
function rest_update_robots_txt( $request) { |
| 2702 |
|
| 2703 |
|
| 2704 |
$params = $request->get_json_params(); |
| 2705 |
$content = $params['content'] ?? ''; |
| 2706 |
|
| 2707 |
if ( empty( $content ) ) { |
| 2708 |
return new WP_REST_Response( [ |
| 2709 |
'success' => false, |
| 2710 |
'message' => 'Content is empty. Please provide valid content.' |
| 2711 |
], 400 ); |
| 2712 |
} |
| 2713 |
|
| 2714 |
// Validate the content (basic validation) |
| 2715 |
if ( strlen( $content ) > 5000 ) { |
| 2716 |
return new WP_REST_Response( [ |
| 2717 |
'success' => false, |
| 2718 |
'message' => 'Content is too long. Please limit it to 5000 characters.' |
| 2719 |
], 400 ); |
| 2720 |
} |
| 2721 |
|
| 2722 |
$result = $this->core->set_robots_txt( $content ); |
| 2723 |
|
| 2724 |
if ($result === false) { |
| 2725 |
return new WP_REST_Response( [ |
| 2726 |
'success' => false, |
| 2727 |
'message' => 'Could not write to robots.txt file. Please check file permissions.' |
| 2728 |
], 500 ); |
| 2729 |
} |
| 2730 |
|
| 2731 |
return new WP_REST_Response( [ 'success' => true ], 200 ); |
| 2732 |
} |
| 2733 |
|
| 2734 |
function rest_ai_generate_robots_txt( $request ) { |
| 2735 |
try { |
| 2736 |
$params = $request->get_json_params(); |
| 2737 |
|
| 2738 |
$prompt = empty( $params['prompt'] ) ? 'Generate a robots.txt file for a WordPress website.' : $params['prompt']; |
| 2739 |
$content = $params['content']; |
| 2740 |
|
| 2741 |
if ( empty( $prompt ) ) { |
| 2742 |
return new WP_REST_Response( [ |
| 2743 |
'success' => false, |
| 2744 |
'message' => 'Prompt is empty. Please provide a valid prompt.' |
| 2745 |
], 400 ); |
| 2746 |
} |
| 2747 |
|
| 2748 |
global $mwai; |
| 2749 |
if (is_null( $mwai ) || !isset( $mwai ) ) { |
| 2750 |
return new WP_REST_Response( [ |
| 2751 |
'success' => false, |
| 2752 |
'message' => 'Missing AI Engine.' |
| 2753 |
], 500 ); |
| 2754 |
} |
| 2755 |
|
| 2756 |
// Gather the necessary data for the prompt |
| 2757 |
$site_url = get_site_url(); |
| 2758 |
$site_name = get_bloginfo( 'name' ); |
| 2759 |
$site_description = get_bloginfo( 'description' ); |
| 2760 |
$site_language = get_option( 'WPLANG' ); |
| 2761 |
$site_admin_email = get_option( 'admin_email' ); |
| 2762 |
$site_post_types = get_post_types( [ 'public' => true ], 'names' ); |
| 2763 |
$site_categories = get_categories( [ 'hide_empty' => false ] ); |
| 2764 |
$site_taxonomies = get_taxonomies( [ 'public' => true ], 'names' ); |
| 2765 |
$site_sitemap = get_option( 'home' ) . '/sitemap.xml'; |
| 2766 |
$site_last_updated = date( 'Y-m-d H:i:s' ); |
| 2767 |
|
| 2768 |
$site_data = [ |
| 2769 |
'site_url' => $site_url, |
| 2770 |
'site_name' => $site_name, |
| 2771 |
'site_description' => $site_description, |
| 2772 |
'site_language' => $site_language, |
| 2773 |
'site_admin_email' => $site_admin_email, |
| 2774 |
'site_post_types' => implode( ', ', $site_post_types ), |
| 2775 |
'site_categories' => implode( ', ', wp_list_pluck( $site_categories, 'name' ) ), |
| 2776 |
'site_taxonomies' => implode( ', ', $site_taxonomies ), |
| 2777 |
'site_sitemap' => $site_sitemap, |
| 2778 |
'site_last_updated' => $site_last_updated, |
| 2779 |
]; |
| 2780 |
|
| 2781 |
$site_data_json = json_encode( $site_data, JSON_PRETTY_PRINT ); |
| 2782 |
|
| 2783 |
|
| 2784 |
$instructions = "Generate a robots.txt file for a WordPress website. The content should be SEO optimized. Here are the details of the website:\n\n"; |
| 2785 |
$instructions .= "Website Data:\n"; |
| 2786 |
$instructions .= $site_data_json . "\n\n"; |
| 2787 |
$instructions .= "Here is the prompt from the user:\n"; |
| 2788 |
$instructions .= $prompt . "\n\n"; |
| 2789 |
$instructions .= "The current content of the robots.txt file is:\n"; |
| 2790 |
$instructions .= $content . "\n\n"; |
| 2791 |
$instructions .= "Please generate a robots.txt file based on the above information. Don't include any explanations, just provide the raw text of the robots.txt file. No quotes, no code blocks, just the text. The content should be SEO optimized and follow best practices for a WordPress website.\n\n"; |
| 2792 |
|
| 2793 |
$robots_txt = $mwai->simpleTextQuery( $instructions, [ 'scope' => 'seo' ] ); |
| 2794 |
if ( empty( $robots_txt ) || is_null( $robots_txt ) ) { |
| 2795 |
return new WP_REST_Response( [ |
| 2796 |
'success' => false, |
| 2797 |
'message' => 'AI suggestion is invalid.' |
| 2798 |
], 400 ); |
| 2799 |
} |
| 2800 |
|
| 2801 |
// Validate the generated robots.txt content |
| 2802 |
if ( strlen( $robots_txt ) > 5000 ) { |
| 2803 |
return new WP_REST_Response( [ |
| 2804 |
'success' => false, |
| 2805 |
'message' => 'Generated content is too long. Please limit it to 5000 characters.' |
| 2806 |
], 400 ); |
| 2807 |
} |
| 2808 |
|
| 2809 |
// Send the generated robots.txt content back to the client |
| 2810 |
return new WP_REST_Response( [ |
| 2811 |
'success' => true, |
| 2812 |
'message' => 'OK', |
| 2813 |
'data' => $robots_txt |
| 2814 |
], 200 ); |
| 2815 |
|
| 2816 |
} catch (Exception $e) { |
| 2817 |
return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2818 |
} |
| 2819 |
} |
| 2820 |
|
| 2821 |
#endregion |
| 2822 |
|
| 2823 |
#region LLMs.txt |
| 2824 |
function rest_get_llms_txt() { |
| 2825 |
$llms = $this->core->get_llms_txt(); |
| 2826 |
|
| 2827 |
$llmsTxt = $llms['content'] ?? ''; |
| 2828 |
$source = $llms['source'] ?? 'default'; |
| 2829 |
|
| 2830 |
|
| 2831 |
return new WP_REST_Response( [ 'success' => true, 'data' => $llmsTxt, 'source' => $source ], 200 ); |
| 2832 |
} |
| 2833 |
|
| 2834 |
function rest_update_llms_txt( $request ) { |
| 2835 |
$params = $request->get_json_params(); |
| 2836 |
$content = $params['content'] ?? ''; |
| 2837 |
|
| 2838 |
// Validate the content (basic validation) |
| 2839 |
if ( strlen( $content ) > 50000 ) { |
| 2840 |
return new WP_REST_Response( [ |
| 2841 |
'success' => false, |
| 2842 |
'message' => 'Content is too long. Please limit it to 50000 characters.' |
| 2843 |
], 400 ); |
| 2844 |
} |
| 2845 |
|
| 2846 |
$result = $this->core->set_llms_txt( $content ); |
| 2847 |
|
| 2848 |
if ( $result === false ) { |
| 2849 |
return new WP_REST_Response( [ |
| 2850 |
'success' => false, |
| 2851 |
'message' => 'Could not write to llms.txt file. Please check file permissions.' |
| 2852 |
], 500 ); |
| 2853 |
} |
| 2854 |
|
| 2855 |
return new WP_REST_Response( [ 'success' => true ], 200 ); |
| 2856 |
} |
| 2857 |
|
| 2858 |
function rest_delete_llms_txt( $request ) { |
| 2859 |
$result = $this->core->delete_llms_txt(); |
| 2860 |
|
| 2861 |
if ( $result === false ) { |
| 2862 |
return new WP_REST_Response( [ |
| 2863 |
'success' => false, |
| 2864 |
'message' => 'Could not delete the llms.txt file. It may not exist or the file is not writable.' |
| 2865 |
], 500 ); |
| 2866 |
} |
| 2867 |
|
| 2868 |
return new WP_REST_Response( [ 'success' => true ], 200 ); |
| 2869 |
} |
| 2870 |
|
| 2871 |
function rest_ai_generate_llms_txt( $request ) { |
| 2872 |
try { |
| 2873 |
$params = $request->get_json_params(); |
| 2874 |
$notes = isset( $params['notes'] ) ? trim( (string) $params['notes'] ) : ''; |
| 2875 |
|
| 2876 |
global $mwai; |
| 2877 |
if ( is_null( $mwai ) || !isset( $mwai ) ) { |
| 2878 |
return new WP_REST_Response( [ |
| 2879 |
'success' => false, |
| 2880 |
'message' => 'Missing AI Engine.' |
| 2881 |
], 500 ); |
| 2882 |
} |
| 2883 |
|
| 2884 |
$payload = $this->collect_llms_content(); |
| 2885 |
|
| 2886 |
$payload_json = wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); |
| 2887 |
|
| 2888 |
$instructions = "You are generating an llms.txt index file for a website, following the llmstxt.org specification.\n\n"; |
| 2889 |
$instructions .= "Output ONLY raw markdown (no preamble, no code fences, no commentary). The exact structure MUST be:\n\n"; |
| 2890 |
$instructions .= "# {site_name}\n\n"; |
| 2891 |
$instructions .= "> One-sentence summary that helps an AI decide whether to fetch more from this site. Factual, concrete, no marketing fluff.\n\n"; |
| 2892 |
$instructions .= "## Pages\n"; |
| 2893 |
$instructions .= "* [Title](URL): One-line factual description (max ~120 chars).\n"; |
| 2894 |
$instructions .= "...\n\n"; |
| 2895 |
$instructions .= "## Articles\n"; |
| 2896 |
$instructions .= "* [Title](URL): One-line factual description.\n"; |
| 2897 |
$instructions .= "...\n\n"; |
| 2898 |
$instructions .= "## Optional\n"; |
| 2899 |
$instructions .= "* [Title](URL): One-line factual description.\n"; |
| 2900 |
$instructions .= "...\n\n"; |
| 2901 |
$instructions .= "Rules:\n"; |
| 2902 |
$instructions .= "- Use the titles and URLs from the JSON below EXACTLY as provided. Do not invent links. Do not rewrite titles.\n"; |
| 2903 |
$instructions .= "- Only the one-line descriptions are yours to write.\n"; |
| 2904 |
$instructions .= "- Descriptions must be specific to that page, not generic. Mention the actual topic or purpose.\n"; |
| 2905 |
$instructions .= "- Use the page excerpt as a guide for the description, but rewrite it tightly. No jargon. No em-dashes.\n"; |
| 2906 |
$instructions .= "- Write in the same language as the site content.\n"; |
| 2907 |
$instructions .= "- If a section has no entries in the JSON, omit that section entirely.\n"; |
| 2908 |
$instructions .= "- Keep the total file under 8KB.\n"; |
| 2909 |
|
| 2910 |
if ( !empty( $notes ) ) { |
| 2911 |
$instructions .= "\nAdditional tone notes from the site owner (do not let these override the structure rules):\n"; |
| 2912 |
$instructions .= $notes . "\n"; |
| 2913 |
} |
| 2914 |
|
| 2915 |
$instructions .= "\nSite + content JSON:\n"; |
| 2916 |
$instructions .= $payload_json . "\n"; |
| 2917 |
|
| 2918 |
$llms_txt = $mwai->simpleTextQuery( $instructions, [ 'scope' => 'seo' ] ); |
| 2919 |
if ( empty( $llms_txt ) || is_null( $llms_txt ) ) { |
| 2920 |
return new WP_REST_Response( [ |
| 2921 |
'success' => false, |
| 2922 |
'message' => 'AI suggestion is invalid.' |
| 2923 |
], 400 ); |
| 2924 |
} |
| 2925 |
|
| 2926 |
// Strip leading/trailing code fences if the model added them despite instructions. |
| 2927 |
$llms_txt = trim( $llms_txt ); |
| 2928 |
$llms_txt = preg_replace( '/^```[a-zA-Z]*\s*\n/', '', $llms_txt ); |
| 2929 |
$llms_txt = preg_replace( '/\n```\s*$/', '', $llms_txt ); |
| 2930 |
|
| 2931 |
if ( strlen( $llms_txt ) > 50000 ) { |
| 2932 |
return new WP_REST_Response( [ |
| 2933 |
'success' => false, |
| 2934 |
'message' => 'Generated content is too long. Please limit it to 50000 characters.' |
| 2935 |
], 400 ); |
| 2936 |
} |
| 2937 |
|
| 2938 |
return new WP_REST_Response( [ |
| 2939 |
'success' => true, |
| 2940 |
'message' => 'OK', |
| 2941 |
'data' => $llms_txt |
| 2942 |
], 200 ); |
| 2943 |
|
| 2944 |
} catch ( Exception $e ) { |
| 2945 |
return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 2946 |
} |
| 2947 |
} |
| 2948 |
|
| 2949 |
/** |
| 2950 |
* Build the structured content payload the AI uses to write an llms.txt: |
| 2951 |
* site identity + a deduped list of Pages, Articles, Optional. |
| 2952 |
* |
| 2953 |
* Sources blended: front/blog page, main-menu pages, top-level pages by menu_order |
| 2954 |
* (Pages tier); top SEO-scored posts merged with top GSC pages if available |
| 2955 |
* (Articles tier); recent posts (Optional tier). All deduped by URL. |
| 2956 |
*/ |
| 2957 |
private function collect_llms_content() { |
| 2958 |
$site_url = get_site_url(); |
| 2959 |
$site_name = get_bloginfo( 'name' ); |
| 2960 |
$site_desc = get_bloginfo( 'description' ); |
| 2961 |
|
| 2962 |
$pages = []; |
| 2963 |
$articles = []; |
| 2964 |
$optional = []; |
| 2965 |
$seen = []; |
| 2966 |
|
| 2967 |
$add = function( &$bucket, $url, $title, $excerpt ) use ( &$seen ) { |
| 2968 |
if ( empty( $url ) || empty( $title ) ) return; |
| 2969 |
$url = $this->normalize_url( $url ); |
| 2970 |
if ( isset( $seen[ $url ] ) ) return; |
| 2971 |
$seen[ $url ] = true; |
| 2972 |
$bucket[] = [ |
| 2973 |
'title' => $this->shorten( $title, 90 ), |
| 2974 |
'url' => $url, |
| 2975 |
'excerpt' => $this->shorten( $excerpt, 240 ), |
| 2976 |
]; |
| 2977 |
}; |
| 2978 |
|
| 2979 |
// ---- Pages tier ---------------------------------------------------- |
| 2980 |
// Front page (if static), blog page, then main-menu pages, then top-level pages by menu_order. |
| 2981 |
$front_id = (int) get_option( 'page_on_front' ); |
| 2982 |
$blog_id = (int) get_option( 'page_for_posts' ); |
| 2983 |
if ( $front_id ) { |
| 2984 |
$p = get_post( $front_id ); |
| 2985 |
if ( $p ) $add( $pages, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 2986 |
} |
| 2987 |
if ( $blog_id && $blog_id !== $front_id ) { |
| 2988 |
$p = get_post( $blog_id ); |
| 2989 |
if ( $p ) $add( $pages, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 2990 |
} |
| 2991 |
|
| 2992 |
$menu_items = $this->fetch_primary_menu_pages(); |
| 2993 |
foreach ( $menu_items as $p ) { |
| 2994 |
$add( $pages, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 2995 |
} |
| 2996 |
|
| 2997 |
$top_pages = get_posts( [ |
| 2998 |
'post_type' => 'page', |
| 2999 |
'post_status' => 'publish', |
| 3000 |
'posts_per_page' => 10, |
| 3001 |
'orderby' => 'menu_order title', |
| 3002 |
'order' => 'ASC', |
| 3003 |
'post_parent' => 0, |
| 3004 |
] ); |
| 3005 |
foreach ( $top_pages as $p ) { |
| 3006 |
$add( $pages, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 3007 |
if ( count( $pages ) >= 12 ) break; |
| 3008 |
} |
| 3009 |
|
| 3010 |
// ---- Articles tier ------------------------------------------------- |
| 3011 |
// Top GSC pages first (if available), then top SEO-scored posts. |
| 3012 |
$gsc_pages = []; |
| 3013 |
if ( method_exists( $this->core, 'get_gsc_top_pages' ) ) { |
| 3014 |
$gsc_pages = $this->core->get_gsc_top_pages( [ 'days' => 28, 'limit' => 15 ] ); |
| 3015 |
if ( !is_array( $gsc_pages ) ) $gsc_pages = []; |
| 3016 |
} |
| 3017 |
foreach ( $gsc_pages as $row ) { |
| 3018 |
$post_id = isset( $row['post_id'] ) ? (int) $row['post_id'] : 0; |
| 3019 |
$url = isset( $row['url'] ) ? (string) $row['url'] : ''; |
| 3020 |
$title = isset( $row['post_title'] ) && !empty( $row['post_title'] ) ? $row['post_title'] : ''; |
| 3021 |
$excerpt = ''; |
| 3022 |
if ( $post_id ) { |
| 3023 |
$p = get_post( $post_id ); |
| 3024 |
if ( $p ) { |
| 3025 |
if ( empty( $title ) ) $title = $this->core->get_seo_title( $p ); |
| 3026 |
$excerpt = $this->core->get_seo_excerpt( $p ); |
| 3027 |
} |
| 3028 |
} |
| 3029 |
$add( $articles, $url, $title, $excerpt ); |
| 3030 |
if ( count( $articles ) >= 12 ) break; |
| 3031 |
} |
| 3032 |
|
| 3033 |
if ( count( $articles ) < 12 ) { |
| 3034 |
$scored = $this->core->get_all_posts_with_seo_score(); |
| 3035 |
if ( is_array( $scored ) ) { |
| 3036 |
usort( $scored, function( $a, $b ) { |
| 3037 |
return (int) ( $b['score'] ?? 0 ) - (int) ( $a['score'] ?? 0 ); |
| 3038 |
} ); |
| 3039 |
foreach ( $scored as $row ) { |
| 3040 |
if ( (int) ( $row['score'] ?? 0 ) < 60 ) break; |
| 3041 |
$pid = (int) ( $row['id'] ?? 0 ); |
| 3042 |
if ( !$pid ) continue; |
| 3043 |
$p = get_post( $pid ); |
| 3044 |
if ( !$p || $p->post_status !== 'publish' ) continue; |
| 3045 |
$add( $articles, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 3046 |
if ( count( $articles ) >= 12 ) break; |
| 3047 |
} |
| 3048 |
} |
| 3049 |
} |
| 3050 |
|
| 3051 |
// ---- Optional tier ------------------------------------------------- |
| 3052 |
$recent = wp_get_recent_posts( [ |
| 3053 |
'numberposts' => 15, |
| 3054 |
'post_status' => 'publish', |
| 3055 |
], OBJECT ); |
| 3056 |
if ( is_array( $recent ) ) { |
| 3057 |
foreach ( $recent as $p ) { |
| 3058 |
$add( $optional, get_permalink( $p ), $this->core->get_seo_title( $p ), $this->core->get_seo_excerpt( $p ) ); |
| 3059 |
if ( count( $optional ) >= 8 ) break; |
| 3060 |
} |
| 3061 |
} |
| 3062 |
|
| 3063 |
return [ |
| 3064 |
'site_name' => $site_name, |
| 3065 |
'site_url' => $site_url, |
| 3066 |
'site_description' => $site_desc, |
| 3067 |
'pages' => $pages, |
| 3068 |
'articles' => $articles, |
| 3069 |
'optional' => $optional, |
| 3070 |
]; |
| 3071 |
} |
| 3072 |
|
| 3073 |
private function fetch_primary_menu_pages() { |
| 3074 |
$locations = get_nav_menu_locations(); |
| 3075 |
$menu = null; |
| 3076 |
foreach ( [ 'primary', 'main', 'top', 'header' ] as $slug ) { |
| 3077 |
if ( !empty( $locations[ $slug ] ) ) { |
| 3078 |
$menu = wp_get_nav_menu_object( $locations[ $slug ] ); |
| 3079 |
if ( $menu ) break; |
| 3080 |
} |
| 3081 |
} |
| 3082 |
if ( !$menu && !empty( $locations ) ) { |
| 3083 |
$first = reset( $locations ); |
| 3084 |
$menu = wp_get_nav_menu_object( $first ); |
| 3085 |
} |
| 3086 |
if ( !$menu ) return []; |
| 3087 |
|
| 3088 |
$items = wp_get_nav_menu_items( $menu->term_id ); |
| 3089 |
if ( !is_array( $items ) ) return []; |
| 3090 |
|
| 3091 |
$posts = []; |
| 3092 |
foreach ( $items as $item ) { |
| 3093 |
if ( $item->object === 'page' || $item->object === 'post' ) { |
| 3094 |
$p = get_post( $item->object_id ); |
| 3095 |
if ( $p && $p->post_status === 'publish' ) $posts[] = $p; |
| 3096 |
} |
| 3097 |
if ( count( $posts ) >= 10 ) break; |
| 3098 |
} |
| 3099 |
return $posts; |
| 3100 |
} |
| 3101 |
|
| 3102 |
private function normalize_url( $url ) { |
| 3103 |
$url = trim( (string) $url ); |
| 3104 |
// Some GSC URLs come with a trailing slash mismatch; keep them as-is but strip fragments. |
| 3105 |
return preg_replace( '/#.*$/', '', $url ); |
| 3106 |
} |
| 3107 |
|
| 3108 |
private function shorten( $text, $max ) { |
| 3109 |
$text = trim( wp_strip_all_tags( (string) $text ) ); |
| 3110 |
if ( $text === '' ) return ''; |
| 3111 |
if ( function_exists( 'mb_strlen' ) ? mb_strlen( $text ) <= $max : strlen( $text ) <= $max ) { |
| 3112 |
return $text; |
| 3113 |
} |
| 3114 |
if ( function_exists( 'mb_substr' ) ) { |
| 3115 |
return rtrim( mb_substr( $text, 0, $max ) ); |
| 3116 |
} |
| 3117 |
return rtrim( substr( $text, 0, $max ) ); |
| 3118 |
} |
| 3119 |
|
| 3120 |
#endregion |
| 3121 |
|
| 3122 |
#region Sitemap |
| 3123 |
|
| 3124 |
function rest_sitemap_generate() { |
| 3125 |
try { |
| 3126 |
$res = $this->core->generate_sitemap(); |
| 3127 |
return new WP_REST_Response( [ |
| 3128 |
'success' => true, |
| 3129 |
'data' => $res |
| 3130 |
], 200 ); |
| 3131 |
} catch ( Exception $e ) { |
| 3132 |
return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 ); |
| 3133 |
} |
| 3134 |
} |
| 3135 |
|
| 3136 |
#endregion |
| 3137 |
|
| 3138 |
#region Redirects + 404 |
| 3139 |
|
| 3140 |
private function get_redirects_module() { |
| 3141 |
if ( !$this->core->redirects_module ) { |
| 3142 |
return null; |
| 3143 |
} |
| 3144 |
return $this->core->redirects_module; |
| 3145 |
} |
| 3146 |
|
| 3147 |
function rest_redirects_list( $request ) { |
| 3148 |
try { |
| 3149 |
$mod = $this->get_redirects_module(); |
| 3150 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3151 |
|
| 3152 |
$params = $request->get_json_params(); |
| 3153 |
$type = isset( $params['type'] ) && $params['type'] === 'not_found' ? 'not_found' : 'rule'; |
| 3154 |
|
| 3155 |
$args = array( |
| 3156 |
'search' => isset( $params['search'] ) ? (string) $params['search'] : '', |
| 3157 |
'sort' => isset( $params['sort'] ) ? (string) $params['sort'] : null, |
| 3158 |
'order' => isset( $params['order'] ) ? (string) $params['order'] : 'DESC', |
| 3159 |
'page' => isset( $params['page'] ) ? intval( $params['page'] ) : 1, |
| 3160 |
'limit' => isset( $params['limit'] ) ? intval( $params['limit'] ) : 50, |
| 3161 |
); |
| 3162 |
if ( $args['sort'] === null ) { unset( $args['sort'] ); } |
| 3163 |
|
| 3164 |
if ( $type === 'not_found' ) { |
| 3165 |
$args['include_ignored'] = !empty( $params['include_ignored'] ); |
| 3166 |
$data = $mod->list_404s( $args ); |
| 3167 |
} else { |
| 3168 |
if ( isset( $params['enabled'] ) && $params['enabled'] !== '' && $params['enabled'] !== null ) { |
| 3169 |
$args['enabled'] = (int) (bool) $params['enabled']; |
| 3170 |
} |
| 3171 |
$data = $mod->list_redirects( $args ); |
| 3172 |
} |
| 3173 |
|
| 3174 |
return $this->success_response( $data ); |
| 3175 |
} catch ( Exception $e ) { |
| 3176 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3177 |
} |
| 3178 |
} |
| 3179 |
|
| 3180 |
function rest_redirects_save( $request ) { |
| 3181 |
try { |
| 3182 |
$mod = $this->get_redirects_module(); |
| 3183 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3184 |
|
| 3185 |
$params = $request->get_json_params(); |
| 3186 |
$result = $mod->save_redirect( $params ); |
| 3187 |
if ( is_wp_error( $result ) ) { |
| 3188 |
$status = $result->get_error_code() === 'pro_required' ? 403 : 400; |
| 3189 |
return $this->error_response( $result->get_error_message(), $result->get_error_code(), $status ); |
| 3190 |
} |
| 3191 |
return $this->success_response( $result ); |
| 3192 |
} catch ( Exception $e ) { |
| 3193 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3194 |
} |
| 3195 |
} |
| 3196 |
|
| 3197 |
function rest_redirects_delete( $request ) { |
| 3198 |
try { |
| 3199 |
$mod = $this->get_redirects_module(); |
| 3200 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3201 |
|
| 3202 |
$params = $request->get_json_params(); |
| 3203 |
$ids = isset( $params['ids'] ) ? $params['ids'] : ( isset( $params['id'] ) ? array( $params['id'] ) : array() ); |
| 3204 |
$count = $mod->delete_redirects( $ids ); |
| 3205 |
return $this->success_response( array( 'deleted' => $count ) ); |
| 3206 |
} catch ( Exception $e ) { |
| 3207 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3208 |
} |
| 3209 |
} |
| 3210 |
|
| 3211 |
function rest_redirects_bulk( $request ) { |
| 3212 |
try { |
| 3213 |
$mod = $this->get_redirects_module(); |
| 3214 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3215 |
|
| 3216 |
$params = $request->get_json_params(); |
| 3217 |
$action = isset( $params['action'] ) ? (string) $params['action'] : ''; |
| 3218 |
$ids = isset( $params['ids'] ) ? $params['ids'] : array(); |
| 3219 |
if ( !in_array( $action, array( 'enable', 'disable', 'delete' ), true ) ) { |
| 3220 |
return $this->error_response( 'Unknown bulk action.', 'invalid_action', 400 ); |
| 3221 |
} |
| 3222 |
$count = $mod->bulk_redirects( $action, $ids ); |
| 3223 |
return $this->success_response( array( 'affected' => $count ) ); |
| 3224 |
} catch ( Exception $e ) { |
| 3225 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3226 |
} |
| 3227 |
} |
| 3228 |
|
| 3229 |
function rest_redirects_404_convert( $request ) { |
| 3230 |
try { |
| 3231 |
$mod = $this->get_redirects_module(); |
| 3232 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3233 |
|
| 3234 |
$params = $request->get_json_params(); |
| 3235 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3236 |
if ( $id <= 0 ) { |
| 3237 |
return $this->error_response( 'Missing 404 entry id.', 'invalid_id', 400 ); |
| 3238 |
} |
| 3239 |
$extra = array( |
| 3240 |
'target_url' => isset( $params['target_url'] ) ? (string) $params['target_url'] : '', |
| 3241 |
'status_code' => isset( $params['status_code'] ) ? intval( $params['status_code'] ) : 301, |
| 3242 |
'notes' => isset( $params['notes'] ) ? (string) $params['notes'] : 'Created from 404 log', |
| 3243 |
); |
| 3244 |
$result = $mod->convert_404( $id, $extra ); |
| 3245 |
if ( is_wp_error( $result ) ) { |
| 3246 |
return $this->error_response( $result->get_error_message(), $result->get_error_code(), 400 ); |
| 3247 |
} |
| 3248 |
return $this->success_response( $result ); |
| 3249 |
} catch ( Exception $e ) { |
| 3250 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3251 |
} |
| 3252 |
} |
| 3253 |
|
| 3254 |
function rest_redirects_404_ignore( $request ) { |
| 3255 |
try { |
| 3256 |
$mod = $this->get_redirects_module(); |
| 3257 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3258 |
|
| 3259 |
$params = $request->get_json_params(); |
| 3260 |
$ids = isset( $params['ids'] ) ? $params['ids'] : ( isset( $params['id'] ) ? array( $params['id'] ) : array() ); |
| 3261 |
$ignored = !isset( $params['ignored'] ) ? true : (bool) $params['ignored']; |
| 3262 |
$count = $mod->ignore_404( $ids, $ignored ); |
| 3263 |
return $this->success_response( array( 'affected' => $count ) ); |
| 3264 |
} catch ( Exception $e ) { |
| 3265 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3266 |
} |
| 3267 |
} |
| 3268 |
|
| 3269 |
function rest_redirects_404_clear( $request ) { |
| 3270 |
try { |
| 3271 |
$mod = $this->get_redirects_module(); |
| 3272 |
if ( !$mod ) { return $this->error_response( 'Redirects module unavailable.', 'no_module', 500 ); } |
| 3273 |
|
| 3274 |
$params = $request->get_json_params(); |
| 3275 |
$mode = isset( $params['mode'] ) ? (string) $params['mode'] : 'all'; |
| 3276 |
if ( $mode === 'ids' ) { |
| 3277 |
$ids = isset( $params['ids'] ) ? $params['ids'] : array(); |
| 3278 |
$count = $mod->delete_404s( $ids ); |
| 3279 |
} else { |
| 3280 |
$older_than = isset( $params['older_than_days'] ) ? intval( $params['older_than_days'] ) : 0; |
| 3281 |
$count = $mod->clear_404s( $older_than ); |
| 3282 |
} |
| 3283 |
return $this->success_response( array( 'deleted' => $count ) ); |
| 3284 |
} catch ( Exception $e ) { |
| 3285 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3286 |
} |
| 3287 |
} |
| 3288 |
|
| 3289 |
#endregion |
| 3290 |
|
| 3291 |
#region AI Visibility |
| 3292 |
|
| 3293 |
private function get_ai_visibility_module() { |
| 3294 |
$mod = $this->core->ai_visibility(); |
| 3295 |
// The module object exists even when the feature is off, but its tables |
| 3296 |
// do not, so anything that touches them must check is_enabled() first. |
| 3297 |
return ( $mod && $mod->is_enabled() ) ? $mod : null; |
| 3298 |
} |
| 3299 |
|
| 3300 |
function rest_ai_visibility_config( $request ) { |
| 3301 |
try { |
| 3302 |
$mod = $this->get_ai_visibility_module(); |
| 3303 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3304 |
return $this->success_response( array( |
| 3305 |
'overview' => $mod->get_overview(), |
| 3306 |
'surfaces' => $mod->get_surfaces(), |
| 3307 |
'options' => $mod->get_surfaces_options(), |
| 3308 |
) ); |
| 3309 |
} catch ( Exception $e ) { |
| 3310 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3311 |
} |
| 3312 |
} |
| 3313 |
|
| 3314 |
function rest_ai_visibility_save_surfaces( $request ) { |
| 3315 |
try { |
| 3316 |
$mod = $this->get_ai_visibility_module(); |
| 3317 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3318 |
$params = $request->get_json_params(); |
| 3319 |
$surfaces = isset( $params['surfaces'] ) ? $params['surfaces'] : array(); |
| 3320 |
return $this->success_response( array( 'surfaces' => $mod->save_surfaces( $surfaces ) ) ); |
| 3321 |
} catch ( Exception $e ) { |
| 3322 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3323 |
} |
| 3324 |
} |
| 3325 |
|
| 3326 |
function rest_ai_visibility_brands( $request ) { |
| 3327 |
try { |
| 3328 |
$mod = $this->get_ai_visibility_module(); |
| 3329 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3330 |
return $this->success_response( array( 'brands' => $mod->list_brands() ) ); |
| 3331 |
} catch ( Exception $e ) { |
| 3332 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3333 |
} |
| 3334 |
} |
| 3335 |
|
| 3336 |
function rest_ai_visibility_save_brand( $request ) { |
| 3337 |
try { |
| 3338 |
$mod = $this->get_ai_visibility_module(); |
| 3339 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3340 |
$result = $mod->save_brand( $request->get_json_params() ); |
| 3341 |
if ( is_wp_error( $result ) ) { |
| 3342 |
return $this->error_response( $result->get_error_message(), $result->get_error_code(), 400 ); |
| 3343 |
} |
| 3344 |
return $this->success_response( array( 'brand' => $result ) ); |
| 3345 |
} catch ( Exception $e ) { |
| 3346 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3347 |
} |
| 3348 |
} |
| 3349 |
|
| 3350 |
function rest_ai_visibility_delete_brand( $request ) { |
| 3351 |
try { |
| 3352 |
$mod = $this->get_ai_visibility_module(); |
| 3353 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3354 |
$params = $request->get_json_params(); |
| 3355 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3356 |
return $this->success_response( array( 'deleted' => $mod->delete_brand( $id ) ) ); |
| 3357 |
} catch ( Exception $e ) { |
| 3358 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3359 |
} |
| 3360 |
} |
| 3361 |
|
| 3362 |
function rest_ai_visibility_brand_detail( $request ) { |
| 3363 |
try { |
| 3364 |
$mod = $this->get_ai_visibility_module(); |
| 3365 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3366 |
$params = $request->get_json_params(); |
| 3367 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3368 |
$brand = $mod->get_brand( $id ); |
| 3369 |
if ( !$brand ) { return $this->error_response( 'Brand not found.', 'not_found', 404 ); } |
| 3370 |
return $this->success_response( array( |
| 3371 |
'brand' => $brand, |
| 3372 |
'queries' => $mod->get_brand_queries( $id ), |
| 3373 |
'competitors' => $mod->get_brand_competitors( $id ), |
| 3374 |
'transcripts' => $mod->get_brand_transcripts( $id ), |
| 3375 |
'timeseries' => $mod->get_brand_timeseries( $id ), |
| 3376 |
) ); |
| 3377 |
} catch ( Exception $e ) { |
| 3378 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3379 |
} |
| 3380 |
} |
| 3381 |
|
| 3382 |
function rest_ai_visibility_generate_queries( $request ) { |
| 3383 |
try { |
| 3384 |
$mod = $this->get_ai_visibility_module(); |
| 3385 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3386 |
$result = $mod->generate_queries( $request->get_json_params() ); |
| 3387 |
if ( is_wp_error( $result ) ) { |
| 3388 |
return $this->error_response( $result->get_error_message(), $result->get_error_code(), 400 ); |
| 3389 |
} |
| 3390 |
return $this->success_response( array( 'queries' => $result ) ); |
| 3391 |
} catch ( Exception $e ) { |
| 3392 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3393 |
} |
| 3394 |
} |
| 3395 |
|
| 3396 |
function rest_ai_visibility_scan_plan( $request ) { |
| 3397 |
try { |
| 3398 |
$mod = $this->get_ai_visibility_module(); |
| 3399 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3400 |
$params = $request->get_json_params(); |
| 3401 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3402 |
$plan = $mod->build_scan_plan( $id ); |
| 3403 |
if ( is_wp_error( $plan ) ) { |
| 3404 |
return $this->error_response( $plan->get_error_message(), $plan->get_error_code(), 400 ); |
| 3405 |
} |
| 3406 |
return $this->success_response( $plan ); |
| 3407 |
} catch ( Exception $e ) { |
| 3408 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3409 |
} |
| 3410 |
} |
| 3411 |
|
| 3412 |
function rest_ai_visibility_scan_one( $request ) { |
| 3413 |
try { |
| 3414 |
$mod = $this->get_ai_visibility_module(); |
| 3415 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3416 |
$params = $request->get_json_params(); |
| 3417 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3418 |
$query = isset( $params['query'] ) ? (string) $params['query'] : ''; |
| 3419 |
$surface = isset( $params['surface'] ) && is_array( $params['surface'] ) ? $params['surface'] : array(); |
| 3420 |
$batch = isset( $params['batch'] ) ? (string) $params['batch'] : ''; |
| 3421 |
$result = $mod->scan_one( $id, $query, $surface, $batch ); |
| 3422 |
if ( is_wp_error( $result ) ) { |
| 3423 |
return $this->error_response( $result->get_error_message(), $result->get_error_code(), 400 ); |
| 3424 |
} |
| 3425 |
return $this->success_response( $result ); |
| 3426 |
} catch ( Exception $e ) { |
| 3427 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3428 |
} |
| 3429 |
} |
| 3430 |
|
| 3431 |
function rest_ai_visibility_scan_finalize( $request ) { |
| 3432 |
try { |
| 3433 |
$mod = $this->get_ai_visibility_module(); |
| 3434 |
if ( !$mod ) { return $this->error_response( 'AI Visibility module unavailable.', 'no_module', 500 ); } |
| 3435 |
$params = $request->get_json_params(); |
| 3436 |
$id = isset( $params['id'] ) ? intval( $params['id'] ) : 0; |
| 3437 |
return $this->success_response( array( 'brand' => $mod->finalize_scan( $id ) ) ); |
| 3438 |
} catch ( Exception $e ) { |
| 3439 |
return $this->error_response( $e->getMessage(), 'exception', 500 ); |
| 3440 |
} |
| 3441 |
} |
| 3442 |
|
| 3443 |
#endregion |
| 3444 |
|
| 3445 |
#region Analytics |
| 3446 |
|
| 3447 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3448 |
function rest_get_posts_visitor_series( $request ) { |
| 3449 |
$params = $request->get_json_params(); |
| 3450 |
$post_ids = isset( $params['post_ids'] ) && is_array( $params['post_ids'] ) ? $params['post_ids'] : array(); |
| 3451 |
$days = isset( $params['days'] ) ? (int) $params['days'] : 30; |
| 3452 |
return new WP_REST_Response( [ |
| 3453 |
'success' => true, |
| 3454 |
'data' => $this->core->get_posts_visitor_series( $post_ids, $days ) |
| 3455 |
], 200 ); |
| 3456 |
} |
| 3457 |
|
| 3458 |
function rest_get_analytics_data( $request ) { |
| 3459 |
try { |
| 3460 |
$params = $request->get_json_params(); |
| 3461 |
$args = array( |
| 3462 |
'post_id' => isset( $params['post_id'] ) ? intval( $params['post_id'] ) : null, |
| 3463 |
'start_date' => isset( $params['start_date'] ) ? $params['start_date'] : null, |
| 3464 |
'end_date' => isset( $params['end_date'] ) ? $params['end_date'] : null, |
| 3465 |
'group_by' => isset( $params['group_by'] ) ? $params['group_by'] : 'day', |
| 3466 |
'limit' => isset( $params['limit'] ) ? intval( $params['limit'] ) : 100 |
| 3467 |
); |
| 3468 |
$data = $this->core->get_analytics_data( $args ); |
| 3469 |
|
| 3470 |
return new WP_REST_Response( [ |
| 3471 |
'success' => true, |
| 3472 |
'data' => $data |
| 3473 |
], 200 ); |
| 3474 |
|
| 3475 |
} |
| 3476 |
catch ( Exception $e ) { |
| 3477 |
return new WP_REST_Response( [ |
| 3478 |
'success' => false, |
| 3479 |
'message' => $e->getMessage() |
| 3480 |
], 500 ); |
| 3481 |
} |
| 3482 |
} |
| 3483 |
|
| 3484 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3485 |
function rest_get_analytics_summary( $request ) { |
| 3486 |
try { |
| 3487 |
$params = $request->get_json_params(); |
| 3488 |
$start_date = isset( $params['start_date'] ) ? $params['start_date'] : null; |
| 3489 |
$end_date = isset( $params['end_date'] ) ? $params['end_date'] : null; |
| 3490 |
$data = $this->core->get_analytics_summary( $start_date, $end_date ); |
| 3491 |
|
| 3492 |
return new WP_REST_Response( [ |
| 3493 |
'success' => true, |
| 3494 |
'data' => $data |
| 3495 |
], 200 ); |
| 3496 |
|
| 3497 |
} |
| 3498 |
catch ( Exception $e ) { |
| 3499 |
return new WP_REST_Response( [ |
| 3500 |
'success' => false, |
| 3501 |
'message' => $e->getMessage() |
| 3502 |
], 500 ); |
| 3503 |
} |
| 3504 |
} |
| 3505 |
|
| 3506 |
function rest_get_analytics_realtime( $request ) { |
| 3507 |
// Realtime is a bonus card, never a reason to fail the whole dashboard. |
| 3508 |
try { |
| 3509 |
$data = $this->core->get_analytics_realtime_data(); |
| 3510 |
} catch ( Exception $e ) { |
| 3511 |
$data = array(); |
| 3512 |
} |
| 3513 |
|
| 3514 |
return new WP_REST_Response( [ |
| 3515 |
'success' => true, |
| 3516 |
'data' => $data |
| 3517 |
], 200 ); |
| 3518 |
} |
| 3519 |
|
| 3520 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3521 |
function rest_get_top_posts( $request ) { |
| 3522 |
try { |
| 3523 |
$params = $request->get_json_params(); |
| 3524 |
$args = array( |
| 3525 |
'start_date' => isset( $params['start_date'] ) ? $params['start_date'] : null, |
| 3526 |
'end_date' => isset( $params['end_date'] ) ? $params['end_date'] : null, |
| 3527 |
'limit' => isset( $params['limit'] ) ? intval( $params['limit'] ) : 10 |
| 3528 |
); |
| 3529 |
$data = $this->core->get_top_posts( $args ); |
| 3530 |
|
| 3531 |
return new WP_REST_Response( [ |
| 3532 |
'success' => true, |
| 3533 |
'data' => $data |
| 3534 |
], 200 ); |
| 3535 |
|
| 3536 |
} |
| 3537 |
catch ( Exception $e ) { |
| 3538 |
return new WP_REST_Response( [ |
| 3539 |
'success' => false, |
| 3540 |
'message' => $e->getMessage() |
| 3541 |
], 500 ); |
| 3542 |
} |
| 3543 |
} |
| 3544 |
|
| 3545 |
function rest_get_ai_agents_summary( $request ) { |
| 3546 |
try { |
| 3547 |
$params = $request->get_json_params(); |
| 3548 |
$start_date = isset( $params['start_date'] ) ? $params['start_date'] : null; |
| 3549 |
$end_date = isset( $params['end_date'] ) ? $params['end_date'] : null; |
| 3550 |
$data = $this->core->get_ai_agents_summary( $start_date, $end_date ); |
| 3551 |
|
| 3552 |
return new WP_REST_Response( [ |
| 3553 |
'success' => true, |
| 3554 |
'data' => $data |
| 3555 |
], 200 ); |
| 3556 |
|
| 3557 |
} |
| 3558 |
catch ( Exception $e ) { |
| 3559 |
return new WP_REST_Response( [ |
| 3560 |
'success' => false, |
| 3561 |
'message' => $e->getMessage() |
| 3562 |
], 500 ); |
| 3563 |
} |
| 3564 |
} |
| 3565 |
|
| 3566 |
function rest_get_ai_agent_details( $request ) { |
| 3567 |
try { |
| 3568 |
$params = $request->get_json_params(); |
| 3569 |
$bot_name = isset( $params['bot_name'] ) ? $params['bot_name'] : null; |
| 3570 |
$start_date = isset( $params['start_date'] ) ? $params['start_date'] : null; |
| 3571 |
$end_date = isset( $params['end_date'] ) ? $params['end_date'] : null; |
| 3572 |
|
| 3573 |
if ( !$bot_name ) { |
| 3574 |
return new WP_REST_Response( [ |
| 3575 |
'success' => false, |
| 3576 |
'message' => 'Bot name is required' |
| 3577 |
], 400 ); |
| 3578 |
} |
| 3579 |
|
| 3580 |
$data = $this->core->get_ai_agent_details( $bot_name, $start_date, $end_date ); |
| 3581 |
|
| 3582 |
return new WP_REST_Response( [ |
| 3583 |
'success' => true, |
| 3584 |
'data' => $data |
| 3585 |
], 200 ); |
| 3586 |
|
| 3587 |
} |
| 3588 |
catch ( Exception $e ) { |
| 3589 |
return new WP_REST_Response( [ |
| 3590 |
'success' => false, |
| 3591 |
'message' => $e->getMessage() |
| 3592 |
], 500 ); |
| 3593 |
} |
| 3594 |
} |
| 3595 |
|
| 3596 |
function rest_get_ai_agents_by_post( $request ) { |
| 3597 |
try { |
| 3598 |
$params = $request->get_json_params(); |
| 3599 |
$post_id = isset( $params['post_id'] ) ? intval( $params['post_id'] ) : null; |
| 3600 |
$days = isset( $params['days'] ) ? intval( $params['days'] ) : 30; |
| 3601 |
|
| 3602 |
if ( !$post_id ) { |
| 3603 |
return new WP_REST_Response( [ |
| 3604 |
'success' => false, |
| 3605 |
'message' => 'Post ID is required' |
| 3606 |
], 400 ); |
| 3607 |
} |
| 3608 |
|
| 3609 |
$data = $this->core->get_ai_agents_by_post( $post_id, $days ); |
| 3610 |
|
| 3611 |
return new WP_REST_Response( [ |
| 3612 |
'success' => true, |
| 3613 |
'data' => $data |
| 3614 |
], 200 ); |
| 3615 |
|
| 3616 |
} |
| 3617 |
catch ( Exception $e ) { |
| 3618 |
return new WP_REST_Response( [ |
| 3619 |
'success' => false, |
| 3620 |
'message' => $e->getMessage() |
| 3621 |
], 500 ); |
| 3622 |
} |
| 3623 |
} |
| 3624 |
|
| 3625 |
function rest_check_google_analytics_authenticated() { |
| 3626 |
$is_authenticated = $this->core->get_is_authenticated(); |
| 3627 |
return new WP_REST_Response( [ |
| 3628 |
'success' => true, |
| 3629 |
'is_authenticated' => $is_authenticated, |
| 3630 |
], 200 ); |
| 3631 |
} |
| 3632 |
|
| 3633 |
function rest_get_google_analytics_auth() { |
| 3634 |
$auth_url = $this->core->get_google_auth_url(); |
| 3635 |
if ( $auth_url ) { |
| 3636 |
return new WP_REST_Response( [ |
| 3637 |
'success' => true, |
| 3638 |
'auth_url' => $auth_url |
| 3639 |
], 200 ); |
| 3640 |
} |
| 3641 |
else { |
| 3642 |
return new WP_REST_Response( [ |
| 3643 |
'success' => false, |
| 3644 |
'message' => 'Failed to get Google Analytics redirect URL.' |
| 3645 |
], 500 ); |
| 3646 |
} |
| 3647 |
} |
| 3648 |
|
| 3649 |
function rest_unlink_google_analytics() { |
| 3650 |
$res = $this->core->unlink_google_analytics(); |
| 3651 |
if ( $res ) { |
| 3652 |
return new WP_REST_Response( [ |
| 3653 |
'success' => true, |
| 3654 |
'message' => 'Google Analytics unlinked successfully.' |
| 3655 |
], 200 ); |
| 3656 |
} |
| 3657 |
else { |
| 3658 |
return new WP_REST_Response( [ |
| 3659 |
'success' => false, |
| 3660 |
'message' => 'Failed to unlink Google Analytics.' |
| 3661 |
], 500 ); |
| 3662 |
} |
| 3663 |
} |
| 3664 |
|
| 3665 |
function rest_check_gsc_authenticated() { |
| 3666 |
return new WP_REST_Response( [ |
| 3667 |
'success' => true, |
| 3668 |
'is_authenticated' => $this->core->is_gsc_authenticated(), |
| 3669 |
'property' => $this->core->pro && $this->core->pro->search_console |
| 3670 |
? $this->core->pro->search_console->get_property() : '', |
| 3671 |
], 200 ); |
| 3672 |
} |
| 3673 |
|
| 3674 |
function rest_get_gsc_auth() { |
| 3675 |
$auth_url = $this->core->get_gsc_auth_url(); |
| 3676 |
if ( $auth_url ) { |
| 3677 |
return new WP_REST_Response( [ |
| 3678 |
'success' => true, |
| 3679 |
'auth_url' => $auth_url |
| 3680 |
], 200 ); |
| 3681 |
} |
| 3682 |
return new WP_REST_Response( [ |
| 3683 |
'success' => false, |
| 3684 |
'message' => 'Search Console requires Pro and your Google Client ID/Secret in settings.' |
| 3685 |
], 500 ); |
| 3686 |
} |
| 3687 |
|
| 3688 |
function rest_unlink_gsc() { |
| 3689 |
$res = $this->core->unlink_gsc(); |
| 3690 |
if ( $res ) { |
| 3691 |
return new WP_REST_Response( [ |
| 3692 |
'success' => true, |
| 3693 |
'message' => 'Google Search Console unlinked successfully.' |
| 3694 |
], 200 ); |
| 3695 |
} |
| 3696 |
return new WP_REST_Response( [ |
| 3697 |
'success' => false, |
| 3698 |
'message' => 'Failed to unlink Google Search Console.' |
| 3699 |
], 500 ); |
| 3700 |
} |
| 3701 |
|
| 3702 |
function rest_list_gsc_properties() { |
| 3703 |
$properties = $this->core->list_gsc_properties(); |
| 3704 |
$last_error = ''; |
| 3705 |
if ( $this->core->pro && $this->core->pro->search_console ) { |
| 3706 |
$last_error = $this->core->pro->search_console->get_last_error() ?: ''; |
| 3707 |
} |
| 3708 |
return new WP_REST_Response( [ |
| 3709 |
'success' => true, |
| 3710 |
'properties' => $properties, |
| 3711 |
'last_error' => $last_error, |
| 3712 |
], 200 ); |
| 3713 |
} |
| 3714 |
|
| 3715 |
function rest_set_gsc_property( $request ) { |
| 3716 |
$params = $request->get_json_params(); |
| 3717 |
$property = isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : ''; |
| 3718 |
if ( empty( $property ) ) { |
| 3719 |
return new WP_REST_Response( [ |
| 3720 |
'success' => false, |
| 3721 |
'message' => 'property is required.' |
| 3722 |
], 400 ); |
| 3723 |
} |
| 3724 |
$this->core->set_gsc_property( $property ); |
| 3725 |
return new WP_REST_Response( [ |
| 3726 |
'success' => true, |
| 3727 |
'property' => $property, |
| 3728 |
'tracked' => $this->core->pro && $this->core->pro->search_console |
| 3729 |
? $this->core->pro->search_console->get_tracked_properties() : [], |
| 3730 |
'message' => 'Active Search Console property updated.' |
| 3731 |
], 200 ); |
| 3732 |
} |
| 3733 |
|
| 3734 |
function rest_get_gsc_movers( $request ) { |
| 3735 |
$params = $request->get_json_params(); |
| 3736 |
$args = [ |
| 3737 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3738 |
'limit' => isset( $params['limit'] ) ? (int) $params['limit'] : 4, |
| 3739 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3740 |
'fresh' => !empty( $params['fresh'] ), |
| 3741 |
]; |
| 3742 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_page_movers( $args ) ], 200 ); |
| 3743 |
} |
| 3744 |
|
| 3745 |
function rest_get_gsc_timeseries( $request ) { |
| 3746 |
$params = $request->get_json_params(); |
| 3747 |
$args = [ |
| 3748 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3749 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3750 |
'fresh' => !empty( $params['fresh'] ), |
| 3751 |
]; |
| 3752 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_timeseries( $args ) ], 200 ); |
| 3753 |
} |
| 3754 |
|
| 3755 |
function rest_get_gsc_summary( $request ) { |
| 3756 |
$params = $request->get_json_params(); |
| 3757 |
$args = [ |
| 3758 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3759 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3760 |
'fresh' => !empty( $params['fresh'] ), |
| 3761 |
]; |
| 3762 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_summary( $args ) ], 200 ); |
| 3763 |
} |
| 3764 |
|
| 3765 |
function rest_get_gsc_quick_wins( $request ) { |
| 3766 |
$params = $request->get_json_params(); |
| 3767 |
$args = [ |
| 3768 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3769 |
'min_impressions' => isset( $params['min_impressions'] ) ? (int) $params['min_impressions'] : 50, |
| 3770 |
'limit_per_category' => isset( $params['limit_per_category'] ) ? (int) $params['limit_per_category'] : 3, |
| 3771 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3772 |
'fresh' => !empty( $params['fresh'] ), |
| 3773 |
]; |
| 3774 |
return new WP_REST_Response( $this->core->get_gsc_quick_wins( $args ), 200 ); |
| 3775 |
} |
| 3776 |
|
| 3777 |
function rest_get_gsc_top_pages( $request ) { |
| 3778 |
$params = $request->get_json_params(); |
| 3779 |
$args = [ |
| 3780 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3781 |
'limit' => isset( $params['limit'] ) ? (int) $params['limit'] : 10, |
| 3782 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3783 |
'fresh' => !empty( $params['fresh'] ), |
| 3784 |
]; |
| 3785 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_top_pages( $args ) ], 200 ); |
| 3786 |
} |
| 3787 |
|
| 3788 |
function rest_get_gsc_top_queries( $request ) { |
| 3789 |
$params = $request->get_json_params(); |
| 3790 |
$args = [ |
| 3791 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3792 |
'limit' => isset( $params['limit'] ) ? (int) $params['limit'] : 10, |
| 3793 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3794 |
'fresh' => !empty( $params['fresh'] ), |
| 3795 |
]; |
| 3796 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_top_queries( $args ) ], 200 ); |
| 3797 |
} |
| 3798 |
|
| 3799 |
function rest_get_gsc_post_metrics_map( $request ) { |
| 3800 |
$days = $request->get_param( 'days' ) ? (int) $request->get_param( 'days' ) : 28; |
| 3801 |
$args = [ 'days' => $days, 'fresh' => !empty( $request->get_param( 'fresh' ) ) ]; |
| 3802 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_post_metrics_map( $args ) ], 200 ); |
| 3803 |
} |
| 3804 |
|
| 3805 |
function rest_get_gsc_post_pulse( $request ) { |
| 3806 |
$post_id = (int) $request->get_param( 'post_id' ); |
| 3807 |
if ( !$post_id ) { |
| 3808 |
return $this->error_response( 'Missing post_id', 'no_post_id' ); |
| 3809 |
} |
| 3810 |
$days = $request->get_param( 'days' ) ? (int) $request->get_param( 'days' ) : 28; |
| 3811 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_post_pulse( $post_id, $days ) ], 200 ); |
| 3812 |
} |
| 3813 |
|
| 3814 |
function rest_get_gsc_breakdown( $request ) { |
| 3815 |
$params = $request->get_json_params(); |
| 3816 |
$args = [ |
| 3817 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3818 |
'limit' => isset( $params['limit'] ) ? (int) $params['limit'] : 12, |
| 3819 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3820 |
'fresh' => !empty( $params['fresh'] ), |
| 3821 |
]; |
| 3822 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_search_breakdown( $args ) ], 200 ); |
| 3823 |
} |
| 3824 |
|
| 3825 |
function rest_get_gsc_pages_with_issues( $request ) { |
| 3826 |
$params = $request->get_json_params(); |
| 3827 |
$args = [ |
| 3828 |
'days' => isset( $params['days'] ) ? (int) $params['days'] : 28, |
| 3829 |
'limit' => isset( $params['limit'] ) ? (int) $params['limit'] : 12, |
| 3830 |
'property' => isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : null, |
| 3831 |
'fresh' => !empty( $params['fresh'] ), |
| 3832 |
]; |
| 3833 |
return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_gsc_pages_with_issues( $args ) ], 200 ); |
| 3834 |
} |
| 3835 |
|
| 3836 |
function rest_toggle_gsc_tracked( $request ) { |
| 3837 |
$params = $request->get_json_params(); |
| 3838 |
$property = isset( $params['property'] ) ? sanitize_text_field( $params['property'] ) : ''; |
| 3839 |
$tracked = isset( $params['tracked'] ) ? (bool) $params['tracked'] : false; |
| 3840 |
if ( empty( $property ) ) { |
| 3841 |
return new WP_REST_Response( [ |
| 3842 |
'success' => false, |
| 3843 |
'message' => 'property is required.' |
| 3844 |
], 400 ); |
| 3845 |
} |
| 3846 |
$ok = $this->core->toggle_gsc_tracked_property( $property, $tracked ); |
| 3847 |
if ( !$ok ) { |
| 3848 |
return new WP_REST_Response( [ |
| 3849 |
'success' => false, |
| 3850 |
'message' => 'Could not change tracked state — the active default cannot be untracked.' |
| 3851 |
], 400 ); |
| 3852 |
} |
| 3853 |
return new WP_REST_Response( [ |
| 3854 |
'success' => true, |
| 3855 |
'tracked' => $this->core->pro && $this->core->pro->search_console |
| 3856 |
? $this->core->pro->search_console->get_tracked_properties() : [], |
| 3857 |
], 200 ); |
| 3858 |
} |
| 3859 |
|
| 3860 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3861 |
function rest_get_google_analytics_data( $request ) { |
| 3862 |
try { |
| 3863 |
$params = $request->get_json_params(); |
| 3864 |
$args = array( |
| 3865 |
'start_date' => isset( $params['start_date'] ) ? $params['start_date'] : null, |
| 3866 |
'end_date' => isset( $params['end_date'] ) ? $params['end_date'] : null, |
| 3867 |
'group_by' => isset( $params['group_by'] ) ? $params['group_by'] : 'day', |
| 3868 |
'limit' => isset( $params['limit'] ) ? intval( $params['limit'] ) : 100 |
| 3869 |
); |
| 3870 |
$data = $this->core->get_google_analytics_data( $args ); |
| 3871 |
|
| 3872 |
return new WP_REST_Response( [ |
| 3873 |
'success' => true, |
| 3874 |
'data' => $data, |
| 3875 |
'has_error' => false, |
| 3876 |
'error_message' => null |
| 3877 |
], 200 ); |
| 3878 |
|
| 3879 |
} |
| 3880 |
catch ( Exception $e ) { |
| 3881 |
// Return error in the same format as success, but with has_error flag |
| 3882 |
return new WP_REST_Response( [ |
| 3883 |
'success' => true, |
| 3884 |
'data' => array(), |
| 3885 |
'has_error' => true, |
| 3886 |
'error_message' => $e->getMessage() |
| 3887 |
], 200 ); |
| 3888 |
} |
| 3889 |
} |
| 3890 |
|
| 3891 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3892 |
function rest_get_google_analytics_summary( $request ) { |
| 3893 |
try { |
| 3894 |
$params = $request->get_json_params(); |
| 3895 |
|
| 3896 |
$start_date = isset( $params['start_date'] ) ? $params['start_date'] : null; |
| 3897 |
$end_date = isset( $params['end_date'] ) ? $params['end_date'] : null; |
| 3898 |
|
| 3899 |
$data = $this->core->get_google_analytics_summary( $start_date, $end_date ); |
| 3900 |
|
| 3901 |
return new WP_REST_Response( [ |
| 3902 |
'success' => true, |
| 3903 |
'data' => $data, |
| 3904 |
'has_error' => false, |
| 3905 |
'error_message' => null |
| 3906 |
], 200 ); |
| 3907 |
|
| 3908 |
} catch ( Exception $e ) { |
| 3909 |
return new WP_REST_Response( [ |
| 3910 |
'success' => true, |
| 3911 |
'data' => array(), |
| 3912 |
'has_error' => true, |
| 3913 |
'error_message' => $e->getMessage() |
| 3914 |
], 200 ); |
| 3915 |
} |
| 3916 |
} |
| 3917 |
|
| 3918 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3919 |
function rest_get_google_analytics_top_posts( $request ) { |
| 3920 |
try { |
| 3921 |
$params = $request->get_json_params(); |
| 3922 |
|
| 3923 |
$args = array( |
| 3924 |
'start_date' => isset( $params['start_date'] ) ? $params['start_date'] : null, |
| 3925 |
'end_date' => isset( $params['end_date'] ) ? $params['end_date'] : null, |
| 3926 |
'limit' => isset( $params['limit'] ) ? intval( $params['limit'] ) : 10 |
| 3927 |
); |
| 3928 |
|
| 3929 |
$data = $this->core->get_google_analytics_top_posts( $args ); |
| 3930 |
|
| 3931 |
return new WP_REST_Response( [ |
| 3932 |
'success' => true, |
| 3933 |
'data' => $data, |
| 3934 |
'has_error' => false, |
| 3935 |
'error_message' => null |
| 3936 |
], 200 ); |
| 3937 |
|
| 3938 |
} catch ( Exception $e ) { |
| 3939 |
return new WP_REST_Response( [ |
| 3940 |
'success' => true, |
| 3941 |
'data' => array(), |
| 3942 |
'has_error' => true, |
| 3943 |
'error_message' => $e->getMessage() |
| 3944 |
], 200 ); |
| 3945 |
} |
| 3946 |
} |
| 3947 |
|
| 3948 |
// TODO [2025]: Refactor to unified analytics provider interface |
| 3949 |
function rest_get_google_analytics_realtime( $request ) { |
| 3950 |
try { |
| 3951 |
$data = $this->core->get_google_analytics_realtime_data(); |
| 3952 |
|
| 3953 |
return new WP_REST_Response( [ |
| 3954 |
'success' => true, |
| 3955 |
'data' => $data |
| 3956 |
], 200 ); |
| 3957 |
|
| 3958 |
} catch ( Exception $e ) { |
| 3959 |
// Note: Realtime errors are not fatal, just return empty data |
| 3960 |
// The main data/summary/top_posts will show the error |
| 3961 |
return new WP_REST_Response( [ |
| 3962 |
'success' => true, |
| 3963 |
'data' => array() |
| 3964 |
], 200 ); |
| 3965 |
} |
| 3966 |
} |
| 3967 |
|
| 3968 |
function rest_reset_data( $request ) { |
| 3969 |
try { |
| 3970 |
global $wpdb; |
| 3971 |
$prefix = $wpdb->prefix; |
| 3972 |
|
| 3973 |
// Delete all SEO analysis data from post meta |
| 3974 |
$wpdb->query( "DELETE FROM {$prefix}postmeta WHERE meta_key LIKE '_mwseo_%'" ); |
| 3975 |
|
| 3976 |
// Delete analytics tables if they exist |
| 3977 |
$analytics_table = $prefix . 'mwseo_analytics'; |
| 3978 |
$ai_agents_table = $prefix . 'mwseo_ai_agents'; |
| 3979 |
|
| 3980 |
$wpdb->query( "TRUNCATE TABLE {$analytics_table}" ); |
| 3981 |
$wpdb->query( "TRUNCATE TABLE {$ai_agents_table}" ); |
| 3982 |
|
| 3983 |
// Clear any cached data. wp_cache_flush() misses transients kept in the options |
| 3984 |
// table, so the score caches have to be removed explicitly. |
| 3985 |
$this->delete_transients_by_prefix( self::$score_transient_prefixes ); |
| 3986 |
Meow_MWSEO_Score::bump_cache_version(); |
| 3987 |
wp_cache_flush(); |
| 3988 |
|
| 3989 |
return $this->success_response( null, 'All SEO Engine data has been reset successfully.' ); |
| 3990 |
|
| 3991 |
} catch ( Exception $e ) { |
| 3992 |
return $this->error_response( $e->getMessage(), 'reset_data_failed', 500 ); |
| 3993 |
} |
| 3994 |
} |
| 3995 |
|
| 3996 |
/** |
| 3997 |
* Baseline analysis - runs technical checks only, returns AI steps to run |
| 3998 |
* POST /analysis/baseline |
| 3999 |
* Body: { post_id: 123 } |
| 4000 |
*/ |
| 4001 |
function rest_analysis_baseline( $request ) { |
| 4002 |
global $mwseo_score; |
| 4003 |
|
| 4004 |
try { |
| 4005 |
$params = $request->get_json_params(); |
| 4006 |
$post_id = $params['post_id'] ?? null; |
| 4007 |
|
| 4008 |
if ( !$post_id ) { |
| 4009 |
return $this->error_response( 'Missing post_id parameter', 'missing_post_id', 400 ); |
| 4010 |
} |
| 4011 |
|
| 4012 |
$post = get_post( $post_id ); |
| 4013 |
if ( !$post ) { |
| 4014 |
return $this->error_response( 'Post not found', 'post_not_found', 404 ); |
| 4015 |
} |
| 4016 |
|
| 4017 |
if ( !$mwseo_score ) { |
| 4018 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 4019 |
} |
| 4020 |
|
| 4021 |
// Generate a session ID |
| 4022 |
$session_id = wp_generate_uuid4(); |
| 4023 |
|
| 4024 |
// Store session metadata |
| 4025 |
update_post_meta( $post_id, '_mwseo_analysis_session', [ |
| 4026 |
'session_id' => $session_id, |
| 4027 |
'started_at' => time(), |
| 4028 |
] ); |
| 4029 |
|
| 4030 |
// Run baseline analysis (technical checks only) |
| 4031 |
$result = $this->core->calculate_seo_score( $post, 'baseline' ); |
| 4032 |
|
| 4033 |
// Get list of AI steps to run |
| 4034 |
$ai_steps = $mwseo_score->get_enabled_ai_steps(); |
| 4035 |
|
| 4036 |
// Get the updated post data |
| 4037 |
$score_data = get_post_meta( $post_id, '_mwseo_analysis', true ); |
| 4038 |
|
| 4039 |
return new WP_REST_Response( [ |
| 4040 |
'success' => true, |
| 4041 |
'session_id' => $session_id, |
| 4042 |
'ai_steps' => $ai_steps, |
| 4043 |
'result' => $result, |
| 4044 |
'analysis' => $score_data, |
| 4045 |
], 200 ); |
| 4046 |
|
| 4047 |
} catch ( Exception $e ) { |
| 4048 |
return $this->error_response( $e->getMessage(), 'baseline_analysis_failed', 500 ); |
| 4049 |
} |
| 4050 |
} |
| 4051 |
|
| 4052 |
/** |
| 4053 |
* Run a single AI analysis step |
| 4054 |
* POST /analysis/ai-step |
| 4055 |
* Body: { post_id: 123, step: 'grammar', session_id: 'uuid' } |
| 4056 |
*/ |
| 4057 |
function rest_analysis_ai_step( $request ) { |
| 4058 |
global $mwseo_score; |
| 4059 |
|
| 4060 |
try { |
| 4061 |
$params = $request->get_json_params(); |
| 4062 |
$post_id = $params['post_id'] ?? null; |
| 4063 |
$step = $params['step'] ?? null; |
| 4064 |
$session_id = $params['session_id'] ?? null; |
| 4065 |
|
| 4066 |
if ( !$post_id || !$step || !$session_id ) { |
| 4067 |
return $this->error_response( 'Missing required parameters', 'missing_parameters', 400 ); |
| 4068 |
} |
| 4069 |
|
| 4070 |
// Check if session is still valid |
| 4071 |
$session_meta = get_post_meta( $post_id, '_mwseo_analysis_session', true ); |
| 4072 |
if ( !$session_meta || $session_meta['session_id'] !== $session_id ) { |
| 4073 |
return new WP_REST_Response( [ |
| 4074 |
'success' => false, |
| 4075 |
'code' => 'stale_session', |
| 4076 |
'message' => 'Session has been superseded by a newer analysis' |
| 4077 |
], 200 ); // Return 200 so client can handle gracefully |
| 4078 |
} |
| 4079 |
|
| 4080 |
$post = get_post( $post_id ); |
| 4081 |
if ( !$post ) { |
| 4082 |
return $this->error_response( 'Post not found', 'post_not_found', 404 ); |
| 4083 |
} |
| 4084 |
|
| 4085 |
if ( !$mwseo_score ) { |
| 4086 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 4087 |
} |
| 4088 |
|
| 4089 |
// Run the AI step |
| 4090 |
$step_result = $mwseo_score->run_ai_step( $post, $step ); |
| 4091 |
|
| 4092 |
if ( $step_result === false ) { |
| 4093 |
return new WP_REST_Response( [ |
| 4094 |
'success' => false, |
| 4095 |
'code' => 'ai_error', |
| 4096 |
'message' => 'AI step failed', |
| 4097 |
'step' => $step |
| 4098 |
], 200 ); // Return 200 so client can decide to retry or skip |
| 4099 |
} |
| 4100 |
|
| 4101 |
// Merge the step result into the analysis |
| 4102 |
$merged = $mwseo_score->merge_ai_step( $post_id, $step_result ); |
| 4103 |
|
| 4104 |
if ( !$merged ) { |
| 4105 |
return $this->error_response( 'Failed to merge AI step result', 'merge_failed', 500 ); |
| 4106 |
} |
| 4107 |
|
| 4108 |
// Get updated analysis data |
| 4109 |
$analysis = get_post_meta( $post_id, '_mwseo_analysis', true ); |
| 4110 |
|
| 4111 |
// Extract issues found in this AI step |
| 4112 |
$step_issues = $this->extract_ai_step_issues( $step, $step_result, $analysis ); |
| 4113 |
|
| 4114 |
// Map step name to penalty key |
| 4115 |
$step_to_penalty_map = [ |
| 4116 |
'summary' => 'semantic_alignment', |
| 4117 |
'grammar' => 'grammar_typos', |
| 4118 |
'authenticity' => 'authenticity_originality', |
| 4119 |
'personality' => 'personality_engagement', |
| 4120 |
'structure' => 'structure_quality', |
| 4121 |
'readability' => 'readability_score', |
| 4122 |
'topic' => 'topic_completeness' |
| 4123 |
]; |
| 4124 |
|
| 4125 |
// Get penalties for this specific step |
| 4126 |
$step_penalties = []; |
| 4127 |
$penalty_key = $step_to_penalty_map[$step] ?? null; |
| 4128 |
if ( $penalty_key && isset( $analysis['penalties'][$penalty_key] ) ) { |
| 4129 |
$step_penalties[$penalty_key] = $analysis['penalties'][$penalty_key]; |
| 4130 |
} |
| 4131 |
|
| 4132 |
// Debug logging |
| 4133 |
error_log( "SEO Engine - AI Step '{$step}' completed:" ); |
| 4134 |
error_log( " Penalty key: {$penalty_key}" ); |
| 4135 |
error_log( " Test value: " . ( isset( $analysis['tests'][$penalty_key] ) ? $analysis['tests'][$penalty_key] : 'NOT SET' ) ); |
| 4136 |
error_log( " Penalty value: " . ( isset( $analysis['penalties'][$penalty_key] ) ? $analysis['penalties'][$penalty_key] : 'NOT SET' ) ); |
| 4137 |
error_log( " Step penalties: " . json_encode( $step_penalties ) ); |
| 4138 |
error_log( " AI data: " . json_encode( $analysis['ai'] ?? [] ) ); |
| 4139 |
|
| 4140 |
// Return unified response format |
| 4141 |
return $this->success_response( [ |
| 4142 |
'step' => $step, |
| 4143 |
'completed' => true, |
| 4144 |
'score' => $analysis['overall'] ?? 0, |
| 4145 |
'step_issues' => $step_issues, |
| 4146 |
'step_penalties' => $step_penalties, |
| 4147 |
'analysis' => $analysis |
| 4148 |
] ); |
| 4149 |
|
| 4150 |
} catch ( Exception $e ) { |
| 4151 |
return new WP_REST_Response( [ |
| 4152 |
'success' => false, |
| 4153 |
'code' => 'ai_step_exception', |
| 4154 |
'message' => $e->getMessage(), |
| 4155 |
'step' => $step ?? 'unknown' |
| 4156 |
], 200 ); // Return 200 so client can handle gracefully |
| 4157 |
} |
| 4158 |
} |
| 4159 |
|
| 4160 |
/** |
| 4161 |
* NEW UNIFIED ANALYSIS API |
| 4162 |
* Initialize analysis session and return metadata about what will be analyzed |
| 4163 |
* POST /analysis/init |
| 4164 |
* Body: { post_id: 123, mode: 'quick' | 'full' } |
| 4165 |
*/ |
| 4166 |
function rest_analysis_init( $request ) { |
| 4167 |
global $mwseo_score; |
| 4168 |
|
| 4169 |
try { |
| 4170 |
$params = $request->get_json_params(); |
| 4171 |
$post_id = $params['post_id'] ?? null; |
| 4172 |
$mode = $params['mode'] ?? 'quick'; // 'quick' or 'full' |
| 4173 |
|
| 4174 |
if ( !$post_id ) { |
| 4175 |
return $this->error_response( 'Missing post_id parameter', 'missing_post_id', 400 ); |
| 4176 |
} |
| 4177 |
|
| 4178 |
$post = get_post( $post_id ); |
| 4179 |
if ( !$post ) { |
| 4180 |
return $this->error_response( 'Post not found', 'post_not_found', 404 ); |
| 4181 |
} |
| 4182 |
|
| 4183 |
if ( !$mwseo_score ) { |
| 4184 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 4185 |
} |
| 4186 |
|
| 4187 |
// Generate a session ID |
| 4188 |
$session_id = wp_generate_uuid4(); |
| 4189 |
|
| 4190 |
// Store session metadata |
| 4191 |
update_post_meta( $post_id, '_mwseo_analysis_session', [ |
| 4192 |
'session_id' => $session_id, |
| 4193 |
'started_at' => time(), |
| 4194 |
'mode' => $mode, |
| 4195 |
] ); |
| 4196 |
|
| 4197 |
// Get list of AI steps (if full mode) |
| 4198 |
$ai_steps = []; |
| 4199 |
if ( $mode === 'full' ) { |
| 4200 |
$ai_steps = $mwseo_score->get_enabled_ai_steps(); |
| 4201 |
} |
| 4202 |
|
| 4203 |
// Return metadata about what will be analyzed |
| 4204 |
return $this->success_response( [ |
| 4205 |
'session_id' => $session_id, |
| 4206 |
'mode' => $mode, |
| 4207 |
'steps' => [ |
| 4208 |
'tech' => [ |
| 4209 |
'enabled' => true, |
| 4210 |
'name' => 'Technical Analysis' |
| 4211 |
], |
| 4212 |
'ai' => [ |
| 4213 |
'enabled' => $mode === 'full' && count( $ai_steps ) > 0, |
| 4214 |
'steps' => $ai_steps |
| 4215 |
] |
| 4216 |
] |
| 4217 |
] ); |
| 4218 |
|
| 4219 |
} catch ( Exception $e ) { |
| 4220 |
return $this->error_response( $e->getMessage(), 'init_failed', 500 ); |
| 4221 |
} |
| 4222 |
} |
| 4223 |
|
| 4224 |
/** |
| 4225 |
* Run technical analysis step |
| 4226 |
* POST /analysis/tech-step |
| 4227 |
* Body: { post_id: 123, session_id: 'uuid' } |
| 4228 |
* Returns: Unified response with score, issues_found, penalties, analysis |
| 4229 |
*/ |
| 4230 |
function rest_analysis_tech_step( $request ) { |
| 4231 |
global $mwseo_score; |
| 4232 |
|
| 4233 |
try { |
| 4234 |
$params = $request->get_json_params(); |
| 4235 |
$post_id = $params['post_id'] ?? null; |
| 4236 |
$session_id = $params['session_id'] ?? null; |
| 4237 |
|
| 4238 |
if ( !$post_id || !$session_id ) { |
| 4239 |
return $this->error_response( 'Missing required parameters', 'missing_parameters', 400 ); |
| 4240 |
} |
| 4241 |
|
| 4242 |
// Check if session is still valid |
| 4243 |
$session_meta = get_post_meta( $post_id, '_mwseo_analysis_session', true ); |
| 4244 |
if ( !$session_meta || $session_meta['session_id'] !== $session_id ) { |
| 4245 |
return $this->error_response( 'Session has been superseded by a newer analysis', 'stale_session', 409 ); |
| 4246 |
} |
| 4247 |
|
| 4248 |
$post = get_post( $post_id ); |
| 4249 |
if ( !$post ) { |
| 4250 |
return $this->error_response( 'Post not found', 'post_not_found', 404 ); |
| 4251 |
} |
| 4252 |
|
| 4253 |
if ( !$mwseo_score ) { |
| 4254 |
return $this->error_response( 'Score module not initialized', 'score_module_error', 500 ); |
| 4255 |
} |
| 4256 |
|
| 4257 |
// Run technical analysis (baseline) |
| 4258 |
$result = $this->core->calculate_seo_score( $post, 'baseline' ); |
| 4259 |
|
| 4260 |
// Get updated analysis data |
| 4261 |
$analysis = get_post_meta( $post_id, '_mwseo_analysis', true ); |
| 4262 |
|
| 4263 |
// Extract issues found in this technical step |
| 4264 |
$step_issues = $this->extract_technical_issues( $analysis ); |
| 4265 |
|
| 4266 |
return $this->success_response( [ |
| 4267 |
'step' => 'tech', |
| 4268 |
'completed' => true, |
| 4269 |
'score' => $analysis['overall'] ?? 0, |
| 4270 |
'step_issues' => $step_issues, |
| 4271 |
'step_penalties' => $analysis['penalties'] ?? [], |
| 4272 |
'analysis' => $analysis |
| 4273 |
] ); |
| 4274 |
|
| 4275 |
} catch ( Exception $e ) { |
| 4276 |
return $this->error_response( $e->getMessage(), 'tech_step_failed', 500 ); |
| 4277 |
} |
| 4278 |
} |
| 4279 |
|
| 4280 |
/** |
| 4281 |
* Helper to extract technical issues from analysis |
| 4282 |
* Returns list of issues with test, title, description, penalty |
| 4283 |
*/ |
| 4284 |
private function extract_technical_issues( $analysis ) { |
| 4285 |
if ( !isset( $analysis['tests'] ) ) { |
| 4286 |
return []; |
| 4287 |
} |
| 4288 |
|
| 4289 |
$issues = []; |
| 4290 |
$tests = $analysis['tests']; |
| 4291 |
$penalties = $analysis['penalties'] ?? []; |
| 4292 |
$max_penalties = $analysis['max_penalties'] ?? []; |
| 4293 |
|
| 4294 |
// Technical tests (non-AI) |
| 4295 |
$technical_tests = [ |
| 4296 |
'title_exists', 'title_unique_sitewide', 'title_length', 'slug_structure', |
| 4297 |
'excerpt_exists', 'excerpt_length', 'author_visible', 'content_depth', |
| 4298 |
'not_orphaned', 'internal_links', 'external_link_present', 'alt_coverage', |
| 4299 |
'schema_integrity', 'featured_image', 'meta_robots_tag', 'js_rendered_content' |
| 4300 |
]; |
| 4301 |
|
| 4302 |
foreach ( $technical_tests as $test_name ) { |
| 4303 |
if ( isset( $tests[$test_name] ) && $tests[$test_name] === 'fail' ) { |
| 4304 |
$issues[] = [ |
| 4305 |
'test' => $test_name, |
| 4306 |
'penalty' => $penalties[$test_name] ?? 0, |
| 4307 |
'max_penalty' => $max_penalties[$test_name] ?? 0 |
| 4308 |
]; |
| 4309 |
} |
| 4310 |
} |
| 4311 |
|
| 4312 |
return $issues; |
| 4313 |
} |
| 4314 |
|
| 4315 |
/** |
| 4316 |
* Helper to extract AI step issues from step result |
| 4317 |
*/ |
| 4318 |
private function extract_ai_step_issues( $step, $step_result, $analysis ) { |
| 4319 |
$issues = []; |
| 4320 |
|
| 4321 |
// Map step name to test/penalty key |
| 4322 |
$step_to_test_map = [ |
| 4323 |
'summary' => 'semantic_alignment', |
| 4324 |
'grammar' => 'grammar_typos', |
| 4325 |
'authenticity' => 'authenticity_originality', |
| 4326 |
'personality' => 'personality_engagement', |
| 4327 |
'structure' => 'structure_quality', |
| 4328 |
'readability' => 'readability_score', |
| 4329 |
'topic' => 'topic_completeness' |
| 4330 |
]; |
| 4331 |
|
| 4332 |
$test_name = $step_to_test_map[$step] ?? null; |
| 4333 |
if ( !$test_name ) { |
| 4334 |
return $issues; |
| 4335 |
} |
| 4336 |
|
| 4337 |
$tests = $analysis['tests'] ?? []; |
| 4338 |
$penalties = $analysis['penalties'] ?? []; |
| 4339 |
|
| 4340 |
// Check if this AI step has a penalty (score < 100 or test failed) |
| 4341 |
// AI tests return numeric scores (0-100), not 'fail' |
| 4342 |
if ( isset( $tests[$test_name] ) ) { |
| 4343 |
$test_value = $tests[$test_name]; |
| 4344 |
$has_penalty = ( is_numeric( $test_value ) && $test_value < 100 ) || $test_value === 'fail' || $test_value === 0; |
| 4345 |
|
| 4346 |
if ( $has_penalty && isset( $penalties[$test_name] ) && $penalties[$test_name] > 0 ) { |
| 4347 |
$issues[] = [ |
| 4348 |
'test' => $test_name, |
| 4349 |
'penalty' => $penalties[$test_name] |
| 4350 |
]; |
| 4351 |
} |
| 4352 |
} |
| 4353 |
|
| 4354 |
return $issues; |
| 4355 |
} |
| 4356 |
|
| 4357 |
#endregion |
| 4358 |
} |
| 4359 |
|