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 +1154 -92 1.1.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;
@@ -26,9 +26,20 @@
26 26 use ThinkRank\API\Integrations_Endpoint;
27 27 use ThinkRank\API\Social_Platforms_Endpoint;
28 28 use ThinkRank\API\LLMs_Txt_Endpoint;
29 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;
30 40
41 +
31 42 // Prevent direct access
32 43 if (!defined('ABSPATH')) {
33 44 exit;
34 45 }
@@ -48,9 +59,31 @@
48 59 * @var string
49 60 */
50 61 private const NAMESPACE = 'thinkrank/v1';
51 62
52 - /**
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 + /**
53 86 * Initialize API manager
54 87 *
55 88 * @return void
56 89 */
@@ -56,8 +89,21 @@
56 89 */
57 90 public function init(): void {
58 91 add_action('rest_api_init', [$this, 'register_routes']);
59 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);
60 106 }
61 107
62 108 /**
63 109 * Register REST API routes
@@ -83,25 +129,39 @@
83 129 'callback' => [$this, 'get_system_status'],
84 130 'permission_callback' => [$this, 'check_basic_permissions'],
85 131 ]);
86 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 + ]);
87 142
88 -
89 143 // Settings endpoints
90 144 register_rest_route(self::NAMESPACE, '/settings', [
91 145 'methods' => 'GET',
92 146 'callback' => [$this, 'get_settings'],
93 - 'permission_callback' => [$this, 'check_admin_permissions'],
147 + 'permission_callback' => [$this, 'check_settings_permissions'],
94 148 ]);
95 149
96 150 register_rest_route(self::NAMESPACE, '/settings', [
97 151 'methods' => 'POST',
98 152 'callback' => [$this, 'save_settings'],
99 - 'permission_callback' => [$this, 'check_admin_permissions'],
153 + 'permission_callback' => [$this, 'check_settings_permissions'],
100 154 'args' => [
101 155 'ai_provider' => [
102 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(),
103 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',
104 164 ],
105 165 'openai_api_key' => [
106 166 'type' => 'string',
107 167 'sanitize_callback' => 'sanitize_text_field',
@@ -125,12 +185,51 @@
125 185 'gemini_model' => [
126 186 'type' => 'string',
127 187 'sanitize_callback' => 'sanitize_text_field',
128 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 + ],
129 216 'keep_data_on_uninstall' => [
130 217 'type' => 'boolean',
131 218 'sanitize_callback' => 'rest_sanitize_boolean',
132 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 + ],
133 232 ],
134 233 ]);
135 234
136 235
@@ -156,8 +255,86 @@
156 255 'args' => [
157 256 'content' => [
158 257 'type' => 'string',
159 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',
160 337 'sanitize_callback' => 'sanitize_textarea_field',
161 338 ],
162 339 'target_keyword' => [
163 340 'type' => 'string',
@@ -172,11 +349,125 @@
172 349 'type' => 'string',
173 350 'default' => 'professional',
174 351 'sanitize_callback' => 'sanitize_text_field',
175 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 + ],
176 363 ],
177 364 ]);
178 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 +
179 470 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
180 471 'methods' => 'POST',
181 472 'callback' => [$this, 'test_ai_connection'],
182 473 'permission_callback' => [$this, 'check_admin_permissions'],
@@ -191,8 +482,13 @@
191 482 'required' => false,
192 483 'default' => 'openai',
193 484 'sanitize_callback' => 'sanitize_key',
194 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
195 491 ],
196 492 ]);
197 493
198 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -222,8 +518,16 @@
222 518 } catch (\Exception $e) {
223 519 // Usage Analytics endpoint registration failed
224 520 }
225 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 +
226 530 // Register SEO Analytics endpoints
227 531 try {
228 532 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
229 533 $seo_analytics_endpoint->register_routes();
@@ -230,19 +534,64 @@
230 534 } catch (\Exception $e) {
231 535 // Failed to register SEO Analytics endpoint
232 536 }
233 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 + }
234 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 + }
235 553
236 - // Add a simple test endpoint to verify API is working
237 - register_rest_route(self::NAMESPACE, '/seo-score/test', [
238 - 'methods' => 'GET',
239 - 'callback' => function() {
240 - return ['message' => 'SEO Score API is working!', 'timestamp' => current_time('mysql')];
241 - },
242 - 'permission_callback' => [$this, 'check_basic_permissions'],
243 - ]);
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 + }
244 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 +
245 594 register_rest_route(self::NAMESPACE, '/ai/status', [
246 595 'methods' => 'GET',
247 596 'callback' => [$this, 'get_ai_status'],
248 597 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -255,9 +604,9 @@
255 604 'args' => [
256 605 'content' => [
257 606 'type' => 'string',
258 607 'required' => true,
259 - 'sanitize_callback' => 'sanitize_textarea_field',
608 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
260 609 ],
261 610 'metadata' => [
262 611 'type' => 'object',
263 612 'required' => false,
@@ -299,38 +648,38 @@
299 648 return true;
300 649 }
301 650
302 651
303 - /**
304 - * Simple transient-based rate limiter
305 - *
306 - * @param string $bucket_id Unique bucket per user/IP and route
307 - * @param int $limit Max requests per minute
308 - * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
309 - */
310 - private function enforce_rate_limit(string $bucket_id, int $limit) {
311 - $settings = new \ThinkRank\Core\Settings();
312 - $enabled = (bool) $settings->get('enable_rate_limiting', true);
313 - if (!$enabled) {
314 - return true;
315 - }
316 - $now = time();
317 - $window = 60;
318 - $key = 'thinkrank_rl_' . md5($bucket_id);
319 - $bucket = get_transient($key);
320 - if (!is_array($bucket)) {
321 - $bucket = ['start' => $now, 'count' => 0];
322 - }
323 - if ($now - ($bucket['start'] ?? 0) >= $window) {
324 - $bucket = ['start' => $now, 'count' => 0];
325 - }
326 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
327 - return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
328 - }
329 - $bucket['count']++;
330 - set_transient($key, $bucket, $window);
331 - return true;
332 - }
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 + }
333 682
334 683 /**
335 684 * Check admin permissions (for settings)
336 685 *
@@ -358,8 +707,38 @@
358 707 return true;
359 708 }
360 709
361 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 + /**
362 741 * Get user capabilities
363 742 *
364 743 * @param \WP_REST_Request $request Request object
365 744 * @return \WP_REST_Response Response object
@@ -401,10 +780,23 @@
401 780 'wp_version' => get_bloginfo('version'),
402 781 ]);
403 782 }
404 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 + }
405 796
406 797
798 +
407 799 /**
408 800 * Get settings
409 801 *
410 802 * @param \WP_REST_Request $request Request object
@@ -411,41 +803,70 @@
411 803 * @return \WP_REST_Response Response object
412 804 */
413 805 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
414 806 // Use Settings class for consistent access (handles decryption automatically)
415 - $settings_instance = new \ThinkRank\Core\Settings();
807 + $settings_instance = \ThinkRank\Core\Settings::instance();
416 808
417 809 $settings = [
418 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
810 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
419 811 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
420 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
812 + 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
421 813 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
422 - '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),
423 815 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
424 - '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),
425 819 'max_tokens' => $settings_instance->get('max_tokens', 1000),
426 820 'temperature' => $settings_instance->get('temperature', 0.7),
427 821 'cache_duration' => $settings_instance->get('cache_duration', 3600),
428 - '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),
429 827 ];
430 828
431 829
432 830
433 - // 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.
434 833 if (!empty($settings['openai_api_key'])) {
435 - $settings['openai_api_key'] = '••••••••' . substr($settings['openai_api_key'], -4);
834 + $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
436 835 }
437 836 if (!empty($settings['claude_api_key'])) {
438 - $settings['claude_api_key'] = '••••••••' . substr($settings['claude_api_key'], -4);
837 + $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
439 838 }
440 839 if (!empty($settings['gemini_api_key'])) {
441 - $settings['gemini_api_key'] = '••••••••' . substr($settings['gemini_api_key'], -4);
840 + $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
442 841 }
842 + if (!empty($settings['openrouter_api_key'])) {
843 + $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
844 + }
443 845
444 846 return new \WP_REST_Response($settings);
445 847 }
446 848
447 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 + /**
448 869 * Save settings
449 870 *
450 871 * @param \WP_REST_Request $request Request object
451 872 * @return \WP_REST_Response Response object
@@ -453,10 +874,14 @@
453 874 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
454 875 $params = $request->get_params();
455 876
456 877 // Get Settings instance for proper encryption handling
457 - $settings = new \ThinkRank\Core\Settings();
878 + $settings = \ThinkRank\Core\Settings::instance();
458 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 +
459 884 // Map frontend parameter names to setting keys
460 885 $settings_map = [
461 886 'ai_provider' => 'ai_provider',
462 887 'openai_api_key' => 'openai_api_key',
@@ -464,12 +889,17 @@
464 889 'claude_api_key' => 'claude_api_key',
465 890 'claude_model' => 'claude_model',
466 891 'gemini_api_key' => 'gemini_api_key',
467 892 'gemini_model' => 'gemini_model',
893 + 'openrouter_api_key' => 'openrouter_api_key',
894 + 'openrouter_model' => 'openrouter_model',
468 895 'max_tokens' => 'max_tokens',
469 896 'temperature' => 'temperature',
470 897 'cache_duration' => 'cache_duration',
471 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',
472 902 ];
473 903
474 904 // Processing settings save request
475 905
@@ -477,27 +907,48 @@
477 907 if (isset($params[$param_key])) {
478 908 $value = $params[$param_key];
479 909
480 910 // Handle API keys specially - check for masked values
481 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key'])) {
482 - // Don't update if masked (but allow empty to clear)
483 - 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) {
484 916 continue;
485 917 }
486 918 }
487 919
488 920 // Use Settings class for all operations (handles encryption automatically)
489 - if (!$settings->set($setting_key, $value)) {
490 - // Settings save failed, continue with other settings
491 - }
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);
492 924 }
493 925 }
494 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 +
495 946 // Auto-dismiss welcome notice if API key was saved
496 947 $this->maybe_dismiss_welcome_notice($params);
497 948
498 949 // Force AI Manager to re-initialize client with new settings
499 - 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'])) {
500 951 // Clear any cached AI Manager instances to force re-initialization
501 952 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
502 953
503 954 // If we have an AI Manager instance, force it to re-initialize
@@ -546,17 +997,40 @@
546 997 * @param \WP_REST_Request $request Request object
547 998 * @return \WP_REST_Response Response object
548 999 */
549 1000 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
550 - $post_id = $request->get_param('post_id');
1001 + $post_id = (int) $request->get_param('post_id');
551 1002
552 - // 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)
553 1023 $metadata = [
554 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
555 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
556 - '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),
557 1027 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
558 - '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,
559 1033 ];
560 1034
561 1035 return new \WP_REST_Response($metadata);
562 1036 }
@@ -573,15 +1047,18 @@
573 1047 $options = [
574 1048 'target_keyword' => $request->get_param('target_keyword'),
575 1049 'content_type' => $request->get_param('content_type'),
576 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')),
577 1054 ];
578 1055
579 1056 // Rate limiting: per user/IP per route
580 1057 $user_id = get_current_user_id();
581 - $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
582 1059 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
583 - $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);
584 1061 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
585 1062 if (is_wp_error($allowed)) {
586 1063 return new \WP_REST_Response([
587 1064 'success' => false,
@@ -593,9 +1070,12 @@
593 1070 // Get AI manager instance
594 1071 $ai_manager = new \ThinkRank\AI\Manager();
595 1072 $ai_manager->initialize_client();
596 1073
597 - $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);
598 1078
599 1079 return new \WP_REST_Response([
600 1080 'success' => true,
601 1081 'data' => $metadata,
@@ -600,9 +1080,58 @@
600 1080 'success' => true,
601 1081 'data' => $metadata,
602 1082 'message' => __('SEO metadata generated successfully', 'thinkrank'),
603 1083 ]);
1084 + } catch (\Exception $e) {
1085 + return new \WP_REST_Response([
1086 + 'success' => false,
1087 + 'message' => $e->getMessage(),
1088 + ], 400);
1089 + }
1090 + }
604 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 + ]);
605 1134 } catch (\Exception $e) {
606 1135 return new \WP_REST_Response([
607 1136 'success' => false,
608 1137 'message' => $e->getMessage(),
@@ -610,8 +1139,264 @@
610 1139 }
611 1140 }
612 1141
613 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 + /**
614 1399 * Test AI connection for specified provider
615 1400 *
616 1401 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
617 1402 * @return \WP_REST_Response Response object with connection test results
@@ -619,11 +1404,11 @@
619 1404 */
620 1405 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
621 1406 // Rate limiting: per user/IP per route
622 1407 $user_id = get_current_user_id();
623 - $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
624 1409 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
625 - $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);
626 1411 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
627 1412 if (is_wp_error($allowed)) {
628 1413 return new \WP_REST_Response([
629 1414 'success' => false,
@@ -633,16 +1418,33 @@
633 1418
634 1419 try {
635 1420 $api_key = $request->get_param('api_key');
636 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'));
637 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 +
638 1438 // If no API key provided in request, try to get from saved settings
639 1439 if (empty($api_key)) {
640 - $settings = new \ThinkRank\Core\Settings();
1440 + $settings = \ThinkRank\Core\Settings::instance();
641 1441 if ($provider === 'openai') {
642 1442 $api_key = (string) $settings->get('openai_api_key', '');
643 1443 } elseif ($provider === 'claude') {
644 1444 $api_key = (string) $settings->get('claude_api_key', '');
1445 + } elseif ($provider === 'openrouter') {
1446 + $api_key = (string) $settings->get('openrouter_api_key', '');
645 1447 } else {
646 1448 $api_key = (string) $settings->get('gemini_api_key', '');
647 1449 }
648 1450
@@ -655,17 +1457,18 @@
655 1457 }
656 1458
657 1459 // Test the connection with a simple API call
658 1460 if ($provider === 'openai') {
659 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
660 1462 } elseif ($provider === 'claude') {
661 - $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);
662 1466 } else {
663 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
664 1468 }
665 1469
666 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
667 -
668 1471 } catch (\Exception $e) {
669 1472 return new \WP_REST_Response([
670 1473 'success' => false,
671 1474 'message' => $e->getMessage(),
@@ -675,12 +1478,21 @@
675 1478
676 1479 /**
677 1480 * Test OpenAI API connection
678 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 + *
679 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
680 1488 * @return array Test result
681 1489 */
682 - 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 +
683 1495 $url = 'https://api.openai.com/v1/models';
684 1496
685 1497 $response = wp_remote_get($url, [
686 1498 'headers' => [
@@ -702,11 +1514,28 @@
702 1514
703 1515 if ($status_code === 200) {
704 1516 $data = json_decode($body, true);
705 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 +
706 1530 return [
707 1531 'success' => true,
708 - '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'),
709 1538 'models_count' => count($data['data']),
710 1539 ];
711 1540 }
712 1541 }
@@ -721,14 +1550,135 @@
721 1550 ];
722 1551 }
723 1552
724 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 + /**
725 1674 * Test Claude API connection
726 1675 *
727 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
728 1678 * @return array Test result
729 1679 */
730 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
731 1681 // First validate the key format
732 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
733 1683 return [
734 1684 'success' => false,
@@ -738,10 +1688,18 @@
738 1688
739 1689 // Test with a simple API call
740 1690 $url = 'https://api.anthropic.com/v1/messages';
741 1691
742 - // Get the configured Claude model, with fallback to a current model
743 - $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 + }
744 1702
745 1703 $body = [
746 1704 'model' => $claude_model,
747 1705 'max_tokens' => 10,
@@ -775,16 +1733,32 @@
775 1733
776 1734 if ($status_code === 200) {
777 1735 return [
778 1736 'success' => true,
779 - '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),
780 1741 ];
781 1742 } else {
782 1743 $error_data = json_decode($response_body, true);
783 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
784 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 +
785 1758 return [
786 1759 'success' => false,
1760 + 'model' => $claude_model,
787 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
788 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
789 1763 ];
790 1764 }
@@ -793,15 +1767,26 @@
793 1767 /**
794 1768 * Test Gemini API connection
795 1769 *
796 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
797 1772 * @return array Test result
798 1773 */
799 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
800 1775 // Test with a simple API call
801 - $gemini_model = (new \ThinkRank\Core\Settings())->get('gemini_model', 'gemini-2.5-flash');
802 - $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);
803 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 +
804 1789 $body = [
805 1790 'contents' => [
806 1791 [
807 1792 'parts' => [
@@ -835,16 +1820,32 @@
835 1820
836 1821 if ($status_code === 200) {
837 1822 return [
838 1823 'success' => true,
839 - '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),
840 1828 ];
841 1829 } else {
842 1830 $error_data = json_decode($response_body, true);
843 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
844 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 +
845 1845 return [
846 1846 'success' => false,
1847 + 'model' => $gemini_model,
847 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
848 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
849 1850 ];
850 1851 }
@@ -889,11 +1890,11 @@
889 1890 $post_id = $request->get_param('post_id');
890 1891
891 1892 // Rate limiting: per user/IP per route
892 1893 $user_id = get_current_user_id();
893 - $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
894 1895 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
895 - $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);
896 1897 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
897 1898 if (is_wp_error($allowed)) {
898 1899 return new \WP_REST_Response([
899 1900 'success' => false,
@@ -913,9 +1914,8 @@
913 1914 'success' => true,
914 1915 'data' => $analysis,
915 1916 'message' => __('Content analyzed successfully', 'thinkrank'),
916 1917 ]);
917 -
918 1918 } catch (\Exception $e) {
919 1919 return new \WP_REST_Response([
920 1920 'success' => false,
921 1921 'message' => $e->getMessage(),
@@ -968,8 +1968,15 @@
968 1968 // Failed to register Site Identity endpoint
969 1969 }
970 1970
971 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 {
972 1979 $performance_endpoint = new Performance_Endpoint();
973 1980 $performance_endpoint->register_routes();
974 1981 } catch (\Exception $e) {
975 1982 // Failed to register Performance endpoint
@@ -1036,7 +2043,62 @@
1036 2043 $global_seo_endpoint->register_routes();
1037 2044 } catch (\Exception $e) {
1038 2045 // Failed to register Global SEO endpoint
1039 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 + }
1040 2103 }
1041 -
1042 2104 }