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 +250 -26 2.1.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
@@ -88,8 +90,13 @@
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']);
91 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 +
92 99 // Make declared schema constraints mean something. Applied once over
93 100 // the whole namespace rather than at 70-odd call sites, because that is
94 101 // exactly how the enum on /setup-wizard/migrated-plugins and the one on
95 102 // /seo-analytics/dashboard came to be inert while the route next door
@@ -146,9 +153,15 @@
146 153 'permission_callback' => [$this, 'check_settings_permissions'],
147 154 'args' => [
148 155 'ai_provider' => [
149 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(),
150 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',
151 164 ],
152 165 'openai_api_key' => [
153 166 'type' => 'string',
154 167 'sanitize_callback' => 'sanitize_text_field',
@@ -180,8 +193,27 @@
180 193 'openrouter_model' => [
181 194 'type' => 'string',
182 195 'sanitize_callback' => 'sanitize_text_field',
183 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 + ],
184 216 'keep_data_on_uninstall' => [
185 217 'type' => 'boolean',
186 218 'sanitize_callback' => 'rest_sanitize_boolean',
187 219 ],
@@ -192,8 +224,12 @@
192 224 'enable_migration_tools' => [
193 225 'type' => 'boolean',
194 226 'sanitize_callback' => 'rest_sanitize_boolean',
195 227 ],
228 + 'enable_import_export' => [
229 + 'type' => 'boolean',
230 + 'sanitize_callback' => 'rest_sanitize_boolean',
231 + ],
196 232 ],
197 233 ]);
198 234
199 235
@@ -446,8 +482,13 @@
446 482 'required' => false,
447 483 'default' => 'openai',
448 484 'sanitize_callback' => 'sanitize_key',
449 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
450 491 ],
451 492 ]);
452 493
453 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -765,9 +806,9 @@
765 806 // Use Settings class for consistent access (handles decryption automatically)
766 807 $settings_instance = \ThinkRank\Core\Settings::instance();
767 808
768 809 $settings = [
769 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
810 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
770 811 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
771 812 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
772 813 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
773 814 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
@@ -779,8 +820,9 @@
779 820 'temperature' => $settings_instance->get('temperature', 0.7),
780 821 'cache_duration' => $settings_instance->get('cache_duration', 3600),
781 822 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
782 823 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
824 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
783 825 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
784 826 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
785 827 ];
786 828
@@ -855,8 +897,9 @@
855 897 'cache_duration' => 'cache_duration',
856 898 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
857 899 'enable_mcp' => 'enable_mcp',
858 900 'enable_migration_tools' => 'enable_migration_tools',
901 + 'enable_import_export' => 'enable_import_export',
859 902 ];
860 903
861 904 // Processing settings save request
862 905
@@ -1375,9 +1418,24 @@
1375 1418
1376 1419 try {
1377 1420 $api_key = $request->get_param('api_key');
1378 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'));
1379 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 +
1380 1438 // If no API key provided in request, try to get from saved settings
1381 1439 if (empty($api_key)) {
1382 1440 $settings = \ThinkRank\Core\Settings::instance();
1383 1441 if ($provider === 'openai') {
@@ -1399,15 +1457,15 @@
1399 1457 }
1400 1458
1401 1459 // Test the connection with a simple API call
1402 1460 if ($provider === 'openai') {
1403 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1404 1462 } elseif ($provider === 'claude') {
1405 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1406 1464 } elseif ($provider === 'openrouter') {
1407 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1408 1466 } else {
1409 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1410 1468 }
1411 1469
1412 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1413 1471 } catch (\Exception $e) {
@@ -1420,12 +1478,21 @@
1420 1478
1421 1479 /**
1422 1480 * Test OpenAI API connection
1423 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 + *
1424 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1425 1488 * @return array Test result
1426 1489 */
1427 - 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 +
1428 1495 $url = 'https://api.openai.com/v1/models';
1429 1496
1430 1497 $response = wp_remote_get($url, [
1431 1498 'headers' => [
@@ -1447,11 +1514,28 @@
1447 1514
1448 1515 if ($status_code === 200) {
1449 1516 $data = json_decode($body, true);
1450 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 +
1451 1530 return [
1452 1531 'success' => true,
1453 - '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'),
1454 1538 'models_count' => count($data['data']),
1455 1539 ];
1456 1540 }
1457 1541 }
@@ -1469,11 +1553,16 @@
1469 1553 /**
1470 1554 * Test OpenRouter API connection
1471 1555 *
1472 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1473 1558 * @return array Test result
1474 1559 */
1475 - 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 +
1476 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1477 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1478 1567 return [
1479 1568 'success' => false,
@@ -1506,11 +1595,25 @@
1506 1595
1507 1596 if ($status_code === 200) {
1508 1597 $data = json_decode($body, true);
1509 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 +
1510 1608 return [
1511 1609 'success' => true,
1512 - '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'),
1513 1616 ];
1514 1617 }
1515 1618 }
1516 1619
@@ -1524,14 +1627,58 @@
1524 1627 ];
1525 1628 }
1526 1629
1527 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 + /**
1528 1674 * Test Claude API connection
1529 1675 *
1530 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1531 1678 * @return array Test result
1532 1679 */
1533 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1534 1681 // First validate the key format
1535 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1536 1683 return [
1537 1684 'success' => false,
@@ -1541,12 +1688,18 @@
1541 1688
1542 1689 // Test with a simple API call
1543 1690 $url = 'https://api.anthropic.com/v1/messages';
1544 1691
1545 - // Get the configured Claude model, with fallback to a current model.
1546 - // Self-heal retired/unavailable IDs saved by earlier versions.
1547 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1548 - $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 + }
1549 1702
1550 1703 $body = [
1551 1704 'model' => $claude_model,
1552 1705 'max_tokens' => 10,
@@ -1580,16 +1733,32 @@
1580 1733
1581 1734 if ($status_code === 200) {
1582 1735 return [
1583 1736 'success' => true,
1584 - '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),
1585 1741 ];
1586 1742 } else {
1587 1743 $error_data = json_decode($response_body, true);
1588 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1589 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 +
1590 1758 return [
1591 1759 'success' => false,
1760 + 'model' => $claude_model,
1592 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1593 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1594 1763 ];
1595 1764 }
@@ -1598,15 +1767,26 @@
1598 1767 /**
1599 1768 * Test Gemini API connection
1600 1769 *
1601 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1602 1772 * @return array Test result
1603 1773 */
1604 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1605 1775 // Test with a simple API call
1606 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1607 - $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);
1608 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 +
1609 1789 $body = [
1610 1790 'contents' => [
1611 1791 [
1612 1792 'parts' => [
@@ -1640,16 +1820,32 @@
1640 1820
1641 1821 if ($status_code === 200) {
1642 1822 return [
1643 1823 'success' => true,
1644 - '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),
1645 1828 ];
1646 1829 } else {
1647 1830 $error_data = json_decode($response_body, true);
1648 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1649 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 +
1650 1845 return [
1651 1846 'success' => false,
1847 + 'model' => $gemini_model,
1652 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1653 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1654 1850 ];
1655 1851 }
@@ -1779,15 +1975,8 @@
1779 1975 // Failed to register AI Insights endpoint
1780 1976 }
1781 1977
1782 1978 try {
1783 - $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1784 - $brand_visibility_endpoint->register_routes();
1785 - } catch (\Exception $e) {
1786 - // Failed to register Brand Visibility endpoint
1787 - }
1788 -
1789 - try {
1790 1979 $performance_endpoint = new Performance_Endpoint();
1791 1980 $performance_endpoint->register_routes();
1792 1981 } catch (\Exception $e) {
1793 1982 // Failed to register Performance endpoint
@@ -1856,8 +2045,15 @@
1856 2045 // Failed to register Global SEO endpoint
1857 2046 }
1858 2047
1859 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 {
1860 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1861 2057 $image_seo_endpoint->register_routes();
1862 2058 } catch (\Exception $e) {
1863 2059 // Failed to register Image SEO endpoint
@@ -1863,12 +2059,40 @@
1863 2059 // Failed to register Image SEO endpoint
1864 2060 }
1865 2061
1866 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 {
1867 2076 $import_controller = new Import_Controller();
1868 2077 $import_controller->register_routes();
1869 2078 } catch (\Exception $e) {
1870 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 + }
1871 2095 }
1872 2096
1873 2097 try {
1874 2098 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();