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 +273 -30 1.28.02.7.0 View file →
@@ -27,8 +27,9 @@
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 30 use ThinkRank\API\Image_SEO_Endpoint;
31 +use ThinkRank\API\External_Links_Endpoint;
31 32 use ThinkRank\API\Instant_Indexing_Endpoint;
32 33 use ThinkRank\API\Pillar_Content_Endpoint;
33 34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
34 35 use ThinkRank\API\Author_Archives_Endpoint;
@@ -33,8 +34,9 @@
33 34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
34 35 use ThinkRank\API\Author_Archives_Endpoint;
35 36 use ThinkRank\API\Email_Report_Endpoint;
36 37 use ThinkRank\Admin\Importers\Import_Controller;
38 +use ThinkRank\Admin\Importers\Export_Controller;
37 39 use ThinkRank\API\Setup_Wizard_Endpoint;
38 40
39 41
40 42 // Prevent direct access
@@ -87,8 +89,21 @@
87 89 */
88 90 public function init(): void {
89 91 add_action('rest_api_init', [$this, 'register_routes']);
90 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);
91 106 }
92 107
93 108 /**
94 109 * Register REST API routes
@@ -138,9 +153,15 @@
138 153 'permission_callback' => [$this, 'check_settings_permissions'],
139 154 'args' => [
140 155 'ai_provider' => [
141 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(),
142 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',
143 164 ],
144 165 'openai_api_key' => [
145 166 'type' => 'string',
146 167 'sanitize_callback' => 'sanitize_text_field',
@@ -172,8 +193,27 @@
172 193 'openrouter_model' => [
173 194 'type' => 'string',
174 195 'sanitize_callback' => 'sanitize_text_field',
175 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 + ],
176 216 'keep_data_on_uninstall' => [
177 217 'type' => 'boolean',
178 218 'sanitize_callback' => 'rest_sanitize_boolean',
179 219 ],
@@ -184,8 +224,12 @@
184 224 'enable_migration_tools' => [
185 225 'type' => 'boolean',
186 226 'sanitize_callback' => 'rest_sanitize_boolean',
187 227 ],
228 + 'enable_import_export' => [
229 + 'type' => 'boolean',
230 + 'sanitize_callback' => 'rest_sanitize_boolean',
231 + ],
188 232 ],
189 233 ]);
190 234
191 235
@@ -438,8 +482,13 @@
438 482 'required' => false,
439 483 'default' => 'openai',
440 484 'sanitize_callback' => 'sanitize_key',
441 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
442 491 ],
443 492 ]);
444 493
445 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -757,9 +806,9 @@
757 806 // Use Settings class for consistent access (handles decryption automatically)
758 807 $settings_instance = \ThinkRank\Core\Settings::instance();
759 808
760 809 $settings = [
761 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
810 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
762 811 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
763 812 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
764 813 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
765 814 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
@@ -771,8 +820,9 @@
771 820 'temperature' => $settings_instance->get('temperature', 0.7),
772 821 'cache_duration' => $settings_instance->get('cache_duration', 3600),
773 822 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
774 823 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
824 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
775 825 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
776 826 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
777 827 ];
778 828
@@ -847,8 +897,9 @@
847 897 'cache_duration' => 'cache_duration',
848 898 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
849 899 'enable_mcp' => 'enable_mcp',
850 900 'enable_migration_tools' => 'enable_migration_tools',
901 + 'enable_import_export' => 'enable_import_export',
851 902 ];
852 903
853 904 // Processing settings save request
854 905
@@ -856,9 +907,9 @@
856 907 if (isset($params[$param_key])) {
857 908 $value = $params[$param_key];
858 909
859 910 // Handle API keys specially - check for masked values
860 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'])) {
911 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'], true)) {
861 912 // Don't update if the value carries the mask sentinel (the
862 913 // preview now keeps real head/tail chars around it, so match
863 914 // anywhere rather than only at the start). Empty still clears.
864 915 if (strpos($value, '••••••••') !== false) {
@@ -866,11 +917,11 @@
866 917 }
867 918 }
868 919
869 920 // Use Settings class for all operations (handles encryption automatically)
870 - if (!$settings->set($setting_key, $value)) {
871 - // Settings save failed, continue with other settings
872 - }
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);
873 924 }
874 925 }
875 926
876 927 // MCP is a single master switch (see #244): enabling it auto-mints a
@@ -955,8 +1006,15 @@
955 1006 if (!current_user_can('edit_post', $post_id)) {
956 1007 return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
957 1008 }
958 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 +
959 1017 // Get existing metadata. These must read the same canonical meta keys
960 1018 // the rest of the plugin writes/reads (frontend, metabox, scoring),
961 1019 // otherwise the response is always empty:
962 1020 // title/description → _thinkrank_seo_title / _thinkrank_meta_description
@@ -967,8 +1025,12 @@
967 1025 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
968 1026 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
969 1027 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
970 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,
971 1033 ];
972 1034
973 1035 return new \WP_REST_Response($metadata);
974 1036 }
@@ -1356,9 +1418,24 @@
1356 1418
1357 1419 try {
1358 1420 $api_key = $request->get_param('api_key');
1359 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'));
1360 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 +
1361 1438 // If no API key provided in request, try to get from saved settings
1362 1439 if (empty($api_key)) {
1363 1440 $settings = \ThinkRank\Core\Settings::instance();
1364 1441 if ($provider === 'openai') {
@@ -1380,15 +1457,15 @@
1380 1457 }
1381 1458
1382 1459 // Test the connection with a simple API call
1383 1460 if ($provider === 'openai') {
1384 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1385 1462 } elseif ($provider === 'claude') {
1386 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1387 1464 } elseif ($provider === 'openrouter') {
1388 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1389 1466 } else {
1390 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1391 1468 }
1392 1469
1393 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1394 1471 } catch (\Exception $e) {
@@ -1401,12 +1478,21 @@
1401 1478
1402 1479 /**
1403 1480 * Test OpenAI API connection
1404 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 + *
1405 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1406 1488 * @return array Test result
1407 1489 */
1408 - 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 +
1409 1495 $url = 'https://api.openai.com/v1/models';
1410 1496
1411 1497 $response = wp_remote_get($url, [
1412 1498 'headers' => [
@@ -1428,11 +1514,28 @@
1428 1514
1429 1515 if ($status_code === 200) {
1430 1516 $data = json_decode($body, true);
1431 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 +
1432 1530 return [
1433 1531 'success' => true,
1434 - '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'),
1435 1538 'models_count' => count($data['data']),
1436 1539 ];
1437 1540 }
1438 1541 }
@@ -1450,11 +1553,16 @@
1450 1553 /**
1451 1554 * Test OpenRouter API connection
1452 1555 *
1453 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1454 1558 * @return array Test result
1455 1559 */
1456 - private function test_openrouter_connection(string $api_key): array {
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 +
1457 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1458 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1459 1567 return [
1460 1568 'success' => false,
@@ -1487,11 +1595,25 @@
1487 1595
1488 1596 if ($status_code === 200) {
1489 1597 $data = json_decode($body, true);
1490 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 +
1491 1608 return [
1492 1609 'success' => true,
1493 - 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
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'),
1494 1616 ];
1495 1617 }
1496 1618 }
1497 1619
@@ -1505,14 +1627,58 @@
1505 1627 ];
1506 1628 }
1507 1629
1508 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 + /**
1509 1674 * Test Claude API connection
1510 1675 *
1511 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1512 1678 * @return array Test result
1513 1679 */
1514 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1515 1681 // First validate the key format
1516 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1517 1683 return [
1518 1684 'success' => false,
@@ -1522,12 +1688,18 @@
1522 1688
1523 1689 // Test with a simple API call
1524 1690 $url = 'https://api.anthropic.com/v1/messages';
1525 1691
1526 - // Get the configured Claude model, with fallback to a current model.
1527 - // Self-heal retired/unavailable IDs saved by earlier versions.
1528 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1529 - $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
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 + }
1530 1702
1531 1703 $body = [
1532 1704 'model' => $claude_model,
1533 1705 'max_tokens' => 10,
@@ -1561,16 +1733,32 @@
1561 1733
1562 1734 if ($status_code === 200) {
1563 1735 return [
1564 1736 'success' => true,
1565 - '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),
1566 1741 ];
1567 1742 } else {
1568 1743 $error_data = json_decode($response_body, true);
1569 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1570 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 +
1571 1758 return [
1572 1759 'success' => false,
1760 + 'model' => $claude_model,
1573 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1574 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1575 1763 ];
1576 1764 }
@@ -1579,15 +1767,26 @@
1579 1767 /**
1580 1768 * Test Gemini API connection
1581 1769 *
1582 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1583 1772 * @return array Test result
1584 1773 */
1585 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1586 1775 // Test with a simple API call
1587 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1588 - $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);
1589 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 +
1590 1789 $body = [
1591 1790 'contents' => [
1592 1791 [
1593 1792 'parts' => [
@@ -1621,16 +1820,32 @@
1621 1820
1622 1821 if ($status_code === 200) {
1623 1822 return [
1624 1823 'success' => true,
1625 - '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),
1626 1828 ];
1627 1829 } else {
1628 1830 $error_data = json_decode($response_body, true);
1629 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1630 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 +
1631 1845 return [
1632 1846 'success' => false,
1847 + 'model' => $gemini_model,
1633 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1634 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1635 1850 ];
1636 1851 }
@@ -1760,15 +1975,8 @@
1760 1975 // Failed to register AI Insights endpoint
1761 1976 }
1762 1977
1763 1978 try {
1764 - $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1765 - $brand_visibility_endpoint->register_routes();
1766 - } catch (\Exception $e) {
1767 - // Failed to register Brand Visibility endpoint
1768 - }
1769 -
1770 - try {
1771 1979 $performance_endpoint = new Performance_Endpoint();
1772 1980 $performance_endpoint->register_routes();
1773 1981 } catch (\Exception $e) {
1774 1982 // Failed to register Performance endpoint
@@ -1837,8 +2045,15 @@
1837 2045 // Failed to register Global SEO endpoint
1838 2046 }
1839 2047
1840 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 {
1841 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1842 2057 $image_seo_endpoint->register_routes();
1843 2058 } catch (\Exception $e) {
1844 2059 // Failed to register Image SEO endpoint
@@ -1844,12 +2059,40 @@
1844 2059 // Failed to register Image SEO endpoint
1845 2060 }
1846 2061
1847 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 {
1848 2076 $import_controller = new Import_Controller();
1849 2077 $import_controller->register_routes();
1850 2078 } catch (\Exception $e) {
1851 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 + }
1852 2095 }
1853 2096
1854 2097 try {
1855 2098 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();