PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / rest.php

rest.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/rest.php

4,359 lines 148.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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