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 +294 -32 1.27.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,22 +806,23 @@
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 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
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 - 'claude_model' => $settings_instance->get('claude_model', 'claude-sonnet-5'),
814 + 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
766 815 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
767 - '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),
768 817 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
769 - 'openrouter_model' => $settings_instance->get('openrouter_model', 'openai/gpt-4o-mini'),
818 + 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
770 819 'max_tokens' => $settings_instance->get('max_tokens', 1000),
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,15 +1006,31 @@
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
959 - // Get existing metadata
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)
960 1023 $metadata = [
961 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
962 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
963 - '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),
964 1027 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
965 - '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,
966 1033 ];
967 1034
968 1035 return new \WP_REST_Response($metadata);
969 1036 }
@@ -1351,9 +1418,24 @@
1351 1418
1352 1419 try {
1353 1420 $api_key = $request->get_param('api_key');
1354 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'));
1355 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 +
1356 1438 // If no API key provided in request, try to get from saved settings
1357 1439 if (empty($api_key)) {
1358 1440 $settings = \ThinkRank\Core\Settings::instance();
1359 1441 if ($provider === 'openai') {
@@ -1375,15 +1457,15 @@
1375 1457 }
1376 1458
1377 1459 // Test the connection with a simple API call
1378 1460 if ($provider === 'openai') {
1379 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1380 1462 } elseif ($provider === 'claude') {
1381 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1382 1464 } elseif ($provider === 'openrouter') {
1383 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1384 1466 } else {
1385 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1386 1468 }
1387 1469
1388 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1389 1471 } catch (\Exception $e) {
@@ -1396,12 +1478,21 @@
1396 1478
1397 1479 /**
1398 1480 * Test OpenAI API connection
1399 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 + *
1400 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1401 1488 * @return array Test result
1402 1489 */
1403 - 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 +
1404 1495 $url = 'https://api.openai.com/v1/models';
1405 1496
1406 1497 $response = wp_remote_get($url, [
1407 1498 'headers' => [
@@ -1423,11 +1514,28 @@
1423 1514
1424 1515 if ($status_code === 200) {
1425 1516 $data = json_decode($body, true);
1426 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 +
1427 1530 return [
1428 1531 'success' => true,
1429 - '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'),
1430 1538 'models_count' => count($data['data']),
1431 1539 ];
1432 1540 }
1433 1541 }
@@ -1445,11 +1553,16 @@
1445 1553 /**
1446 1554 * Test OpenRouter API connection
1447 1555 *
1448 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1449 1558 * @return array Test result
1450 1559 */
1451 - 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 +
1452 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1453 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1454 1567 return [
1455 1568 'success' => false,
@@ -1482,11 +1595,25 @@
1482 1595
1483 1596 if ($status_code === 200) {
1484 1597 $data = json_decode($body, true);
1485 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 +
1486 1608 return [
1487 1609 'success' => true,
1488 - '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'),
1489 1616 ];
1490 1617 }
1491 1618 }
1492 1619
@@ -1500,14 +1627,58 @@
1500 1627 ];
1501 1628 }
1502 1629
1503 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 + /**
1504 1674 * Test Claude API connection
1505 1675 *
1506 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1507 1678 * @return array Test result
1508 1679 */
1509 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1510 1681 // First validate the key format
1511 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1512 1683 return [
1513 1684 'success' => false,
@@ -1517,12 +1688,18 @@
1517 1688
1518 1689 // Test with a simple API call
1519 1690 $url = 'https://api.anthropic.com/v1/messages';
1520 1691
1521 - // Get the configured Claude model, with fallback to a current model.
1522 - // Self-heal retired/unavailable IDs saved by earlier versions.
1523 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', 'claude-sonnet-5');
1524 - $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 + }
1525 1702
1526 1703 $body = [
1527 1704 'model' => $claude_model,
1528 1705 'max_tokens' => 10,
@@ -1556,16 +1733,32 @@
1556 1733
1557 1734 if ($status_code === 200) {
1558 1735 return [
1559 1736 'success' => true,
1560 - '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),
1561 1741 ];
1562 1742 } else {
1563 1743 $error_data = json_decode($response_body, true);
1564 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1565 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 +
1566 1758 return [
1567 1759 'success' => false,
1760 + 'model' => $claude_model,
1568 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1569 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1570 1763 ];
1571 1764 }
@@ -1574,15 +1767,26 @@
1574 1767 /**
1575 1768 * Test Gemini API connection
1576 1769 *
1577 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1578 1772 * @return array Test result
1579 1773 */
1580 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1581 1775 // Test with a simple API call
1582 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', 'gemini-2.5-flash');
1583 - $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);
1584 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 +
1585 1789 $body = [
1586 1790 'contents' => [
1587 1791 [
1588 1792 'parts' => [
@@ -1616,16 +1820,32 @@
1616 1820
1617 1821 if ($status_code === 200) {
1618 1822 return [
1619 1823 'success' => true,
1620 - '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),
1621 1828 ];
1622 1829 } else {
1623 1830 $error_data = json_decode($response_body, true);
1624 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1625 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 +
1626 1845 return [
1627 1846 'success' => false,
1847 + 'model' => $gemini_model,
1628 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1629 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1630 1850 ];
1631 1851 }
@@ -1748,8 +1968,15 @@
1748 1968 // Failed to register Site Identity endpoint
1749 1969 }
1750 1970
1751 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 {
1752 1979 $performance_endpoint = new Performance_Endpoint();
1753 1980 $performance_endpoint->register_routes();
1754 1981 } catch (\Exception $e) {
1755 1982 // Failed to register Performance endpoint
@@ -1818,8 +2045,15 @@
1818 2045 // Failed to register Global SEO endpoint
1819 2046 }
1820 2047
1821 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 {
1822 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1823 2057 $image_seo_endpoint->register_routes();
1824 2058 } catch (\Exception $e) {
1825 2059 // Failed to register Image SEO endpoint
@@ -1825,12 +2059,40 @@
1825 2059 // Failed to register Image SEO endpoint
1826 2060 }
1827 2061
1828 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 {
1829 2076 $import_controller = new Import_Controller();
1830 2077 $import_controller->register_routes();
1831 2078 } catch (\Exception $e) {
1832 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 + }
1833 2095 }
1834 2096
1835 2097 try {
1836 2098 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();