PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-manager.php +1162 -92 1.0.02.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * API Manager Class
4 5 *
5 6 * Handles REST API endpoints registration and management
@@ -14,9 +15,8 @@
14 15
15 16 // Import endpoint classes
16 17 use ThinkRank\API\Site_Identity_Endpoint;
17 18 use ThinkRank\API\Performance_Endpoint;
18 -
19 19 use ThinkRank\API\Schema_Endpoint;
20 20 use ThinkRank\API\Settings_Management_Endpoint;
21 21 use ThinkRank\API\Content_Brief_Endpoint;
22 22 use ThinkRank\API\Social_Media_Endpoint;
@@ -25,9 +25,21 @@
25 25 use ThinkRank\API\Usage_Analytics_Endpoint;
26 26 use ThinkRank\API\Integrations_Endpoint;
27 27 use ThinkRank\API\Social_Platforms_Endpoint;
28 28 use ThinkRank\API\LLMs_Txt_Endpoint;
29 +use ThinkRank\API\Global_SEO_Endpoint;
30 +use ThinkRank\API\Image_SEO_Endpoint;
31 +use ThinkRank\API\External_Links_Endpoint;
32 +use ThinkRank\API\Instant_Indexing_Endpoint;
33 +use ThinkRank\API\Pillar_Content_Endpoint;
34 +use ThinkRank\API\Global_Robot_Meta_Endpoint;
35 +use ThinkRank\API\Author_Archives_Endpoint;
36 +use ThinkRank\API\Email_Report_Endpoint;
37 +use ThinkRank\Admin\Importers\Import_Controller;
38 +use ThinkRank\Admin\Importers\Export_Controller;
39 +use ThinkRank\API\Setup_Wizard_Endpoint;
29 40
41 +
30 42 // Prevent direct access
31 43 if (!defined('ABSPATH')) {
32 44 exit;
33 45 }
@@ -47,9 +59,31 @@
47 59 * @var string
48 60 */
49 61 private const NAMESPACE = 'thinkrank/v1';
50 62
51 - /**
63 + /**
64 + * Maximum accepted length (characters) for AI `content` payloads. Enforced
65 + * at the REST boundary so oversized input can't drive expensive prompt
66 + * building, cache hashing, and AI requests/retries. Mirrors the frontend's
67 + * 5000-character trim.
68 + */
69 + private const AI_CONTENT_MAX_LENGTH = 5000;
70 +
71 + /**
72 + * Sanitize and hard-cap an AI `content` request parameter.
73 + *
74 + * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
75 + * the server enforces its own maximum regardless of what a direct REST
76 + * caller sends.
77 + *
78 + * @param mixed $value Raw request value.
79 + * @return string Sanitized content, truncated to AI_CONTENT_MAX_LENGTH.
80 + */
81 + public function sanitize_ai_content($value): string {
82 + return mb_substr(sanitize_textarea_field((string) $value), 0, self::AI_CONTENT_MAX_LENGTH);
83 + }
84 +
85 + /**
52 86 * Initialize API manager
53 87 *
54 88 * @return void
55 89 */
@@ -55,8 +89,21 @@
55 89 */
56 90 public function init(): void {
57 91 add_action('rest_api_init', [$this, 'register_routes']);
58 92 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
93 +
94 + // Analytics cache invalidation must listen on every request, not only
95 + // REST ones — AI usage is logged from cron and WP-CLI too, and a
96 + // listener bound on rest_api_init never hears those.
97 + Usage_Analytics_Endpoint::boot_cache_invalidation();
98 +
99 + // Make declared schema constraints mean something. Applied once over
100 + // the whole namespace rather than at 70-odd call sites, because that is
101 + // exactly how the enum on /setup-wizard/migrated-plugins and the one on
102 + // /seo-analytics/dashboard came to be inert while the route next door
103 + // was fine (#394). Late priority so it sees every route, including any
104 + // an add-on registered.
105 + add_filter('rest_endpoints', [Rest_Args::class, 'enforce_namespace'], 99);
59 106 }
60 107
61 108 /**
62 109 * Register REST API routes
@@ -82,25 +129,39 @@
82 129 'callback' => [$this, 'get_system_status'],
83 130 'permission_callback' => [$this, 'check_basic_permissions'],
84 131 ]);
85 132
133 + // Integration health check for MCP/Abilities clients (see #188). This
134 + // route is intentionally gated only by the admin capability, NOT by the
135 + // `enable_mcp` toggle, so it stays reachable as a diagnostic even when
136 + // the MCP server is off or abilities failed to register.
137 + register_rest_route(self::NAMESPACE, '/connection-status', [
138 + 'methods' => 'GET',
139 + 'callback' => [$this, 'get_connection_status'],
140 + 'permission_callback' => [$this, 'check_admin_permissions'],
141 + ]);
86 142
87 -
88 143 // Settings endpoints
89 144 register_rest_route(self::NAMESPACE, '/settings', [
90 145 'methods' => 'GET',
91 146 'callback' => [$this, 'get_settings'],
92 - 'permission_callback' => [$this, 'check_admin_permissions'],
147 + 'permission_callback' => [$this, 'check_settings_permissions'],
93 148 ]);
94 149
95 150 register_rest_route(self::NAMESPACE, '/settings', [
96 151 'methods' => 'POST',
97 152 'callback' => [$this, 'save_settings'],
98 - 'permission_callback' => [$this, 'check_admin_permissions'],
153 + 'permission_callback' => [$this, 'check_settings_permissions'],
99 154 'args' => [
100 155 'ai_provider' => [
101 156 'type' => 'string',
157 + // Includes '' (Settings::AI_PROVIDER_NONE) so a client can
158 + // clear the selection, not just switch between providers.
159 + 'enum' => \ThinkRank\Core\Settings::selectable_ai_providers(),
102 160 'sanitize_callback' => 'sanitize_key',
161 + // The enum is inert without this: has_valid_params() skips
162 + // an arg entirely unless a validate_callback is set (#394).
163 + 'validate_callback' => 'rest_validate_request_arg',
103 164 ],
104 165 'openai_api_key' => [
105 166 'type' => 'string',
106 167 'sanitize_callback' => 'sanitize_text_field',
@@ -124,12 +185,51 @@
124 185 'gemini_model' => [
125 186 'type' => 'string',
126 187 'sanitize_callback' => 'sanitize_text_field',
127 188 ],
189 + 'openrouter_api_key' => [
190 + 'type' => 'string',
191 + 'sanitize_callback' => 'sanitize_text_field',
192 + ],
193 + 'openrouter_model' => [
194 + 'type' => 'string',
195 + 'sanitize_callback' => 'sanitize_text_field',
196 + ],
197 + 'max_tokens' => [
198 + 'type' => 'integer',
199 + 'minimum' => 1,
200 + 'maximum' => 32000,
201 + 'sanitize_callback' => 'absint',
202 + 'validate_callback' => 'rest_validate_request_arg',
203 + ],
204 + 'temperature' => [
205 + 'type' => 'number',
206 + 'minimum' => 0,
207 + 'maximum' => 2,
208 + 'validate_callback' => 'rest_validate_request_arg',
209 + ],
210 + 'cache_duration' => [
211 + 'type' => 'integer',
212 + 'minimum' => 0,
213 + 'sanitize_callback' => 'absint',
214 + 'validate_callback' => 'rest_validate_request_arg',
215 + ],
128 216 'keep_data_on_uninstall' => [
129 217 'type' => 'boolean',
130 218 'sanitize_callback' => 'rest_sanitize_boolean',
131 219 ],
220 + 'enable_mcp' => [
221 + 'type' => 'boolean',
222 + 'sanitize_callback' => 'rest_sanitize_boolean',
223 + ],
224 + 'enable_migration_tools' => [
225 + 'type' => 'boolean',
226 + 'sanitize_callback' => 'rest_sanitize_boolean',
227 + ],
228 + 'enable_import_export' => [
229 + 'type' => 'boolean',
230 + 'sanitize_callback' => 'rest_sanitize_boolean',
231 + ],
132 232 ],
133 233 ]);
134 234
135 235
@@ -155,8 +255,86 @@
155 255 'args' => [
156 256 'content' => [
157 257 'type' => 'string',
158 258 'required' => true,
259 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
260 + ],
261 + 'target_keyword' => [
262 + 'type' => 'string',
263 + 'sanitize_callback' => 'sanitize_text_field',
264 + ],
265 + 'content_type' => [
266 + 'type' => 'string',
267 + 'default' => 'blog_post',
268 + 'sanitize_callback' => 'sanitize_text_field',
269 + ],
270 + 'tone' => [
271 + 'type' => 'string',
272 + 'default' => 'professional',
273 + 'sanitize_callback' => 'sanitize_text_field',
274 + ],
275 + 'post_id' => [
276 + 'type' => 'integer',
277 + 'required' => false,
278 + 'default' => 0,
279 + 'sanitize_callback' => 'absint',
280 + ],
281 + ],
282 + ]);
283 +
284 + register_rest_route(self::NAMESPACE, '/ai/improve-title', [
285 + 'methods' => 'POST',
286 + 'callback' => [$this, 'improve_ai_title'],
287 + 'permission_callback' => [$this, 'check_basic_permissions'],
288 + 'args' => [
289 + 'content' => [
290 + 'type' => 'string',
291 + 'required' => true,
292 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
293 + ],
294 + 'current_title' => [
295 + 'type' => 'string',
296 + 'sanitize_callback' => 'sanitize_text_field',
297 + ],
298 + 'target_keyword' => [
299 + 'type' => 'string',
300 + 'sanitize_callback' => 'sanitize_text_field',
301 + ],
302 + 'content_type' => [
303 + 'type' => 'string',
304 + 'default' => 'blog_post',
305 + 'sanitize_callback' => 'sanitize_text_field',
306 + ],
307 + 'tone' => [
308 + 'type' => 'string',
309 + 'default' => 'professional',
310 + 'sanitize_callback' => 'sanitize_text_field',
311 + ],
312 + 'suggestion' => [
313 + 'type' => 'string',
314 + 'sanitize_callback' => 'sanitize_text_field',
315 + ],
316 + 'post_id' => [
317 + 'type' => 'integer',
318 + 'required' => false,
319 + 'default' => 0,
320 + 'sanitize_callback' => 'absint',
321 + ],
322 + ],
323 + ]);
324 +
325 + register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
326 + 'methods' => 'POST',
327 + 'callback' => [$this, 'improve_ai_meta_description'],
328 + 'permission_callback' => [$this, 'check_basic_permissions'],
329 + 'args' => [
330 + 'content' => [
331 + 'type' => 'string',
332 + 'required' => true,
333 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
334 + ],
335 + 'current_description' => [
336 + 'type' => 'string',
159 337 'sanitize_callback' => 'sanitize_textarea_field',
160 338 ],
161 339 'target_keyword' => [
162 340 'type' => 'string',
@@ -171,11 +349,125 @@
171 349 'type' => 'string',
172 350 'default' => 'professional',
173 351 'sanitize_callback' => 'sanitize_text_field',
174 352 ],
353 + 'suggestion' => [
354 + 'type' => 'string',
355 + 'sanitize_callback' => 'sanitize_text_field',
356 + ],
357 + 'post_id' => [
358 + 'type' => 'integer',
359 + 'required' => false,
360 + 'default' => 0,
361 + 'sanitize_callback' => 'absint',
362 + ],
175 363 ],
176 364 ]);
177 365
366 + register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
367 + 'methods' => 'POST',
368 + 'callback' => [$this, 'explain_ai_suggestion'],
369 + 'permission_callback' => [$this, 'check_basic_permissions'],
370 + 'args' => [
371 + 'content' => [
372 + 'type' => 'string',
373 + 'required' => true,
374 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
375 + ],
376 + 'suggestion' => [
377 + 'type' => 'string',
378 + 'required' => true,
379 + 'sanitize_callback' => 'sanitize_text_field',
380 + ],
381 + 'title' => [
382 + 'type' => 'string',
383 + 'sanitize_callback' => 'sanitize_text_field',
384 + ],
385 + 'target_keyword' => [
386 + 'type' => 'string',
387 + 'sanitize_callback' => 'sanitize_text_field',
388 + ],
389 + 'content_type' => [
390 + 'type' => 'string',
391 + 'default' => 'blog_post',
392 + 'sanitize_callback' => 'sanitize_text_field',
393 + ],
394 + ],
395 + ]);
396 +
397 + register_rest_route(self::NAMESPACE, '/ai/add-dofollow-link', [
398 + 'methods' => 'POST',
399 + 'callback' => [$this, 'add_ai_dofollow_link'],
400 + 'permission_callback' => [$this, 'check_basic_permissions'],
401 + 'args' => [
402 + 'content' => [
403 + 'type' => 'string',
404 + 'required' => true,
405 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
406 + ],
407 + 'target_keyword' => [
408 + 'type' => 'string',
409 + 'sanitize_callback' => 'sanitize_text_field',
410 + ],
411 + 'content_type' => [
412 + 'type' => 'string',
413 + 'default' => 'blog_post',
414 + 'sanitize_callback' => 'sanitize_text_field',
415 + ],
416 + ],
417 + ]);
418 +
419 + register_rest_route(self::NAMESPACE, '/ai/add-keyword-paragraph', [
420 + 'methods' => 'POST',
421 + 'callback' => [$this, 'add_ai_keyword_paragraph'],
422 + 'permission_callback' => [$this, 'check_basic_permissions'],
423 + 'args' => [
424 + 'content' => [
425 + 'type' => 'string',
426 + 'required' => true,
427 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
428 + ],
429 + 'target_keyword' => [
430 + 'type' => 'string',
431 + 'required' => true,
432 + 'sanitize_callback' => 'sanitize_text_field',
433 + ],
434 + 'content_type' => [
435 + 'type' => 'string',
436 + 'default' => 'blog_post',
437 + 'sanitize_callback' => 'sanitize_text_field',
438 + ],
439 + 'tone' => [
440 + 'type' => 'string',
441 + 'default' => 'professional',
442 + 'sanitize_callback' => 'sanitize_text_field',
443 + ],
444 + 'word_count' => [
445 + 'type' => 'integer',
446 + 'default' => 0,
447 + 'sanitize_callback' => 'absint',
448 + ],
449 + 'keyword_count' => [
450 + 'type' => 'integer',
451 + 'default' => 0,
452 + 'sanitize_callback' => 'absint',
453 + ],
454 + ],
455 + ]);
456 +
457 + register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
458 + 'methods' => 'POST',
459 + 'callback' => [$this, 'enable_schema_for_post'],
460 + 'permission_callback' => [$this, 'check_admin_permissions'],
461 + 'args' => [
462 + 'post_id' => [
463 + 'type' => 'integer',
464 + 'required' => true,
465 + 'sanitize_callback' => 'absint',
466 + ],
467 + ],
468 + ]);
469 +
178 470 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
179 471 'methods' => 'POST',
180 472 'callback' => [$this, 'test_ai_connection'],
181 473 'permission_callback' => [$this, 'check_admin_permissions'],
@@ -190,8 +482,13 @@
190 482 'required' => false,
191 483 'default' => 'openai',
192 484 'sanitize_callback' => 'sanitize_key',
193 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
194 491 ],
195 492 ]);
196 493
197 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -221,8 +518,16 @@
221 518 } catch (\Exception $e) {
222 519 // Usage Analytics endpoint registration failed
223 520 }
224 521
522 + // Register Site SEO Analyzer endpoint
523 + try {
524 + $seo_analyzer_endpoint = new \ThinkRank\API\SEO_Analyzer_Endpoint();
525 + $seo_analyzer_endpoint->register_routes();
526 + } catch (\Exception $e) {
527 + // Site SEO Analyzer endpoint registration failed
528 + }
529 +
225 530 // Register SEO Analytics endpoints
226 531 try {
227 532 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
228 533 $seo_analytics_endpoint->register_routes();
@@ -229,19 +534,64 @@
229 534 } catch (\Exception $e) {
230 535 // Failed to register SEO Analytics endpoint
231 536 }
232 537
538 + // Register Instant Indexing endpoints
539 + try {
540 + $instant_indexing_endpoint = new \ThinkRank\API\Instant_Indexing_Endpoint();
541 + $instant_indexing_endpoint->register_routes();
542 + } catch (\Exception $e) {
543 + // Failed to register Instant Indexing endpoint
544 + }
233 545
546 + // Register Pillar Content endpoints
547 + try {
548 + $pillar_content_endpoint = new \ThinkRank\API\Pillar_Content_Endpoint();
549 + $pillar_content_endpoint->register_routes();
550 + } catch (\Exception $e) {
551 + // Failed to register Pillar Content endpoint
552 + }
234 553
235 - // Add a simple test endpoint to verify API is working
236 - register_rest_route(self::NAMESPACE, '/seo-score/test', [
237 - 'methods' => 'GET',
238 - 'callback' => function() {
239 - return ['message' => 'SEO Score API is working!', 'timestamp' => current_time('mysql')];
240 - },
241 - 'permission_callback' => [$this, 'check_basic_permissions'],
242 - ]);
554 + // Register Focus Keyword Usage endpoint ("already used" status).
555 + try {
556 + $focus_keyword_usage_endpoint = new \ThinkRank\API\Focus_Keyword_Usage_Endpoint();
557 + $focus_keyword_usage_endpoint->register_routes();
558 + } catch (\Exception $e) {
559 + // Failed to register Focus Keyword Usage endpoint
560 + }
561 + // Register Global Robot Meta endpoints
562 + try {
563 + $global_robot_meta_endpoint = new \ThinkRank\API\Global_Robot_Meta_Endpoint();
564 + $global_robot_meta_endpoint->register_routes();
565 + } catch (\Exception $e) {
566 + // Failed to register Global Robot Meta endpoint
567 + }
243 568
569 + // Register Author Archives endpoints
570 + try {
571 + $author_archives_endpoint = new \ThinkRank\API\Author_Archives_Endpoint();
572 + $author_archives_endpoint->register_routes();
573 + } catch (\Exception $e) {
574 + // Failed to register Author Archives endpoint
575 + }
576 +
577 + // Register Role Manager endpoint
578 + try {
579 + $role_manager_endpoint = new \ThinkRank\API\Role_Manager_Endpoint();
580 + $role_manager_endpoint->register_routes();
581 + } catch (\Exception $e) {
582 + // Failed to register Role Manager endpoint
583 + }
584 +
585 + // Register Email Report endpoints
586 + try {
587 + $email_report_endpoint = new \ThinkRank\API\Email_Report_Endpoint();
588 + $email_report_endpoint->register_routes();
589 + } catch (\Exception $e) {
590 + // Failed to register Email Report endpoint
591 + }
592 +
593 +
244 594 register_rest_route(self::NAMESPACE, '/ai/status', [
245 595 'methods' => 'GET',
246 596 'callback' => [$this, 'get_ai_status'],
247 597 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -254,9 +604,9 @@
254 604 'args' => [
255 605 'content' => [
256 606 'type' => 'string',
257 607 'required' => true,
258 - 'sanitize_callback' => 'sanitize_textarea_field',
608 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
259 609 ],
260 610 'metadata' => [
261 611 'type' => 'object',
262 612 'required' => false,
@@ -298,38 +648,38 @@
298 648 return true;
299 649 }
300 650
301 651
302 - /**
303 - * Simple transient-based rate limiter
304 - *
305 - * @param string $bucket_id Unique bucket per user/IP and route
306 - * @param int $limit Max requests per minute
307 - * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
308 - */
309 - private function enforce_rate_limit(string $bucket_id, int $limit) {
310 - $settings = new \ThinkRank\Core\Settings();
311 - $enabled = (bool) $settings->get('enable_rate_limiting', true);
312 - if (!$enabled) {
313 - return true;
314 - }
315 - $now = time();
316 - $window = 60;
317 - $key = 'thinkrank_rl_' . md5($bucket_id);
318 - $bucket = get_transient($key);
319 - if (!is_array($bucket)) {
320 - $bucket = ['start' => $now, 'count' => 0];
321 - }
322 - if ($now - ($bucket['start'] ?? 0) >= $window) {
323 - $bucket = ['start' => $now, 'count' => 0];
324 - }
325 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
326 - return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
327 - }
328 - $bucket['count']++;
329 - set_transient($key, $bucket, $window);
330 - return true;
331 - }
652 + /**
653 + * Simple transient-based rate limiter
654 + *
655 + * @param string $bucket_id Unique bucket per user/IP and route
656 + * @param int $limit Max requests per minute
657 + * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
658 + */
659 + private function enforce_rate_limit(string $bucket_id, int $limit) {
660 + $settings = \ThinkRank\Core\Settings::instance();
661 + $enabled = (bool) $settings->get('enable_rate_limiting', true);
662 + if (!$enabled) {
663 + return true;
664 + }
665 + $now = time();
666 + $window = 60;
667 + $key = 'thinkrank_rl_' . md5($bucket_id);
668 + $bucket = get_transient($key);
669 + if (!is_array($bucket)) {
670 + $bucket = ['start' => $now, 'count' => 0];
671 + }
672 + if ($now - ($bucket['start'] ?? 0) >= $window) {
673 + $bucket = ['start' => $now, 'count' => 0];
674 + }
675 + if (($bucket['count'] ?? 0) >= max(1, $limit)) {
676 + return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
677 + }
678 + $bucket['count']++;
679 + set_transient($key, $bucket, $window);
680 + return true;
681 + }
332 682
333 683 /**
334 684 * Check admin permissions (for settings)
335 685 *
@@ -357,8 +707,38 @@
357 707 return true;
358 708 }
359 709
360 710 /**
711 + * Check Settings section permissions.
712 + *
713 + * Delegable via Role Manager: passes for administrators (bypass) and for
714 + * any role granted the `thinkrank_settings` capability. Used by the core
715 + * /settings routes so the "Settings & API Keys" area can be delegated.
716 + *
717 + * @param \WP_REST_Request $request Request object
718 + * @return bool|\WP_Error Permission status
719 + */
720 + public function check_settings_permissions(\WP_REST_Request $request) {
721 + if (!is_user_logged_in()) {
722 + return new \WP_Error(
723 + 'rest_forbidden',
724 + __('You must be logged in to access this endpoint.', 'thinkrank'),
725 + ['status' => 401]
726 + );
727 + }
728 +
729 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings')) {
730 + return new \WP_Error(
731 + 'rest_forbidden',
732 + __('You do not have permission to manage ThinkRank settings.', 'thinkrank'),
733 + ['status' => 403]
734 + );
735 + }
736 +
737 + return true;
738 + }
739 +
740 + /**
361 741 * Get user capabilities
362 742 *
363 743 * @param \WP_REST_Request $request Request object
364 744 * @return \WP_REST_Response Response object
@@ -400,10 +780,23 @@
400 780 'wp_version' => get_bloginfo('version'),
401 781 ]);
402 782 }
403 783
784 + /**
785 + * Get ThinkRank integration health for MCP/Abilities clients (see #188).
786 + *
787 + * Admin-gated diagnostic; never returns secret material. Delegates to the
788 + * shared reporter so the ability and this route stay in lock-step.
789 + *
790 + * @param \WP_REST_Request $request Request object
791 + * @return \WP_REST_Response Response object
792 + */
793 + public function get_connection_status(\WP_REST_Request $request): \WP_REST_Response {
794 + return new \WP_REST_Response(\ThinkRank\Diagnostics\Connection_Status::report());
795 + }
404 796
405 797
798 +
406 799 /**
407 800 * Get settings
408 801 *
409 802 * @param \WP_REST_Request $request Request object
@@ -410,41 +803,70 @@
410 803 * @return \WP_REST_Response Response object
411 804 */
412 805 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
413 806 // Use Settings class for consistent access (handles decryption automatically)
414 - $settings_instance = new \ThinkRank\Core\Settings();
807 + $settings_instance = \ThinkRank\Core\Settings::instance();
415 808
416 809 $settings = [
417 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
810 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
418 811 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
419 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
812 + 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
420 813 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
421 - 'claude_model' => $settings_instance->get('claude_model', 'claude-3-7-sonnet-latest'),
814 + 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
422 815 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
423 - 'gemini_model' => $settings_instance->get('gemini_model', 'gemini-2.5-flash'),
816 + 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
817 + 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
818 + 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
424 819 'max_tokens' => $settings_instance->get('max_tokens', 1000),
425 820 'temperature' => $settings_instance->get('temperature', 0.7),
426 821 'cache_duration' => $settings_instance->get('cache_duration', 3600),
427 - 'keep_data_on_uninstall' => $settings_instance->get('keep_data_on_uninstall', true),
822 + 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
823 + 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
824 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
825 + 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
826 + 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
428 827 ];
429 828
430 829
431 830
432 - // Don't send full API keys to frontend for security - mask them
831 + // Don't send full API keys to frontend for security - mask them,
832 + // revealing the first 5 and last 3 chars so the saved key is recognizable.
433 833 if (!empty($settings['openai_api_key'])) {
434 - $settings['openai_api_key'] = '••••••••' . substr($settings['openai_api_key'], -4);
834 + $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
435 835 }
436 836 if (!empty($settings['claude_api_key'])) {
437 - $settings['claude_api_key'] = '••••••••' . substr($settings['claude_api_key'], -4);
837 + $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
438 838 }
439 839 if (!empty($settings['gemini_api_key'])) {
440 - $settings['gemini_api_key'] = '••••••••' . substr($settings['gemini_api_key'], -4);
840 + $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
441 841 }
842 + if (!empty($settings['openrouter_api_key'])) {
843 + $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
844 + }
442 845
443 846 return new \WP_REST_Response($settings);
444 847 }
445 848
446 849 /**
850 + * Mask an AI provider API key for display.
851 + *
852 + * Reveals the first 5 and last 3 characters with a bullet run in between
853 + * (e.g. "sk-pr••••••••abc"). Keys of 8 chars or fewer are fully masked so
854 + * head + tail can't reconstruct the whole value. The "••••••••" sentinel is
855 + * what save_settings() looks for to skip re-saving a resubmitted mask.
856 + *
857 + * @param string $key Raw API key.
858 + * @return string Masked key safe to send to the frontend.
859 + */
860 + private function mask_ai_api_key(string $key): string {
861 + if (strlen($key) <= 8) {
862 + return '••••••••';
863 + }
864 +
865 + return substr($key, 0, 5) . '••••••••' . substr($key, -3);
866 + }
867 +
868 + /**
447 869 * Save settings
448 870 *
449 871 * @param \WP_REST_Request $request Request object
450 872 * @return \WP_REST_Response Response object
@@ -452,10 +874,14 @@
452 874 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
453 875 $params = $request->get_params();
454 876
455 877 // Get Settings instance for proper encryption handling
456 - $settings = new \ThinkRank\Core\Settings();
878 + $settings = \ThinkRank\Core\Settings::instance();
457 879
880 + // Capture the pre-save MCP state so we can detect an on/off transition
881 + // below and mint/revoke the connection token to match (see #244).
882 + $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
883 +
458 884 // Map frontend parameter names to setting keys
459 885 $settings_map = [
460 886 'ai_provider' => 'ai_provider',
461 887 'openai_api_key' => 'openai_api_key',
@@ -463,12 +889,17 @@
463 889 'claude_api_key' => 'claude_api_key',
464 890 'claude_model' => 'claude_model',
465 891 'gemini_api_key' => 'gemini_api_key',
466 892 'gemini_model' => 'gemini_model',
893 + 'openrouter_api_key' => 'openrouter_api_key',
894 + 'openrouter_model' => 'openrouter_model',
467 895 'max_tokens' => 'max_tokens',
468 896 'temperature' => 'temperature',
469 897 'cache_duration' => 'cache_duration',
470 898 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
899 + 'enable_mcp' => 'enable_mcp',
900 + 'enable_migration_tools' => 'enable_migration_tools',
901 + 'enable_import_export' => 'enable_import_export',
471 902 ];
472 903
473 904 // Processing settings save request
474 905
@@ -476,27 +907,48 @@
476 907 if (isset($params[$param_key])) {
477 908 $value = $params[$param_key];
478 909
479 910 // Handle API keys specially - check for masked values
480 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key'])) {
481 - // Don't update if masked (but allow empty to clear)
482 - if (strpos($value, '••••••••') === 0) {
911 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'], true)) {
912 + // Don't update if the value carries the mask sentinel (the
913 + // preview now keeps real head/tail chars around it, so match
914 + // anywhere rather than only at the start). Empty still clears.
915 + if (strpos($value, '••••••••') !== false) {
483 916 continue;
484 917 }
485 918 }
486 919
487 920 // Use Settings class for all operations (handles encryption automatically)
488 - if (!$settings->set($setting_key, $value)) {
489 - // Settings save failed, continue with other settings
490 - }
921 + // A failed write is skipped rather than aborting the batch, so
922 + // one bad setting cannot block the rest of the save.
923 + $settings->set($setting_key, $value);
491 924 }
492 925 }
493 926
927 + // MCP is a single master switch (see #244): enabling it auto-mints a
928 + // read/write connection token so the connect recipes are ready without
929 + // a separate "Generate token" step.
930 + //
931 + // Disabling is a PAUSE, not a wipe: the switch alone already denies all
932 + // access (Mcp_Server 403s and the OAuth/discovery endpoints refuse while
933 + // off), so stored tokens and OAuth grants are inert. We keep them so
934 + // re-enabling restores every previously connected app with no
935 + // re-approval. Explicit revocation stays available per-app (the
936 + // Connected AI apps trash button) and for the shared token (Reset
937 + // token / rotate). Only act on an actual on->off->on transition so
938 + // saving unrelated settings never touches the connection.
939 + if (isset($params['enable_mcp'])) {
940 + $mcp_now_enabled = (bool) $settings->get('enable_mcp', false);
941 + if ($mcp_now_enabled && !$mcp_was_enabled) {
942 + \ThinkRank\Mcp\Mcp_Pairing::connect();
943 + }
944 + }
945 +
494 946 // Auto-dismiss welcome notice if API key was saved
495 947 $this->maybe_dismiss_welcome_notice($params);
496 948
497 949 // Force AI Manager to re-initialize client with new settings
498 - if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key'])) {
950 + if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key'])) {
499 951 // Clear any cached AI Manager instances to force re-initialization
500 952 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
501 953
502 954 // If we have an AI Manager instance, force it to re-initialize
@@ -545,17 +997,40 @@
545 997 * @param \WP_REST_Request $request Request object
546 998 * @return \WP_REST_Response Response object
547 999 */
548 1000 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
549 - $post_id = $request->get_param('post_id');
1001 + $post_id = (int) $request->get_param('post_id');
550 1002
551 - // Get existing metadata
1003 + // Object-level guard: only expose a post's stored SEO meta to a user who
1004 + // can edit that specific post (the section capability gate handles the
1005 + // AI Tools toggle; this adds per-post ownership).
1006 + if (!current_user_can('edit_post', $post_id)) {
1007 + return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
1008 + }
1009 +
1010 + // Read the pending flag BEFORE the meta below, never after. A writer
1011 + // that finishes mid-request writes the meta and *then* clears the
1012 + // flag; reading the flag last could therefore observe "no value" and
1013 + // "not pending" for the same run and stop the editor panel polling one
1014 + // tick before the value it was waiting for lands (#329).
1015 + $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id);
1016 +
1017 + // Get existing metadata. These must read the same canonical meta keys
1018 + // the rest of the plugin writes/reads (frontend, metabox, scoring),
1019 + // otherwise the response is always empty:
1020 + // title/description → _thinkrank_seo_title / _thinkrank_meta_description
1021 + // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords)
1022 + // last_generated → _thinkrank_generated_at (written by Metadata_Generator)
552 1023 $metadata = [
553 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
554 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
555 - 'keywords' => get_post_meta($post_id, '_thinkrank_keywords', true),
1024 + 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1025 + 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1026 + 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
556 1027 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
557 - 'last_generated' => get_post_meta($post_id, '_thinkrank_last_generated', true),
1028 + 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1029 + // Whether a background writer (Auto AI on publish, bulk
1030 + // optimization, imports) is about to fill these fields. The editor
1031 + // panel polls only while this is true.
1032 + 'pending' => $pending,
558 1033 ];
559 1034
560 1035 return new \WP_REST_Response($metadata);
561 1036 }
@@ -572,15 +1047,18 @@
572 1047 $options = [
573 1048 'target_keyword' => $request->get_param('target_keyword'),
574 1049 'content_type' => $request->get_param('content_type'),
575 1050 'tone' => $request->get_param('tone'),
1051 + // Instruct the model to write in the post/site language instead of
1052 + // defaulting to English on non-English sites (issue #234).
1053 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
576 1054 ];
577 1055
578 1056 // Rate limiting: per user/IP per route
579 1057 $user_id = get_current_user_id();
580 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
1058 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
581 1059 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
582 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
1060 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
583 1061 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
584 1062 if (is_wp_error($allowed)) {
585 1063 return new \WP_REST_Response([
586 1064 'success' => false,
@@ -592,9 +1070,12 @@
592 1070 // Get AI manager instance
593 1071 $ai_manager = new \ThinkRank\AI\Manager();
594 1072 $ai_manager->initialize_client();
595 1073
596 - $metadata = $ai_manager->generate_seo_metadata($content, $options);
1074 + // Run through the generator so title/description are capped to the
1075 + // configured limits and the character counts are returned.
1076 + $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager);
1077 + $metadata = $generator->generate_for_content($content, $options);
597 1078
598 1079 return new \WP_REST_Response([
599 1080 'success' => true,
600 1081 'data' => $metadata,
@@ -599,9 +1080,58 @@
599 1080 'success' => true,
600 1081 'data' => $metadata,
601 1082 'message' => __('SEO metadata generated successfully', 'thinkrank'),
602 1083 ]);
1084 + } catch (\Exception $e) {
1085 + return new \WP_REST_Response([
1086 + 'success' => false,
1087 + 'message' => $e->getMessage(),
1088 + ], 400);
1089 + }
1090 + }
603 1091
1092 + /**
1093 + * Generate and return an improved SEO title for an "Apply" suggestion action.
1094 + *
1095 + * @param \WP_REST_Request $request Request object.
1096 + * @return \WP_REST_Response Response with the improved title under data.title.
1097 + * @throws \Exception When title improvement fails or the AI client is unavailable.
1098 + */
1099 + public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response {
1100 + $content = $request->get_param('content');
1101 + $options = [
1102 + 'current_title' => $request->get_param('current_title'),
1103 + 'target_keyword' => $request->get_param('target_keyword'),
1104 + 'content_type' => $request->get_param('content_type'),
1105 + 'tone' => $request->get_param('tone'),
1106 + 'suggestion' => $request->get_param('suggestion'),
1107 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1108 + ];
1109 +
1110 + // Rate limiting: shares the AI generation bucket.
1111 + $user_id = get_current_user_id();
1112 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1113 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1114 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1115 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1116 + if (is_wp_error($allowed)) {
1117 + return new \WP_REST_Response([
1118 + 'success' => false,
1119 + 'message' => $allowed->get_error_message(),
1120 + ], $allowed->get_error_data()['status'] ?? 429);
1121 + }
1122 +
1123 + try {
1124 + $ai_manager = new \ThinkRank\AI\Manager();
1125 + $ai_manager->initialize_client();
1126 +
1127 + $result = $ai_manager->improve_seo_title($content, $options);
1128 +
1129 + return new \WP_REST_Response([
1130 + 'success' => true,
1131 + 'data' => $result,
1132 + 'message' => __('SEO title improved successfully', 'thinkrank'),
1133 + ]);
604 1134 } catch (\Exception $e) {
605 1135 return new \WP_REST_Response([
606 1136 'success' => false,
607 1137 'message' => $e->getMessage(),
@@ -609,8 +1139,264 @@
609 1139 }
610 1140 }
611 1141
612 1142 /**
1143 + * Generate and return an improved meta description for an "Apply" action.
1144 + *
1145 + * @param \WP_REST_Request $request Request object.
1146 + * @return \WP_REST_Response Response with the description under data.description.
1147 + * @throws \Exception When generation fails or the AI client is unavailable.
1148 + */
1149 + public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response {
1150 + $content = $request->get_param('content');
1151 + $options = [
1152 + 'current_description' => $request->get_param('current_description'),
1153 + 'target_keyword' => $request->get_param('target_keyword'),
1154 + 'content_type' => $request->get_param('content_type'),
1155 + 'tone' => $request->get_param('tone'),
1156 + 'suggestion' => $request->get_param('suggestion'),
1157 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1158 + ];
1159 +
1160 + // Rate limiting: shares the AI generation bucket.
1161 + $user_id = get_current_user_id();
1162 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1163 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1164 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1165 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1166 + if (is_wp_error($allowed)) {
1167 + return new \WP_REST_Response([
1168 + 'success' => false,
1169 + 'message' => $allowed->get_error_message(),
1170 + ], $allowed->get_error_data()['status'] ?? 429);
1171 + }
1172 +
1173 + try {
1174 + $ai_manager = new \ThinkRank\AI\Manager();
1175 + $ai_manager->initialize_client();
1176 +
1177 + $result = $ai_manager->improve_meta_description($content, $options);
1178 +
1179 + return new \WP_REST_Response([
1180 + 'success' => true,
1181 + 'data' => $result,
1182 + 'message' => __('Meta description generated successfully', 'thinkrank'),
1183 + ]);
1184 + } catch (\Exception $e) {
1185 + return new \WP_REST_Response([
1186 + 'success' => false,
1187 + 'message' => $e->getMessage(),
1188 + ], 400);
1189 + }
1190 + }
1191 +
1192 + /**
1193 + * Explain a single SEO suggestion in plain, post-specific language.
1194 + *
1195 + * Read-only copilot action: returns a short AI explanation of why the
1196 + * suggestion matters for this post and how to resolve it. Does not modify
1197 + * any content.
1198 + *
1199 + * @param \WP_REST_Request $request Request object.
1200 + * @return \WP_REST_Response Response with the explanation under data.explanation.
1201 + * @throws \Exception When generation fails or the AI client is unavailable.
1202 + */
1203 + public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response {
1204 + $content = $request->get_param('content');
1205 + $options = [
1206 + 'suggestion' => $request->get_param('suggestion'),
1207 + 'title' => $request->get_param('title'),
1208 + 'target_keyword' => $request->get_param('target_keyword'),
1209 + 'content_type' => $request->get_param('content_type'),
1210 + ];
1211 +
1212 + // Rate limiting: shares the AI generation bucket.
1213 + $user_id = get_current_user_id();
1214 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1215 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1216 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1217 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1218 + if (is_wp_error($allowed)) {
1219 + return new \WP_REST_Response([
1220 + 'success' => false,
1221 + 'message' => $allowed->get_error_message(),
1222 + ], $allowed->get_error_data()['status'] ?? 429);
1223 + }
1224 +
1225 + try {
1226 + $ai_manager = new \ThinkRank\AI\Manager();
1227 + $ai_manager->initialize_client();
1228 +
1229 + $result = $ai_manager->explain_seo_suggestion($content, $options);
1230 +
1231 + return new \WP_REST_Response([
1232 + 'success' => true,
1233 + 'data' => $result,
1234 + 'message' => __('Explanation generated successfully', 'thinkrank'),
1235 + ]);
1236 + } catch (\Exception $e) {
1237 + return new \WP_REST_Response([
1238 + 'success' => false,
1239 + 'message' => $e->getMessage(),
1240 + ], 400);
1241 + }
1242 + }
1243 +
1244 + /**
1245 + * Generate a content fragment with one authoritative external dofollow link.
1246 + *
1247 + * @param \WP_REST_Request $request Request object.
1248 + * @return \WP_REST_Response Response with the HTML fragment under data.html.
1249 + * @throws \Exception When generation fails or the AI client is unavailable.
1250 + */
1251 + public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response {
1252 + $content = $request->get_param('content');
1253 + $options = [
1254 + 'target_keyword' => $request->get_param('target_keyword'),
1255 + 'content_type' => $request->get_param('content_type'),
1256 + ];
1257 +
1258 + // Rate limiting: shares the AI generation bucket.
1259 + $user_id = get_current_user_id();
1260 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1261 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1262 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1263 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1264 + if (is_wp_error($allowed)) {
1265 + return new \WP_REST_Response([
1266 + 'success' => false,
1267 + 'message' => $allowed->get_error_message(),
1268 + ], $allowed->get_error_data()['status'] ?? 429);
1269 + }
1270 +
1271 + try {
1272 + $ai_manager = new \ThinkRank\AI\Manager();
1273 + $ai_manager->initialize_client();
1274 +
1275 + $result = $ai_manager->generate_dofollow_link($content, $options);
1276 +
1277 + return new \WP_REST_Response([
1278 + 'success' => true,
1279 + 'data' => $result,
1280 + 'message' => __('Added an authoritative source link', 'thinkrank'),
1281 + ]);
1282 + } catch (\Exception $e) {
1283 + return new \WP_REST_Response([
1284 + 'success' => false,
1285 + 'message' => $e->getMessage(),
1286 + ], 400);
1287 + }
1288 + }
1289 +
1290 + /**
1291 + * Generate a keyword-rich paragraph to lift keyword density into band.
1292 + *
1293 + * @param \WP_REST_Request $request Request object.
1294 + * @return \WP_REST_Response Response with the HTML fragment under data.html.
1295 + * @throws \Exception When generation fails or the AI client is unavailable.
1296 + */
1297 + public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response {
1298 + $content = $request->get_param('content');
1299 + $options = [
1300 + 'target_keyword' => $request->get_param('target_keyword'),
1301 + 'content_type' => $request->get_param('content_type'),
1302 + 'tone' => $request->get_param('tone'),
1303 + 'word_count' => $request->get_param('word_count'),
1304 + 'keyword_count' => $request->get_param('keyword_count'),
1305 + ];
1306 +
1307 + // Rate limiting: shares the AI generation bucket.
1308 + $user_id = get_current_user_id();
1309 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1310 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1311 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1312 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1313 + if (is_wp_error($allowed)) {
1314 + return new \WP_REST_Response([
1315 + 'success' => false,
1316 + 'message' => $allowed->get_error_message(),
1317 + ], $allowed->get_error_data()['status'] ?? 429);
1318 + }
1319 +
1320 + try {
1321 + $ai_manager = new \ThinkRank\AI\Manager();
1322 + $ai_manager->initialize_client();
1323 +
1324 + $result = $ai_manager->generate_keyword_paragraph($content, $options);
1325 +
1326 + return new \WP_REST_Response([
1327 + 'success' => true,
1328 + 'data' => $result,
1329 + 'message' => __('Added a keyword-focused paragraph', 'thinkrank'),
1330 + ]);
1331 + } catch (\Exception $e) {
1332 + return new \WP_REST_Response([
1333 + 'success' => false,
1334 + 'message' => $e->getMessage(),
1335 + ], 400);
1336 + }
1337 + }
1338 +
1339 + /**
1340 + * Enable ThinkRank's Global SEO schema output for a post's post type.
1341 + *
1342 + * Sets a sensible default schema type (Article for posts, WebPage for pages)
1343 + * when none is configured yet, so ThinkRank emits JSON-LD for the post. This
1344 + * resolves the "add structured data" suggestion, which the scorer now credits
1345 + * when ThinkRank schema is active.
1346 + *
1347 + * @param \WP_REST_Request $request Request object.
1348 + * @return \WP_REST_Response Response describing the enabled schema type.
1349 + */
1350 + public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response {
1351 + $post_id = (int) $request->get_param('post_id');
1352 + $post = get_post($post_id);
1353 + if (!$post) {
1354 + return new \WP_REST_Response([
1355 + 'success' => false,
1356 + 'message' => __('Post not found.', 'thinkrank'),
1357 + ], 404);
1358 + }
1359 +
1360 + $post_type = $post->post_type;
1361 + $settings = get_option('thinkrank_global_seo_settings', []);
1362 + if (!is_array($settings)) {
1363 + $settings = [];
1364 + }
1365 + if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) {
1366 + $settings[$post_type] = [];
1367 + }
1368 +
1369 + $already_enabled = !empty($settings[$post_type]['schema_type']);
1370 + if (!$already_enabled) {
1371 + if ($post_type === 'page') {
1372 + $settings[$post_type]['schema_type'] = 'WebPage';
1373 + } else {
1374 + $settings[$post_type]['schema_type'] = 'Article';
1375 + if (empty($settings[$post_type]['article_type'])) {
1376 + $settings[$post_type]['article_type'] = 'BlogPosting';
1377 + }
1378 + }
1379 + update_option('thinkrank_global_seo_settings', $settings);
1380 + }
1381 +
1382 + $schema_type = $settings[$post_type]['schema_type'];
1383 +
1384 + return new \WP_REST_Response([
1385 + 'success' => true,
1386 + 'data' => [
1387 + 'schema_type' => $schema_type,
1388 + 'post_type' => $post_type,
1389 + 'already_enabled' => $already_enabled,
1390 + ],
1391 + 'message' => $already_enabled
1392 + ? __('Schema was already enabled for this post type.', 'thinkrank')
1393 + /* translators: %s: schema type. */
1394 + : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type),
1395 + ]);
1396 + }
1397 +
1398 + /**
613 1399 * Test AI connection for specified provider
614 1400 *
615 1401 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
616 1402 * @return \WP_REST_Response Response object with connection test results
@@ -618,11 +1404,11 @@
618 1404 */
619 1405 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
620 1406 // Rate limiting: per user/IP per route
621 1407 $user_id = get_current_user_id();
622 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
1408 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
623 1409 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
624 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
1410 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
625 1411 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
626 1412 if (is_wp_error($allowed)) {
627 1413 return new \WP_REST_Response([
628 1414 'success' => false,
@@ -632,16 +1418,33 @@
632 1418
633 1419 try {
634 1420 $api_key = $request->get_param('api_key');
635 1421 $provider = $request->get_param('provider') ?: 'openai';
1422 + // The model the caller is asking about. Empty means "whatever is
1423 + // saved" — the settings screen sends the model currently on screen
1424 + // so an unsaved pick or a hand-typed id is what actually gets
1425 + // tested, rather than the last saved one.
1426 + $model = trim((string) $request->get_param('model'));
636 1427
1428 + // An unrecognised provider used to fall through to the Gemini arm
1429 + // below, so a typo silently tested the wrong provider's key.
1430 + if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1431 + return new \WP_REST_Response([
1432 + 'success' => false,
1433 + /* translators: %s: the unrecognised provider value. */
1434 + 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1435 + ], 400);
1436 + }
1437 +
637 1438 // If no API key provided in request, try to get from saved settings
638 1439 if (empty($api_key)) {
639 - $settings = new \ThinkRank\Core\Settings();
1440 + $settings = \ThinkRank\Core\Settings::instance();
640 1441 if ($provider === 'openai') {
641 1442 $api_key = (string) $settings->get('openai_api_key', '');
642 1443 } elseif ($provider === 'claude') {
643 1444 $api_key = (string) $settings->get('claude_api_key', '');
1445 + } elseif ($provider === 'openrouter') {
1446 + $api_key = (string) $settings->get('openrouter_api_key', '');
644 1447 } else {
645 1448 $api_key = (string) $settings->get('gemini_api_key', '');
646 1449 }
647 1450
@@ -654,17 +1457,18 @@
654 1457 }
655 1458
656 1459 // Test the connection with a simple API call
657 1460 if ($provider === 'openai') {
658 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
659 1462 } elseif ($provider === 'claude') {
660 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1464 + } elseif ($provider === 'openrouter') {
1465 + $result = $this->test_openrouter_connection($api_key, $model);
661 1466 } else {
662 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
663 1468 }
664 1469
665 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
666 -
667 1471 } catch (\Exception $e) {
668 1472 return new \WP_REST_Response([
669 1473 'success' => false,
670 1474 'message' => $e->getMessage(),
@@ -674,12 +1478,21 @@
674 1478
675 1479 /**
676 1480 * Test OpenAI API connection
677 1481 *
1482 + * The models endpoint doubles as the model check: it answers with every id
1483 + * this key may call, so an unknown or unentitled model is caught here
1484 + * instead of at the first real generation.
1485 + *
678 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
679 1488 * @return array Test result
680 1489 */
681 - private function test_openai_connection(string $api_key): array {
1490 + private function test_openai_connection(string $api_key, string $model = ''): array {
1491 + $model = $model !== ''
1492 + ? $model
1493 + : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
1494 +
682 1495 $url = 'https://api.openai.com/v1/models';
683 1496
684 1497 $response = wp_remote_get($url, [
685 1498 'headers' => [
@@ -701,11 +1514,28 @@
701 1514
702 1515 if ($status_code === 200) {
703 1516 $data = json_decode($body, true);
704 1517 if (isset($data['data']) && is_array($data['data'])) {
1518 + $ids = array_column($data['data'], 'id');
1519 +
1520 + if ($model !== '' && !in_array($model, $ids, true)) {
1521 + return [
1522 + 'success' => false,
1523 + 'model' => $model,
1524 + 'model_available' => false,
1525 + /* translators: %s: the model id that was tested. */
1526 + 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
1527 + ];
1528 + }
1529 +
705 1530 return [
706 1531 'success' => true,
707 - 'message' => __('OpenAI API connection successful!', 'thinkrank'),
1532 + 'model' => $model,
1533 + 'model_available' => $model !== '',
1534 + 'message' => $model !== ''
1535 + /* translators: %s: the model id that was tested. */
1536 + ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model)
1537 + : __('OpenAI API connection successful!', 'thinkrank'),
708 1538 'models_count' => count($data['data']),
709 1539 ];
710 1540 }
711 1541 }
@@ -720,14 +1550,135 @@
720 1550 ];
721 1551 }
722 1552
723 1553 /**
1554 + * Test OpenRouter API connection
1555 + *
1556 + * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1558 + * @return array Test result
1559 + */
1560 + private function test_openrouter_connection(string $api_key, string $model = ''): array {
1561 + $model = $model !== ''
1562 + ? $model
1563 + : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
1564 +
1565 + // Validate the key format first (OpenRouter keys start with "sk-or-").
1566 + if (!str_starts_with($api_key, 'sk-or-')) {
1567 + return [
1568 + 'success' => false,
1569 + 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'),
1570 + ];
1571 + }
1572 +
1573 + // The key endpoint validates the credential and returns its metadata.
1574 + $url = 'https://openrouter.ai/api/v1/key';
1575 +
1576 + $response = wp_remote_get($url, [
1577 + 'headers' => [
1578 + 'Authorization' => 'Bearer ' . $api_key,
1579 + 'Content-Type' => 'application/json',
1580 + 'HTTP-Referer' => home_url('/'),
1581 + 'X-Title' => 'ThinkRank',
1582 + ],
1583 + 'timeout' => 10,
1584 + ]);
1585 +
1586 + if (is_wp_error($response)) {
1587 + return [
1588 + 'success' => false,
1589 + 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(),
1590 + ];
1591 + }
1592 +
1593 + $status_code = wp_remote_retrieve_response_code($response);
1594 + $body = wp_remote_retrieve_body($response);
1595 +
1596 + if ($status_code === 200) {
1597 + $data = json_decode($body, true);
1598 + if (isset($data['data']) && is_array($data['data'])) {
1599 + // The key is good; the catalogue is a separate document, so
1600 + // the model needs its own lookup.
1601 + if ($model !== '') {
1602 + $model_check = $this->check_openrouter_model($api_key, $model);
1603 + if ($model_check !== null) {
1604 + return $model_check;
1605 + }
1606 + }
1607 +
1608 + return [
1609 + 'success' => true,
1610 + 'model' => $model,
1611 + 'model_available' => $model !== '',
1612 + 'message' => $model !== ''
1613 + /* translators: %s: the model id that was tested. */
1614 + ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model)
1615 + : __('OpenRouter API connection successful!', 'thinkrank'),
1616 + ];
1617 + }
1618 + }
1619 +
1620 + // Handle error response
1621 + $error_data = json_decode($body, true);
1622 + $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1623 +
1624 + return [
1625 + 'success' => false,
1626 + 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message,
1627 + ];
1628 + }
1629 +
1630 + /**
1631 + * Verify a model id against OpenRouter's public catalogue.
1632 + *
1633 + * @param string $api_key API key to authenticate the lookup
1634 + * @param string $model Model id to look for
1635 + * @return array|null Failure payload when the model is unknown, null when it
1636 + * is available or when the catalogue could not be read —
1637 + * a listing hiccup must not fail an otherwise good key.
1638 + */
1639 + private function check_openrouter_model(string $api_key, string $model): ?array {
1640 + $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
1641 + 'headers' => [
1642 + 'Authorization' => 'Bearer ' . $api_key,
1643 + 'Content-Type' => 'application/json',
1644 + 'HTTP-Referer' => home_url('/'),
1645 + 'X-Title' => 'ThinkRank',
1646 + ],
1647 + 'timeout' => 10,
1648 + ]);
1649 +
1650 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1651 + return null;
1652 + }
1653 +
1654 + $data = json_decode(wp_remote_retrieve_body($response), true);
1655 + if (!isset($data['data']) || !is_array($data['data'])) {
1656 + return null;
1657 + }
1658 +
1659 + $ids = array_column($data['data'], 'id');
1660 + if (in_array($model, $ids, true)) {
1661 + return null;
1662 + }
1663 +
1664 + return [
1665 + 'success' => false,
1666 + 'model' => $model,
1667 + 'model_available' => false,
1668 + /* translators: %s: the model id that was tested. */
1669 + 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
1670 + ];
1671 + }
1672 +
1673 + /**
724 1674 * Test Claude API connection
725 1675 *
726 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
727 1678 * @return array Test result
728 1679 */
729 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
730 1681 // First validate the key format
731 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
732 1683 return [
733 1684 'success' => false,
@@ -737,10 +1688,18 @@
737 1688
738 1689 // Test with a simple API call
739 1690 $url = 'https://api.anthropic.com/v1/messages';
740 1691
741 - // Get the configured Claude model, with fallback to a current model
742 - $claude_model = (new \ThinkRank\Core\Settings())->get('claude_model', 'claude-3-7-sonnet-latest');
1692 + // A model sent with the request is tested verbatim: normalizing it would
1693 + // quietly swap a typo for a working id and report success for a model
1694 + // the user never asked for. Only the saved fallback is self-healed, as
1695 + // that is the path where a retired id from an older release shows up.
1696 + if ($model !== '') {
1697 + $claude_model = $model;
1698 + } else {
1699 + $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1700 + $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
1701 + }
743 1702
744 1703 $body = [
745 1704 'model' => $claude_model,
746 1705 'max_tokens' => 10,
@@ -774,16 +1733,32 @@
774 1733
775 1734 if ($status_code === 200) {
776 1735 return [
777 1736 'success' => true,
778 - 'message' => __('Claude API connection successful!', 'thinkrank'),
1737 + 'model' => $claude_model,
1738 + 'model_available' => true,
1739 + /* translators: %s: the model id that was tested. */
1740 + 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model),
779 1741 ];
780 1742 } else {
781 1743 $error_data = json_decode($response_body, true);
782 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
783 1745
1746 + // 404 on /v1/messages means the key authenticated but the model id
1747 + // does not exist — say so, instead of blaming the key.
1748 + if ($status_code === 404) {
1749 + return [
1750 + 'success' => false,
1751 + 'model' => $claude_model,
1752 + 'model_available' => false,
1753 + /* translators: %s: the model id that was tested. */
1754 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
1755 + ];
1756 + }
1757 +
784 1758 return [
785 1759 'success' => false,
1760 + 'model' => $claude_model,
786 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
787 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
788 1763 ];
789 1764 }
@@ -792,15 +1767,26 @@
792 1767 /**
793 1768 * Test Gemini API connection
794 1769 *
795 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
796 1772 * @return array Test result
797 1773 */
798 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
799 1775 // Test with a simple API call
800 - $gemini_model = (new \ThinkRank\Core\Settings())->get('gemini_model', 'gemini-2.5-flash');
801 - $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
1776 + $gemini_model = $model !== ''
1777 + ? $model
1778 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
802 1779
1780 + // The model is a path segment, and ids may arrive with the "models/"
1781 + // prefix Google's own docs use.
1782 + $gemini_model = ltrim($gemini_model, '/');
1783 + $gemini_model = preg_replace('#^models/#', '', $gemini_model);
1784 +
1785 + $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
1786 + . rawurlencode($gemini_model)
1787 + . ':generateContent?key=' . rawurlencode($api_key);
1788 +
803 1789 $body = [
804 1790 'contents' => [
805 1791 [
806 1792 'parts' => [
@@ -834,16 +1820,32 @@
834 1820
835 1821 if ($status_code === 200) {
836 1822 return [
837 1823 'success' => true,
838 - 'message' => __('Gemini API connection successful!', 'thinkrank'),
1824 + 'model' => $gemini_model,
1825 + 'model_available' => true,
1826 + /* translators: %s: the model id that was tested. */
1827 + 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model),
839 1828 ];
840 1829 } else {
841 1830 $error_data = json_decode($response_body, true);
842 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
843 1832
1833 + // Gemini answers 404 for a model id it does not serve; the key
1834 + // itself authenticated fine, so name the real problem.
1835 + if ($status_code === 404) {
1836 + return [
1837 + 'success' => false,
1838 + 'model' => $gemini_model,
1839 + 'model_available' => false,
1840 + /* translators: %s: the model id that was tested. */
1841 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
1842 + ];
1843 + }
1844 +
844 1845 return [
845 1846 'success' => false,
1847 + 'model' => $gemini_model,
846 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
847 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
848 1850 ];
849 1851 }
@@ -888,11 +1890,11 @@
888 1890 $post_id = $request->get_param('post_id');
889 1891
890 1892 // Rate limiting: per user/IP per route
891 1893 $user_id = get_current_user_id();
892 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
1894 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
893 1895 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
894 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
1896 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
895 1897 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
896 1898 if (is_wp_error($allowed)) {
897 1899 return new \WP_REST_Response([
898 1900 'success' => false,
@@ -912,9 +1914,8 @@
912 1914 'success' => true,
913 1915 'data' => $analysis,
914 1916 'message' => __('Content analyzed successfully', 'thinkrank'),
915 1917 ]);
916 -
917 1918 } catch (\Exception $e) {
918 1919 return new \WP_REST_Response([
919 1920 'success' => false,
920 1921 'message' => $e->getMessage(),
@@ -967,8 +1968,15 @@
967 1968 // Failed to register Site Identity endpoint
968 1969 }
969 1970
970 1971 try {
1972 + $ai_insights_endpoint = new Ai_Insights_Endpoint();
1973 + $ai_insights_endpoint->register_routes();
1974 + } catch (\Exception $e) {
1975 + // Failed to register AI Insights endpoint
1976 + }
1977 +
1978 + try {
971 1979 $performance_endpoint = new Performance_Endpoint();
972 1980 $performance_endpoint->register_routes();
973 1981 } catch (\Exception $e) {
974 1982 // Failed to register Performance endpoint
@@ -1028,7 +2036,69 @@
1028 2036 $llms_txt_endpoint->register_routes();
1029 2037 } catch (\Exception $e) {
1030 2038 // Failed to register LLMs.txt endpoint
1031 2039 }
2040 +
2041 + try {
2042 + $global_seo_endpoint = new Global_SEO_Endpoint();
2043 + $global_seo_endpoint->register_routes();
2044 + } catch (\Exception $e) {
2045 + // Failed to register Global SEO endpoint
2046 + }
2047 +
2048 + try {
2049 + $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2050 + $content_type_matrix_endpoint->register_routes();
2051 + } catch (\Exception $e) {
2052 + // Failed to register Content Type Matrix endpoint
2053 + }
2054 +
2055 + try {
2056 + $image_seo_endpoint = new Image_SEO_Endpoint();
2057 + $image_seo_endpoint->register_routes();
2058 + } catch (\Exception $e) {
2059 + // Failed to register Image SEO endpoint
2060 + }
2061 +
2062 + try {
2063 + $external_links_endpoint = new External_Links_Endpoint();
2064 + $external_links_endpoint->register_routes();
2065 + } catch (\Exception $e) {
2066 + // Failed to register External Links endpoint
2067 + }
2068 +
2069 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2070 + // /import/detect backs the setup wizard's migration step and the record
2071 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2072 + // run the wizard's actual import — all on a fresh install, where the
2073 + // setting is off. Gating them would break onboarding, which is a worse
2074 + // bug than the one #583 reports.
2075 + try {
2076 + $import_controller = new Import_Controller();
2077 + $import_controller->register_routes();
2078 + } catch (\Exception $e) {
2079 + // Failed to register Import endpoint
2080 + }
2081 +
2082 + // Export/restore is gated on the setting that gates its admin screen,
2083 + // so turning Import / Export off removes its REST surface along with
2084 + // its menu item (#583). Nothing in the setup wizard calls these:
2085 + // MigrationPluginRow takes startExport/startMigration/cancel from
2086 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2087 + // request, so a toggle takes effect on the next one — no flush.
2088 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2089 + try {
2090 + $export_controller = new Export_Controller();
2091 + $export_controller->register_routes();
2092 + } catch (\Exception $e) {
2093 + // Failed to register Export endpoint
2094 + }
2095 + }
2096 +
2097 + try {
2098 + $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
2099 + $setup_wizard_endpoint->register_routes();
2100 + } catch (\Exception $e) {
2101 + // Failed to register Setup Wizard endpoint
2102 + }
1032 2103 }
1033 -
1034 2104 }