| @@ -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 | */ |
| @@ -94,8 +111,15 @@ | ||
| 94 | 111 | */ |
| 95 | 112 | private Focus_Keyword_Ajax $focus_keyword_ajax; |
| 96 | 113 | |
| 97 | 114 | /** |
| 115 | + * SEO Quick Edit modal AJAX handler instance | |
| 116 | + * | |
| 117 | + * @var Seo_Quick_Edit_Ajax | |
| 118 | + */ | |
| 119 | + private Seo_Quick_Edit_Ajax $seo_quick_edit_ajax; | |
| 120 | + | |
| 121 | + /** | |
| 98 | 122 | * Bulk action manager instance |
| 99 | 123 | * |
| 100 | 124 | * @var Bulk_Action_Manager |
| 101 | 125 | */ |
| @@ -127,10 +151,13 @@ | ||
| 127 | 151 | $this->metabox_manager = new Metabox_Manager($this->settings); |
| 128 | 152 | $this->elementor_metabox = new Elementor_Metabox($this->metabox_manager); |
| 129 | 153 | $this->oxygen_metabox = new Oxygen_Metabox($this->metabox_manager); |
| 130 | 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); | |
| 131 | 157 | $this->post_list_columns = new Post_List_Columns(); |
| 132 | 158 | $this->focus_keyword_ajax = new Focus_Keyword_Ajax(); |
| 159 | + $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax(); | |
| 133 | 160 | $this->bulk_action_manager = new Bulk_Action_Manager(); |
| 134 | 161 | $this->post_list_filters = new Post_List_Filters(); |
| 135 | 162 | } |
| 136 | 163 | |
| @@ -163,8 +190,16 @@ | ||
| 163 | 190 | // Initialize Divi Visual Builder integration (hooks gate on the VB |
| 164 | 191 | // request, so they no-op without Divi) |
| 165 | 192 | $this->divi_metabox->init(); |
| 166 | 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 | + | |
| 167 | 202 | // Initialize post list columns |
| 168 | 203 | $this->post_list_columns->init(); |
| 169 | 204 | |
| 170 | 205 | // Initialize focus keyword AJAX handler |
| @@ -169,8 +204,11 @@ | ||
| 169 | 204 | |
| 170 | 205 | // Initialize focus keyword AJAX handler |
| 171 | 206 | $this->focus_keyword_ajax->init(); |
| 172 | 207 | |
| 208 | + // Initialize SEO Quick Edit modal AJAX handler | |
| 209 | + $this->seo_quick_edit_ajax->init(); | |
| 210 | + | |
| 173 | 211 | // Initialize Bulk Action Manager |
| 174 | 212 | $this->bulk_action_manager->init(); |
| 175 | 213 | |
| 176 | 214 | // Initialize Post List Filters |
| @@ -249,13 +287,33 @@ | ||
| 249 | 287 | 'thinkrank-settings', |
| 250 | 288 | [$this, 'render_settings_page'] |
| 251 | 289 | ); |
| 252 | 290 | |
| 253 | - // Migration page — re-run SEO data imports from other plugins after | |
| 254 | - // setup. Hidden by default; shown only when the "Enable Migration Tools" | |
| 255 | - // advanced setting is on. Capability matches the import REST endpoints | |
| 256 | - // (`manage_options`) so the UI and API stay in agreement. | |
| 257 | - 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)) { | |
| 258 | 316 | $this->pages['migration'] = add_submenu_page( |
| 259 | 317 | 'thinkrank', |
| 260 | 318 | __('Migration', 'thinkrank'), |
| 261 | 319 | __('Migration', 'thinkrank'), |
| @@ -370,8 +428,12 @@ | ||
| 370 | 428 | 'capabilities' => $this->get_user_capabilities(), |
| 371 | 429 | 'settings' => $this->get_admin_settings(), |
| 372 | 430 | 'i18n' => $this->get_i18n_strings(), |
| 373 | 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), | |
| 374 | 436 | // Whether any AI provider API key is configured — used to gate |
| 375 | 437 | // "Generate with AI" buttons in the UI |
| 376 | 438 | 'aiConfigured' => $this->is_ai_configured(), |
| 377 | 439 | // Plugin version - directly available without API call |
| @@ -383,8 +445,13 @@ | ||
| 383 | 445 | 'faviconUrl' => get_site_icon_url(64) ?: '', |
| 384 | 446 | 'adminEmail' => get_option('admin_email'), |
| 385 | 447 | // Post types for Global SEO navigation |
| 386 | 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(), | |
| 387 | 454 | // Role Manager: capabilities the current user holds + the |
| 388 | 455 | // section → capability map, so the SPA can hide sections a role |
| 389 | 456 | // cannot access. Administrators receive every capability. |
| 390 | 457 | 'caps' => Capability_Manager::user_capabilities(), |
| @@ -391,13 +458,10 @@ | ||
| 391 | 458 | 'sectionCaps' => Capability_Manager::section_map(), |
| 392 | 459 | 'canManageRoles' => Capability_Manager::current_user_can(Capability_Manager::MANAGE_ROLES), |
| 393 | 460 | // Pro detection flag |
| 394 | 461 | 'isPro' => Plan_Config::is_pro(), |
| 395 | - // Data update frequency (Pro: daily, Free: every 3 days) | |
| 396 | - 'dataUpdateFrequency' => Plan_Config::is_pro() ? 'daily' : '3days', | |
| 397 | - // Per-feature capability maps. Mirrors PHP Plan_Config so JS | |
| 398 | - // never has to ask "is the user Pro?" — it asks "can the user X?". | |
| 399 | - 'emailReport' => Plan_Config::email_report(), | |
| 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. | |
| 400 | 464 | // MCP (Model Context Protocol) connection details for the MCP page. |
| 401 | 465 | 'mcp' => $this->get_mcp_globals(), |
| 402 | 466 | // Google OAuth: JS only ever gets a nonce-signed admin-post URL. |
| 403 | 467 | // The consent URL, client ID, and scopes are assembled by the proxy, |
| @@ -408,8 +472,14 @@ | ||
| 408 | 472 | // 'contract' (upgraded off the old token flow) or |
| 409 | 473 | // 'credentials' (stored tokens no longer decryptable). |
| 410 | 474 | 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''), |
| 411 | 475 | ], |
| 476 | + // Setup Wizard state — the dashboard Quick Access widget surfaces a | |
| 477 | + // "Complete Setup" shortcut while onboarding is unfinished. | |
| 478 | + 'setupWizard' => [ | |
| 479 | + 'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false), | |
| 480 | + 'url' => admin_url('admin.php?page=' . Setup_Wizard::PAGE_SLUG), | |
| 481 | + ], | |
| 412 | 482 | ]); |
| 413 | 483 | |
| 414 | 484 | } |
| 415 | 485 | |
| @@ -425,8 +495,13 @@ | ||
| 425 | 495 | } |
| 426 | 496 | |
| 427 | 497 | // Check for plugin updates |
| 428 | 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(); | |
| 429 | 504 | } |
| 430 | 505 | |
| 431 | 506 | /** |
| 432 | 507 | * Load admin page |
| @@ -523,13 +598,22 @@ | ||
| 523 | 598 | ]); |
| 524 | 599 | } |
| 525 | 600 | |
| 526 | 601 | /** |
| 527 | - * Render import/export page | |
| 602 | + * Render the Import / Export page (ThinkRank's own data, out and back). | |
| 528 | 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 | + * | |
| 529 | 609 | * @return void |
| 530 | 610 | */ |
| 531 | 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 | + | |
| 532 | 616 | $this->render_admin_page('import-export', [ |
| 533 | 617 | 'page_title' => __('Import / Export', 'thinkrank'), |
| 534 | 618 | ]); |
| 535 | 619 | } |
| @@ -534,19 +618,19 @@ | ||
| 534 | 618 | ]); |
| 535 | 619 | } |
| 536 | 620 | |
| 537 | 621 | /** |
| 538 | - * Render the Migration page (re-run SEO data imports). | |
| 622 | + * Render the Migration page (import SEO data from another plugin). | |
| 539 | 623 | * |
| 540 | - * Defense in depth: the submenu is only registered when the setting is on, | |
| 541 | - * 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. | |
| 542 | 625 | * |
| 543 | 626 | * @return void |
| 544 | 627 | */ |
| 545 | 628 | public function render_migration_page(): void { |
| 546 | - if (!Settings::instance()->get('enable_migration_tools', false)) { | |
| 629 | + if (!(bool) Settings::instance()->get('enable_migration_tools', false)) { | |
| 547 | 630 | wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank')); |
| 548 | 631 | } |
| 632 | + | |
| 549 | 633 | $this->render_admin_page('migration', [ |
| 550 | 634 | 'page_title' => __('Migration', 'thinkrank'), |
| 551 | 635 | ]); |
| 552 | 636 | } |
| @@ -713,8 +797,14 @@ | ||
| 713 | 797 | */ |
| 714 | 798 | public function dismiss_notice(): void { |
| 715 | 799 | check_ajax_referer('thinkrank_admin', 'nonce'); |
| 716 | 800 | |
| 801 | + // The nonce proves intent, not authorization — dismissing a site-wide | |
| 802 | + // notice deletes an option, so require a capability as well. | |
| 803 | + if (!current_user_can('edit_posts')) { | |
| 804 | + wp_die(-1, 403); | |
| 805 | + } | |
| 806 | + | |
| 717 | 807 | $notice_type = sanitize_key($_POST['notice_type'] ?? ''); |
| 718 | 808 | |
| 719 | 809 | if ($notice_type === 'welcome') { |
| 720 | 810 | delete_option('thinkrank_show_welcome'); |
| @@ -723,19 +813,14 @@ | ||
| 723 | 813 | wp_die(); |
| 724 | 814 | } |
| 725 | 815 | |
| 726 | 816 | /** |
| 727 | - * Check if API key is configured | |
| 817 | + * Check if the selected AI provider is configured | |
| 728 | 818 | * |
| 729 | - * @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 | |
| 730 | 820 | */ |
| 731 | 821 | private function has_api_key_configured(): bool { |
| 732 | - $settings = \ThinkRank\Core\Settings::instance(); | |
| 733 | - | |
| 734 | - return !empty($settings->get('openai_api_key')) | |
| 735 | - || !empty($settings->get('claude_api_key')) | |
| 736 | - || !empty($settings->get('gemini_api_key')) | |
| 737 | - || !empty($settings->get('openrouter_api_key')); | |
| 822 | + return \ThinkRank\Core\Settings::instance()->has_ai_provider_configured(); | |
| 738 | 823 | } |
| 739 | 824 | |
| 740 | 825 | /** |
| 741 | 826 | * Get menu icon |
| @@ -742,8 +827,9 @@ | ||
| 742 | 827 | * |
| 743 | 828 | * @return string Menu icon |
| 744 | 829 | */ |
| 745 | 830 | private function get_menu_icon(): string { |
| 831 | + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a data: URI for the menu icon must be base64. | |
| 746 | 832 | return 'data:image/svg+xml;base64,' . base64_encode( |
| 747 | 833 | '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 748 | 834 | <g clipPath="url(#thinkrank-clip)"> |
| 749 | 835 | <g filter="url(#thinkrank-shadow)"> |
| @@ -791,28 +877,23 @@ | ||
| 791 | 877 | */ |
| 792 | 878 | private function get_admin_settings(): array { |
| 793 | 879 | return [ |
| 794 | 880 | |
| 795 | - 'ai_provider' => $this->settings->get('ai_provider', 'openai'), | |
| 881 | + 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE), | |
| 796 | 882 | 'cache_duration' => $this->settings->get('cache_duration', 3600), |
| 797 | 883 | ]; |
| 798 | 884 | } |
| 799 | 885 | |
| 800 | 886 | /** |
| 801 | - * Whether any AI provider API key is configured | |
| 887 | + * Whether the selected AI provider is configured | |
| 802 | 888 | * |
| 803 | - * Mirrors the check used by ThinkRank\AI\Manager. | |
| 889 | + * Same answer as ThinkRank\AI\Manager — both read | |
| 890 | + * Settings::has_ai_provider_configured(). | |
| 804 | 891 | * |
| 805 | 892 | * @return bool |
| 806 | 893 | */ |
| 807 | 894 | private function is_ai_configured(): bool { |
| 808 | - foreach (['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'] as $key) { | |
| 809 | - if (!empty($this->settings->get($key, ''))) { | |
| 810 | - return true; | |
| 811 | - } | |
| 812 | - } | |
| 813 | - | |
| 814 | - return false; | |
| 895 | + return $this->settings->has_ai_provider_configured(); | |
| 815 | 896 | } |
| 816 | 897 | |
| 817 | 898 | /** |
| 818 | 899 | * Get internationalization strings |
| @@ -925,10 +1006,11 @@ | ||
| 925 | 1006 | 'thinkrank_page_thinkrank-ai-tools', |
| 926 | 1007 | 'thinkrank_page_thinkrank-settings', |
| 927 | 1008 | 'thinkrank_page_thinkrank-usages', |
| 928 | 1009 | 'thinkrank_page_thinkrank-license', |
| 1010 | + 'thinkrank_page_thinkrank-import-export', | |
| 929 | 1011 | 'thinkrank_page_thinkrank-migration' |
| 930 | - ] ) ) { | |
| 1012 | + ] , true) ) { | |
| 931 | 1013 | |
| 932 | 1014 | remove_all_actions( 'user_admin_notices' ); |
| 933 | 1015 | remove_all_actions( 'admin_notices' ); |
| 934 | 1016 | remove_all_actions( 'all_admin_notices' ); |