| @@ -16,12 +16,15 @@ | ||
| 16 | 16 | use ThinkRank\Core\Settings; |
| 17 | 17 | use ThinkRank\Core\Database; |
| 18 | 18 | use ThinkRank\Core\Plan_Config; |
| 19 | 19 | use ThinkRank\Core\Capability_Manager; |
| 20 | +use ThinkRank\Config\Local_Business_Types_Config; | |
| 20 | 21 | use ThinkRank\Admin\Metabox_Manager; |
| 21 | 22 | use ThinkRank\Admin\Elementor_Metabox; |
| 22 | 23 | use ThinkRank\Admin\Oxygen_Metabox; |
| 23 | 24 | use ThinkRank\Admin\Divi_Metabox; |
| 25 | +use ThinkRank\Admin\Bricks_Metabox; | |
| 26 | +use ThinkRank\Admin\Beaver_Metabox; | |
| 24 | 27 | use ThinkRank\Admin\Bulk_Action_Manager; |
| 25 | 28 | use ThinkRank\Admin\Post_List_Filters; |
| 26 | 29 | |
| 27 | 30 | // Prevent direct access |
| @@ -80,8 +83,22 @@ | ||
| 80 | 83 | */ |
| 81 | 84 | private Divi_Metabox $divi_metabox; |
| 82 | 85 | |
| 83 | 86 | /** |
| 87 | + * Bricks builder metabox integration instance | |
| 88 | + * | |
| 89 | + * @var Bricks_Metabox | |
| 90 | + */ | |
| 91 | + private Bricks_Metabox $bricks_metabox; | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * Beaver Builder metabox integration | |
| 95 | + * | |
| 96 | + * @var Beaver_Metabox | |
| 97 | + */ | |
| 98 | + private Beaver_Metabox $beaver_metabox; | |
| 99 | + | |
| 100 | + /** | |
| 84 | 101 | * Post list columns instance |
| 85 | 102 | * |
| 86 | 103 | * @var Post_List_Columns |
| 87 | 104 | */ |
| @@ -134,8 +151,10 @@ | ||
| 134 | 151 | $this->metabox_manager = new Metabox_Manager($this->settings); |
| 135 | 152 | $this->elementor_metabox = new Elementor_Metabox($this->metabox_manager); |
| 136 | 153 | $this->oxygen_metabox = new Oxygen_Metabox($this->metabox_manager); |
| 137 | 154 | $this->divi_metabox = new Divi_Metabox($this->metabox_manager); |
| 155 | + $this->bricks_metabox = new Bricks_Metabox($this->metabox_manager); | |
| 156 | + $this->beaver_metabox = new Beaver_Metabox($this->metabox_manager); | |
| 138 | 157 | $this->post_list_columns = new Post_List_Columns(); |
| 139 | 158 | $this->focus_keyword_ajax = new Focus_Keyword_Ajax(); |
| 140 | 159 | $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax(); |
| 141 | 160 | $this->bulk_action_manager = new Bulk_Action_Manager(); |
| @@ -171,8 +190,16 @@ | ||
| 171 | 190 | // Initialize Divi Visual Builder integration (hooks gate on the VB |
| 172 | 191 | // request, so they no-op without Divi) |
| 173 | 192 | $this->divi_metabox->init(); |
| 174 | 193 | |
| 194 | + // Initialize Bricks builder integration (hooks gate on Bricks' own | |
| 195 | + // builder detector, so they no-op without Bricks) | |
| 196 | + $this->bricks_metabox->init(); | |
| 197 | + | |
| 198 | + // Initialize Beaver Builder integration (hooks gate on Beaver Builder's | |
| 199 | + // own builder detector, so they no-op without Beaver Builder) | |
| 200 | + $this->beaver_metabox->init(); | |
| 201 | + | |
| 175 | 202 | // Initialize post list columns |
| 176 | 203 | $this->post_list_columns->init(); |
| 177 | 204 | |
| 178 | 205 | // Initialize focus keyword AJAX handler |
| @@ -260,13 +287,33 @@ | ||
| 260 | 287 | 'thinkrank-settings', |
| 261 | 288 | [$this, 'render_settings_page'] |
| 262 | 289 | ); |
| 263 | 290 | |
| 264 | - // Migration page — re-run SEO data imports from other plugins after | |
| 265 | - // setup. Hidden by default; shown only when the "Enable Migration Tools" | |
| 266 | - // advanced setting is on. Capability matches the import REST endpoints | |
| 267 | - // (`manage_options`) so the UI and API stay in agreement. | |
| 268 | - if (Settings::instance()->get('enable_migration_tools', false)) { | |
| 291 | + // Two separate screens, one per setting, so each menu item appears | |
| 292 | + // exactly when its own feature is on. They shared a page (and therefore | |
| 293 | + // a menu item) until #228: with one route, turning Import / Export off | |
| 294 | + // still left "Import / Export" in the sidebar whenever Migration Tools | |
| 295 | + // happened to be on, which is the opposite of what the switch promises. | |
| 296 | + // | |
| 297 | + // Capability matches the import/export REST endpoints (`manage_options`) | |
| 298 | + // so the UI and API stay in agreement. | |
| 299 | + | |
| 300 | + // ThinkRank's own data, out to a file and back. Off by default. | |
| 301 | + if ((bool) Settings::instance()->get('enable_import_export', false)) { | |
| 302 | + $this->pages['import-export'] = add_submenu_page( | |
| 303 | + 'thinkrank', | |
| 304 | + __('Import / Export', 'thinkrank'), | |
| 305 | + __('Import / Export', 'thinkrank'), | |
| 306 | + 'manage_options', | |
| 307 | + 'thinkrank-import-export', | |
| 308 | + [$this, 'render_import_export_page'] | |
| 309 | + ); | |
| 310 | + } | |
| 311 | + | |
| 312 | + // Importing FROM another SEO plugin. Off by default. Slug kept as | |
| 313 | + // thinkrank-migration so existing links, bookmarks and the docs keep | |
| 314 | + // resolving to the migration screen they always meant. | |
| 315 | + if ((bool) Settings::instance()->get('enable_migration_tools', false)) { | |
| 269 | 316 | $this->pages['migration'] = add_submenu_page( |
| 270 | 317 | 'thinkrank', |
| 271 | 318 | __('Migration', 'thinkrank'), |
| 272 | 319 | __('Migration', 'thinkrank'), |
| @@ -381,8 +428,12 @@ | ||
| 381 | 428 | 'capabilities' => $this->get_user_capabilities(), |
| 382 | 429 | 'settings' => $this->get_admin_settings(), |
| 383 | 430 | 'i18n' => $this->get_i18n_strings(), |
| 384 | 431 | 'isAdmin' => current_user_can('manage_options'), |
| 432 | + // One flag per half of the Import / Export page: the "import from | |
| 433 | + // another SEO plugin" section, and ThinkRank's own export/restore. | |
| 434 | + 'migrationToolsEnabled' => (bool) $this->settings->get('enable_migration_tools', false), | |
| 435 | + 'importExportEnabled' => (bool) $this->settings->get('enable_import_export', false), | |
| 385 | 436 | // Whether any AI provider API key is configured — used to gate |
| 386 | 437 | // "Generate with AI" buttons in the UI |
| 387 | 438 | 'aiConfigured' => $this->is_ai_configured(), |
| 388 | 439 | // Plugin version - directly available without API call |
| @@ -394,8 +445,13 @@ | ||
| 394 | 445 | 'faviconUrl' => get_site_icon_url(64) ?: '', |
| 395 | 446 | 'adminEmail' => get_option('admin_email'), |
| 396 | 447 | // Post types for Global SEO navigation |
| 397 | 448 | 'postTypes' => $this->get_public_post_types(), |
| 449 | + // schema.org LocalBusiness subtypes for the Local SEO control. | |
| 450 | + // Localized rather than mirrored in a JS config: the list runs to | |
| 451 | + // ~150 entries and is also the MCP ability's enum, so a second copy | |
| 452 | + // would be a second thing to keep in step (#623). | |
| 453 | + 'localBusinessTypes' => Local_Business_Types_Config::get_options(), | |
| 398 | 454 | // Role Manager: capabilities the current user holds + the |
| 399 | 455 | // section → capability map, so the SPA can hide sections a role |
| 400 | 456 | // cannot access. Administrators receive every capability. |
| 401 | 457 | 'caps' => Capability_Manager::user_capabilities(), |
| @@ -402,13 +458,10 @@ | ||
| 402 | 458 | 'sectionCaps' => Capability_Manager::section_map(), |
| 403 | 459 | 'canManageRoles' => Capability_Manager::current_user_can(Capability_Manager::MANAGE_ROLES), |
| 404 | 460 | // Pro detection flag |
| 405 | 461 | 'isPro' => Plan_Config::is_pro(), |
| 406 | - // Data update frequency (Pro: daily, Free: every 3 days) | |
| 407 | - 'dataUpdateFrequency' => Plan_Config::is_pro() ? 'daily' : '3days', | |
| 408 | - // NB: no per-feature capability maps here. Email Reporting reads | |
| 409 | - // its capabilities from GET /thinkrank/v1/email-report/config; | |
| 410 | - // localizing a second copy only invited the two to drift. | |
| 462 | + // NB: no per-feature capability maps here; each screen reads its | |
| 463 | + // own state over REST, so a localized copy cannot drift from it. | |
| 411 | 464 | // MCP (Model Context Protocol) connection details for the MCP page. |
| 412 | 465 | 'mcp' => $this->get_mcp_globals(), |
| 413 | 466 | // Google OAuth: JS only ever gets a nonce-signed admin-post URL. |
| 414 | 467 | // The consent URL, client ID, and scopes are assembled by the proxy, |
| @@ -442,8 +495,13 @@ | ||
| 442 | 495 | } |
| 443 | 496 | |
| 444 | 497 | // Check for plugin updates |
| 445 | 498 | $this->check_plugin_updates(); |
| 499 | + | |
| 500 | + // One-time: a Key Features value saved while commas were delimiters | |
| 501 | + // becomes one feature per line, so the next regeneration publishes the | |
| 502 | + // bullets the site already had rather than one merged line. | |
| 503 | + \ThinkRank\SEO\LLMs_Txt_Manager::maybe_migrate_legacy_key_features(); | |
| 446 | 504 | } |
| 447 | 505 | |
| 448 | 506 | /** |
| 449 | 507 | * Load admin page |
| @@ -540,13 +598,22 @@ | ||
| 540 | 598 | ]); |
| 541 | 599 | } |
| 542 | 600 | |
| 543 | 601 | /** |
| 544 | - * Render import/export page | |
| 602 | + * Render the Import / Export page (ThinkRank's own data, out and back). | |
| 545 | 603 | * |
| 604 | + * Defense in depth: the submenu is only registered while the setting is on, | |
| 605 | + * but re-check here so a direct hit on the page URL cannot bypass the gate. | |
| 606 | + * The export REST routes carry their own `manage_options` check, so nothing | |
| 607 | + * is protected by the page alone. | |
| 608 | + * | |
| 546 | 609 | * @return void |
| 547 | 610 | */ |
| 548 | 611 | public function render_import_export_page(): void { |
| 612 | + if (!(bool) Settings::instance()->get('enable_import_export', false)) { | |
| 613 | + wp_die(esc_html__('Import / Export is not enabled.', 'thinkrank')); | |
| 614 | + } | |
| 615 | + | |
| 549 | 616 | $this->render_admin_page('import-export', [ |
| 550 | 617 | 'page_title' => __('Import / Export', 'thinkrank'), |
| 551 | 618 | ]); |
| 552 | 619 | } |
| @@ -551,19 +618,19 @@ | ||
| 551 | 618 | ]); |
| 552 | 619 | } |
| 553 | 620 | |
| 554 | 621 | /** |
| 555 | - * Render the Migration page (re-run SEO data imports). | |
| 622 | + * Render the Migration page (import SEO data from another plugin). | |
| 556 | 623 | * |
| 557 | - * Defense in depth: the submenu is only registered when the setting is on, | |
| 558 | - * but re-check here so a direct hit on the page URL can't bypass the gate. | |
| 624 | + * Same defense in depth as above, against its own setting. | |
| 559 | 625 | * |
| 560 | 626 | * @return void |
| 561 | 627 | */ |
| 562 | 628 | public function render_migration_page(): void { |
| 563 | - if (!Settings::instance()->get('enable_migration_tools', false)) { | |
| 629 | + if (!(bool) Settings::instance()->get('enable_migration_tools', false)) { | |
| 564 | 630 | wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank')); |
| 565 | 631 | } |
| 632 | + | |
| 566 | 633 | $this->render_admin_page('migration', [ |
| 567 | 634 | 'page_title' => __('Migration', 'thinkrank'), |
| 568 | 635 | ]); |
| 569 | 636 | } |
| @@ -746,19 +813,14 @@ | ||
| 746 | 813 | wp_die(); |
| 747 | 814 | } |
| 748 | 815 | |
| 749 | 816 | /** |
| 750 | - * Check if API key is configured | |
| 817 | + * Check if the selected AI provider is configured | |
| 751 | 818 | * |
| 752 | - * @return bool True if at least one API key is configured | |
| 819 | + * @return bool True when the chosen provider has what it needs to run | |
| 753 | 820 | */ |
| 754 | 821 | private function has_api_key_configured(): bool { |
| 755 | - $settings = \ThinkRank\Core\Settings::instance(); | |
| 756 | - | |
| 757 | - return !empty($settings->get('openai_api_key')) | |
| 758 | - || !empty($settings->get('claude_api_key')) | |
| 759 | - || !empty($settings->get('gemini_api_key')) | |
| 760 | - || !empty($settings->get('openrouter_api_key')); | |
| 822 | + return \ThinkRank\Core\Settings::instance()->has_ai_provider_configured(); | |
| 761 | 823 | } |
| 762 | 824 | |
| 763 | 825 | /** |
| 764 | 826 | * Get menu icon |
| @@ -815,28 +877,23 @@ | ||
| 815 | 877 | */ |
| 816 | 878 | private function get_admin_settings(): array { |
| 817 | 879 | return [ |
| 818 | 880 | |
| 819 | - 'ai_provider' => $this->settings->get('ai_provider', 'openai'), | |
| 881 | + 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE), | |
| 820 | 882 | 'cache_duration' => $this->settings->get('cache_duration', 3600), |
| 821 | 883 | ]; |
| 822 | 884 | } |
| 823 | 885 | |
| 824 | 886 | /** |
| 825 | - * Whether any AI provider API key is configured | |
| 887 | + * Whether the selected AI provider is configured | |
| 826 | 888 | * |
| 827 | - * Mirrors the check used by ThinkRank\AI\Manager. | |
| 889 | + * Same answer as ThinkRank\AI\Manager — both read | |
| 890 | + * Settings::has_ai_provider_configured(). | |
| 828 | 891 | * |
| 829 | 892 | * @return bool |
| 830 | 893 | */ |
| 831 | 894 | private function is_ai_configured(): bool { |
| 832 | - foreach (['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'] as $key) { | |
| 833 | - if (!empty($this->settings->get($key, ''))) { | |
| 834 | - return true; | |
| 835 | - } | |
| 836 | - } | |
| 837 | - | |
| 838 | - return false; | |
| 895 | + return $this->settings->has_ai_provider_configured(); | |
| 839 | 896 | } |
| 840 | 897 | |
| 841 | 898 | /** |
| 842 | 899 | * Get internationalization strings |
| @@ -949,8 +1006,9 @@ | ||
| 949 | 1006 | 'thinkrank_page_thinkrank-ai-tools', |
| 950 | 1007 | 'thinkrank_page_thinkrank-settings', |
| 951 | 1008 | 'thinkrank_page_thinkrank-usages', |
| 952 | 1009 | 'thinkrank_page_thinkrank-license', |
| 1010 | + 'thinkrank_page_thinkrank-import-export', | |
| 953 | 1011 | 'thinkrank_page_thinkrank-migration' |
| 954 | 1012 | ] , true) ) { |
| 955 | 1013 | |
| 956 | 1014 | remove_all_actions( 'user_admin_notices' ); |