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 +258 -26 1.30.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
@@ -1367,9 +1418,24 @@
1367 1418
1368 1419 try {
1369 1420 $api_key = $request->get_param('api_key');
1370 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'));
1371 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 +
1372 1438 // If no API key provided in request, try to get from saved settings
1373 1439 if (empty($api_key)) {
1374 1440 $settings = \ThinkRank\Core\Settings::instance();
1375 1441 if ($provider === 'openai') {
@@ -1391,15 +1457,15 @@
1391 1457 }
1392 1458
1393 1459 // Test the connection with a simple API call
1394 1460 if ($provider === 'openai') {
1395 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1396 1462 } elseif ($provider === 'claude') {
1397 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1398 1464 } elseif ($provider === 'openrouter') {
1399 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1400 1466 } else {
1401 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1402 1468 }
1403 1469
1404 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1405 1471 } catch (\Exception $e) {
@@ -1412,12 +1478,21 @@
1412 1478
1413 1479 /**
1414 1480 * Test OpenAI API connection
1415 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 + *
1416 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1417 1488 * @return array Test result
1418 1489 */
1419 - 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 +
1420 1495 $url = 'https://api.openai.com/v1/models';
1421 1496
1422 1497 $response = wp_remote_get($url, [
1423 1498 'headers' => [
@@ -1439,11 +1514,28 @@
1439 1514
1440 1515 if ($status_code === 200) {
1441 1516 $data = json_decode($body, true);
1442 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 +
1443 1530 return [
1444 1531 'success' => true,
1445 - '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'),
1446 1538 'models_count' => count($data['data']),
1447 1539 ];
1448 1540 }
1449 1541 }
@@ -1461,11 +1553,16 @@
1461 1553 /**
1462 1554 * Test OpenRouter API connection
1463 1555 *
1464 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1465 1558 * @return array Test result
1466 1559 */
1467 - 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 +
1468 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1469 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1470 1567 return [
1471 1568 'success' => false,
@@ -1498,11 +1595,25 @@
1498 1595
1499 1596 if ($status_code === 200) {
1500 1597 $data = json_decode($body, true);
1501 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 +
1502 1608 return [
1503 1609 'success' => true,
1504 - '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'),
1505 1616 ];
1506 1617 }
1507 1618 }
1508 1619
@@ -1516,14 +1627,58 @@
1516 1627 ];
1517 1628 }
1518 1629
1519 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 + /**
1520 1674 * Test Claude API connection
1521 1675 *
1522 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1523 1678 * @return array Test result
1524 1679 */
1525 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1526 1681 // First validate the key format
1527 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1528 1683 return [
1529 1684 'success' => false,
@@ -1533,12 +1688,18 @@
1533 1688
1534 1689 // Test with a simple API call
1535 1690 $url = 'https://api.anthropic.com/v1/messages';
1536 1691
1537 - // Get the configured Claude model, with fallback to a current model.
1538 - // Self-heal retired/unavailable IDs saved by earlier versions.
1539 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1540 - $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 + }
1541 1702
1542 1703 $body = [
1543 1704 'model' => $claude_model,
1544 1705 'max_tokens' => 10,
@@ -1572,16 +1733,32 @@
1572 1733
1573 1734 if ($status_code === 200) {
1574 1735 return [
1575 1736 'success' => true,
1576 - '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),
1577 1741 ];
1578 1742 } else {
1579 1743 $error_data = json_decode($response_body, true);
1580 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1581 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 +
1582 1758 return [
1583 1759 'success' => false,
1760 + 'model' => $claude_model,
1584 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1585 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1586 1763 ];
1587 1764 }
@@ -1590,15 +1767,26 @@
1590 1767 /**
1591 1768 * Test Gemini API connection
1592 1769 *
1593 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1594 1772 * @return array Test result
1595 1773 */
1596 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1597 1775 // Test with a simple API call
1598 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1599 - $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);
1600 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 +
1601 1789 $body = [
1602 1790 'contents' => [
1603 1791 [
1604 1792 'parts' => [
@@ -1632,16 +1820,32 @@
1632 1820
1633 1821 if ($status_code === 200) {
1634 1822 return [
1635 1823 'success' => true,
1636 - '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),
1637 1828 ];
1638 1829 } else {
1639 1830 $error_data = json_decode($response_body, true);
1640 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1641 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 +
1642 1845 return [
1643 1846 'success' => false,
1847 + 'model' => $gemini_model,
1644 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1645 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1646 1850 ];
1647 1851 }
@@ -1771,15 +1975,8 @@
1771 1975 // Failed to register AI Insights endpoint
1772 1976 }
1773 1977
1774 1978 try {
1775 - $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1776 - $brand_visibility_endpoint->register_routes();
1777 - } catch (\Exception $e) {
1778 - // Failed to register Brand Visibility endpoint
1779 - }
1780 -
1781 - try {
1782 1979 $performance_endpoint = new Performance_Endpoint();
1783 1980 $performance_endpoint->register_routes();
1784 1981 } catch (\Exception $e) {
1785 1982 // Failed to register Performance endpoint
@@ -1848,8 +2045,15 @@
1848 2045 // Failed to register Global SEO endpoint
1849 2046 }
1850 2047
1851 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 {
1852 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1853 2057 $image_seo_endpoint->register_routes();
1854 2058 } catch (\Exception $e) {
1855 2059 // Failed to register Image SEO endpoint
@@ -1855,12 +2059,40 @@
1855 2059 // Failed to register Image SEO endpoint
1856 2060 }
1857 2061
1858 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 {
1859 2076 $import_controller = new Import_Controller();
1860 2077 $import_controller->register_routes();
1861 2078 } catch (\Exception $e) {
1862 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 + }
1863 2095 }
1864 2096
1865 2097 try {
1866 2098 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();