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/admin/class-manager.php +368 -188 1.10.02.7.0 View file →
@@ -14,9 +14,16 @@
14 14 namespace ThinkRank\Admin;
15 15
16 16 use ThinkRank\Core\Settings;
17 17 use ThinkRank\Core\Database;
18 +use ThinkRank\Core\Plan_Config;
19 +use ThinkRank\Core\Capability_Manager;
18 20 use ThinkRank\Admin\Metabox_Manager;
21 +use ThinkRank\Admin\Elementor_Metabox;
22 +use ThinkRank\Admin\Oxygen_Metabox;
23 +use ThinkRank\Admin\Divi_Metabox;
24 +use ThinkRank\Admin\Bricks_Metabox;
25 +use ThinkRank\Admin\Beaver_Metabox;
19 26 use ThinkRank\Admin\Bulk_Action_Manager;
20 27 use ThinkRank\Admin\Post_List_Filters;
21 28
22 29 // Prevent direct access
@@ -54,8 +61,43 @@
54 61 */
55 62 private Metabox_Manager $metabox_manager;
56 63
57 64 /**
65 + * Elementor editor metabox integration instance
66 + *
67 + * @var Elementor_Metabox
68 + */
69 + private Elementor_Metabox $elementor_metabox;
70 +
71 + /**
72 + * Oxygen / Breakdance editor metabox integration instance
73 + *
74 + * @var Oxygen_Metabox
75 + */
76 + private Oxygen_Metabox $oxygen_metabox;
77 +
78 + /**
79 + * Divi Visual Builder metabox integration instance
80 + *
81 + * @var Divi_Metabox
82 + */
83 + private Divi_Metabox $divi_metabox;
84 +
85 + /**
86 + * Bricks builder metabox integration instance
87 + *
88 + * @var Bricks_Metabox
89 + */
90 + private Bricks_Metabox $bricks_metabox;
91 +
92 + /**
93 + * Beaver Builder metabox integration
94 + *
95 + * @var Beaver_Metabox
96 + */
97 + private Beaver_Metabox $beaver_metabox;
98 +
99 + /**
58 100 * Post list columns instance
59 101 *
60 102 * @var Post_List_Columns
61 103 */
@@ -68,8 +110,15 @@
68 110 */
69 111 private Focus_Keyword_Ajax $focus_keyword_ajax;
70 112
71 113 /**
114 + * SEO Quick Edit modal AJAX handler instance
115 + *
116 + * @var Seo_Quick_Edit_Ajax
117 + */
118 + private Seo_Quick_Edit_Ajax $seo_quick_edit_ajax;
119 +
120 + /**
72 121 * Bulk action manager instance
73 122 *
74 123 * @var Bulk_Action_Manager
75 124 */
@@ -95,13 +144,19 @@
95 144 * @param Settings $settings Settings instance
96 145 * @param Database $database Database instance
97 146 */
98 147 public function __construct(?Settings $settings = null, ?Database $database = null) {
99 - $this->settings = $settings ?? new Settings();
148 + $this->settings = $settings ?? Settings::instance();
100 149 $this->database = $database ?? new Database();
101 150 $this->metabox_manager = new Metabox_Manager($this->settings);
151 + $this->elementor_metabox = new Elementor_Metabox($this->metabox_manager);
152 + $this->oxygen_metabox = new Oxygen_Metabox($this->metabox_manager);
153 + $this->divi_metabox = new Divi_Metabox($this->metabox_manager);
154 + $this->bricks_metabox = new Bricks_Metabox($this->metabox_manager);
155 + $this->beaver_metabox = new Beaver_Metabox($this->metabox_manager);
102 156 $this->post_list_columns = new Post_List_Columns();
103 157 $this->focus_keyword_ajax = new Focus_Keyword_Ajax();
158 + $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax();
104 159 $this->bulk_action_manager = new Bulk_Action_Manager();
105 160 $this->post_list_filters = new Post_List_Filters();
106 161 }
107 162
@@ -114,9 +169,9 @@
114 169 add_action('admin_menu', [$this, 'add_admin_menu']);
115 170 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
116 171 add_action('admin_init', [$this, 'handle_admin_init']);
117 172 add_action('admin_notices', [$this, 'show_admin_notices']);
118 - add_action('tr_admin_notices', [$this, 'show_admin_notices']);
173 + add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
119 174 add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
120 175
121 176 // AJAX handlers
122 177 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
@@ -123,8 +178,27 @@
123 178
124 179 // Initialize metabox manager
125 180 $this->metabox_manager->init();
126 181
182 + // Initialize Elementor editor integration (hooks no-op without Elementor)
183 + $this->elementor_metabox->init();
184 +
185 + // Initialize Oxygen / Breakdance editor integration (hooks gate on the
186 + // builder request, so they no-op without Oxygen)
187 + $this->oxygen_metabox->init();
188 +
189 + // Initialize Divi Visual Builder integration (hooks gate on the VB
190 + // request, so they no-op without Divi)
191 + $this->divi_metabox->init();
192 +
193 + // Initialize Bricks builder integration (hooks gate on Bricks' own
194 + // builder detector, so they no-op without Bricks)
195 + $this->bricks_metabox->init();
196 +
197 + // Initialize Beaver Builder integration (hooks gate on Beaver Builder's
198 + // own builder detector, so they no-op without Beaver Builder)
199 + $this->beaver_metabox->init();
200 +
127 201 // Initialize post list columns
128 202 $this->post_list_columns->init();
129 203
130 204 // Initialize focus keyword AJAX handler
@@ -129,13 +203,22 @@
129 203
130 204 // Initialize focus keyword AJAX handler
131 205 $this->focus_keyword_ajax->init();
132 206
207 + // Initialize SEO Quick Edit modal AJAX handler
208 + $this->seo_quick_edit_ajax->init();
209 +
133 210 // Initialize Bulk Action Manager
134 211 $this->bulk_action_manager->init();
135 212
136 213 // Initialize Post List Filters
137 214 $this->post_list_filters->init();
215 +
216 + // Initialize Setup Wizard (onboarding) controller
217 + (new Setup_Wizard())->init();
218 +
219 + // Initialize the wp-admin Dashboard widget (ThinkRank Website Insights)
220 + (new Dashboard_Widget())->init();
138 221 }
139 222
140 223 /**
141 224 * Add admin menu pages
@@ -146,9 +229,9 @@
146 229 // Main menu page
147 230 $this->pages['dashboard'] = add_menu_page(
148 231 __('ThinkRank', 'thinkrank'),
149 232 __('ThinkRank', 'thinkrank'),
150 - 'manage_options',
233 + Capability_Manager::ACCESS,
151 234 'thinkrank',
152 235 [$this, 'render_dashboard_page'],
153 236 $this->get_menu_icon(),
154 237 30
@@ -158,9 +241,9 @@
158 241 $this->pages['dashboard_sub'] = add_submenu_page(
159 242 'thinkrank',
160 243 __('Dashboard', 'thinkrank'),
161 244 __('Dashboard', 'thinkrank'),
162 - 'manage_options',
245 + Capability_Manager::ACCESS,
163 246 'thinkrank',
164 247 [$this, 'render_dashboard_page']
165 248 );
166 249
@@ -168,43 +251,78 @@
168 251 $this->pages['essential_seo'] = add_submenu_page(
169 252 'thinkrank',
170 253 __('Essential SEO', 'thinkrank'),
171 254 __('Essential SEO', 'thinkrank'),
172 - 'manage_options',
255 + Capability_Manager::ACCESS,
173 256 'thinkrank-essential-seo',
174 257 [$this, 'render_essential_seo_page']
175 258 );
176 259
177 - // AI Tools page
260 + // AI Tools page — gated by the AI Tools section capability.
178 261 $this->pages['ai_tools'] = add_submenu_page(
179 262 'thinkrank',
180 263 __('AI Tools', 'thinkrank'),
181 264 __('AI Tools', 'thinkrank'),
182 - 'edit_posts',
265 + 'thinkrank_content_tools',
183 266 'thinkrank-ai-tools',
184 267 [$this, 'render_ai_tools_page']
185 268 );
186 269
187 - // Settings page
270 + // Usages page — gated by the Analytics section capability.
271 + $this->pages['analytics'] = add_submenu_page(
272 + 'thinkrank',
273 + __('Usages', 'thinkrank'),
274 + __('Usages', 'thinkrank'),
275 + 'thinkrank_analytics',
276 + 'thinkrank-usages',
277 + [$this, 'render_analytics_page']
278 + );
279 +
280 + // Settings page — gated by the Settings & API Keys section capability.
188 281 $this->pages['settings'] = add_submenu_page(
189 282 'thinkrank',
190 283 __('Settings', 'thinkrank'),
191 284 __('Settings', 'thinkrank'),
192 - 'manage_options',
285 + 'thinkrank_settings',
193 286 'thinkrank-settings',
194 287 [$this, 'render_settings_page']
195 288 );
196 289
197 - // Usage Analytics page
198 - $this->pages['analytics'] = add_submenu_page(
199 - 'thinkrank',
200 - __('Usage Analytics', 'thinkrank'),
201 - __('Usage Analytics', 'thinkrank'),
202 - 'edit_posts',
203 - 'thinkrank-analytics',
204 - [$this, 'render_analytics_page']
205 - );
290 + // Two separate screens, one per setting, so each menu item appears
291 + // exactly when its own feature is on. They shared a page (and therefore
292 + // a menu item) until #228: with one route, turning Import / Export off
293 + // still left "Import / Export" in the sidebar whenever Migration Tools
294 + // happened to be on, which is the opposite of what the switch promises.
295 + //
296 + // Capability matches the import/export REST endpoints (`manage_options`)
297 + // so the UI and API stay in agreement.
206 298
299 + // ThinkRank's own data, out to a file and back. Off by default.
300 + if ((bool) Settings::instance()->get('enable_import_export', false)) {
301 + $this->pages['import-export'] = add_submenu_page(
302 + 'thinkrank',
303 + __('Import / Export', 'thinkrank'),
304 + __('Import / Export', 'thinkrank'),
305 + 'manage_options',
306 + 'thinkrank-import-export',
307 + [$this, 'render_import_export_page']
308 + );
309 + }
310 +
311 + // Importing FROM another SEO plugin. Off by default. Slug kept as
312 + // thinkrank-migration so existing links, bookmarks and the docs keep
313 + // resolving to the migration screen they always meant.
314 + if ((bool) Settings::instance()->get('enable_migration_tools', false)) {
315 + $this->pages['migration'] = add_submenu_page(
316 + 'thinkrank',
317 + __('Migration', 'thinkrank'),
318 + __('Migration', 'thinkrank'),
319 + 'manage_options',
320 + 'thinkrank-migration',
321 + [$this, 'render_migration_page']
322 + );
323 + }
324 +
207 325 // Hook for page-specific initialization
208 326 foreach ($this->pages as $page_hook) {
209 327 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
210 328 }
@@ -233,14 +351,13 @@
233 351 'dependencies' => [],
234 352 'version' => THINKRANK_VERSION,
235 353 ];
236 354
237 - // Enqueue Chart.js bundle only on pages that render charts (Analytics and Essential SEO Performance)
355 + // Enqueue the Chart.js bundle on every ThinkRank page: the admin app
356 + // is a SPA, so chart pages (Usages, Essential SEO Performance) are
357 + // reachable from any other ThinkRank page without a reload.
238 358 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
239 - $should_enqueue_charts = in_array($hook_suffix, [
240 - $this->pages['analytics'] ?? '',
241 - $this->pages['essential_seo'] ?? '',
242 - ], true);
359 + $should_enqueue_charts = true;
243 360
244 361 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
245 362 $charts_asset_data = include $charts_asset_file;
246 363 wp_enqueue_script(
@@ -278,52 +395,87 @@
278 395
279 396 // Enqueue WordPress media library for MediaPicker component
280 397 wp_enqueue_media();
281 398
399 + // Preload the REST responses every ThinkRank admin page requests on
400 + // mount (Site Kit pattern: rest_preload_api_request piped into an
401 + // apiFetch preloading middleware) so first paint needs zero
402 + // round-trips for them. Only cheap, local settings endpoints belong
403 + // here — never Google-backed report data.
404 + $preload_paths = apply_filters('thinkrank_apifetch_preload_paths', [
405 + '/thinkrank/v1/site-identity/settings',
406 + '/thinkrank/v1/site-identity/title/templates',
407 + '/thinkrank/v1/site-identity/breadcrumbs/types',
408 + ]);
409 + $preload_data = array_reduce($preload_paths, 'rest_preload_api_request', []);
410 + wp_add_inline_script(
411 + 'thinkrank-admin',
412 + sprintf('window.thinkrankApiPreload = %s;', wp_json_encode((object) $preload_data)),
413 + 'before'
414 + );
415 +
416 + // Site info saved in ThinkRank Site Identity takes precedence over
417 + // the WordPress defaults so previews reflect what the user saved.
418 + $site_identity_settings = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
419 +
282 420 // Localize script with data
283 421 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
284 422 'apiUrl' => rest_url('thinkrank/v1/'),
285 - 'nonce' => wp_create_nonce('wp_rest'),
286 423 'restNonce' => wp_create_nonce('wp_rest'),
287 424 'adminNonce' => wp_create_nonce('thinkrank_admin'),
288 425 'currentUser' => wp_get_current_user()->ID,
426 + 'displayName' => wp_get_current_user()->display_name,
289 427 'capabilities' => $this->get_user_capabilities(),
290 428 'settings' => $this->get_admin_settings(),
291 429 'i18n' => $this->get_i18n_strings(),
292 430 'isAdmin' => current_user_can('manage_options'),
431 + // One flag per half of the Import / Export page: the "import from
432 + // another SEO plugin" section, and ThinkRank's own export/restore.
433 + 'migrationToolsEnabled' => (bool) $this->settings->get('enable_migration_tools', false),
434 + 'importExportEnabled' => (bool) $this->settings->get('enable_import_export', false),
435 + // Whether any AI provider API key is configured — used to gate
436 + // "Generate with AI" buttons in the UI
437 + 'aiConfigured' => $this->is_ai_configured(),
293 438 // Plugin version - directly available without API call
294 439 'version' => THINKRANK_VERSION,
295 440 // Site information for default values
296 - 'siteName' => get_bloginfo('name'),
297 - 'siteDescription' => get_bloginfo('description'),
441 + 'siteName' => !empty($site_identity_settings['site_name']) ? $site_identity_settings['site_name'] : get_bloginfo('name'),
442 + 'siteDescription' => !empty($site_identity_settings['site_description']) ? $site_identity_settings['site_description'] : get_bloginfo('description'),
298 443 'siteUrl' => home_url(),
444 + 'faviconUrl' => get_site_icon_url(64) ?: '',
299 445 'adminEmail' => get_option('admin_email'),
300 446 // Post types for Global SEO navigation
301 447 'postTypes' => $this->get_public_post_types(),
448 + // Role Manager: capabilities the current user holds + the
449 + // section → capability map, so the SPA can hide sections a role
450 + // cannot access. Administrators receive every capability.
451 + 'caps' => Capability_Manager::user_capabilities(),
452 + 'sectionCaps' => Capability_Manager::section_map(),
453 + 'canManageRoles' => Capability_Manager::current_user_can(Capability_Manager::MANAGE_ROLES),
302 454 // Pro detection flag
303 - 'isPro' => defined('THINKRANK_PRO_VERSION'),
304 - // Data update frequency (Pro: daily, Free: every 3 days)
305 - 'dataUpdateFrequency' => defined('THINKRANK_PRO_VERSION') ? 'daily' : '3days',
455 + 'isPro' => Plan_Config::is_pro(),
456 + // NB: no per-feature capability maps here; each screen reads its
457 + // own state over REST, so a localized copy cannot drift from it.
458 + // MCP (Model Context Protocol) connection details for the MCP page.
459 + 'mcp' => $this->get_mcp_globals(),
460 + // Google OAuth: JS only ever gets a nonce-signed admin-post URL.
461 + // The consent URL, client ID, and scopes are assembled by the proxy,
462 + // so no Google app credentials reach the browser or the bundle.
463 + 'googleOAuth' => [
464 + 'connectUrl' => \ThinkRank\Integrations\Google_OAuth_Proxy::get_connect_url(),
465 + // '' when fine, otherwise why re-authorization is needed:
466 + // 'contract' (upgraded off the old token flow) or
467 + // 'credentials' (stored tokens no longer decryptable).
468 + 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
469 + ],
470 + // Setup Wizard state — the dashboard Quick Access widget surfaces a
471 + // "Complete Setup" shortcut while onboarding is unfinished.
472 + 'setupWizard' => [
473 + 'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false),
474 + 'url' => admin_url('admin.php?page=' . Setup_Wizard::PAGE_SLUG),
475 + ],
306 476 ]);
307 477
308 - // Add welcome notice dismissal script
309 - if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
310 - wp_add_inline_script('thinkrank-admin', '
311 - jQuery(document).ready(function($) {
312 - $(".thinkrank-dismiss-welcome").on("click", function(e) {
313 - e.preventDefault();
314 -
315 - $.post(ajaxurl, {
316 - action: "thinkrank_dismiss_notice",
317 - notice_type: "welcome",
318 - nonce: thinkrankAdmin.adminNonce
319 - }, function(response) {
320 - $(".thinkrank-welcome-notice").fadeOut();
321 - });
322 - });
323 - });
324 - ');
325 - }
326 478 }
327 479
328 480 /**
329 481 * Handle admin initialization
@@ -345,11 +497,8 @@
345 497 *
346 498 * @return void
347 499 */
348 500 public function load_admin_page(): void {
349 - // Add help tabs
350 - $this->add_help_tabs();
351 -
352 501 // Add screen options
353 502 $this->add_screen_options();
354 503 }
355 504
@@ -400,10 +549,33 @@
400 549 'description' => __('Configure your AI SEO settings', 'thinkrank'),
401 550 ]);
402 551 }
403 552
553 + /**
554 + * Build the MCP connection globals passed to the admin app.
555 + *
556 + * The MCP page fetches live connection state from the
557 + * /thinkrank/v1/mcp/connection route; these globals only carry what the
558 + * page needs before that request resolves (endpoint URLs and whether the
559 + * bundled Abilities API — the tool catalog — is available).
560 + *
561 + * @return array<string, mixed>
562 + */
563 + private function get_mcp_globals(): array {
564 + // The tool catalog is the abilities registry; wp_register_ability
565 + // comes from the bundled Abilities API under dependencies/. When it's
566 + // missing (bundle not built), the MCP server has no tools to serve.
567 + $abilities_api_available = function_exists('wp_register_ability');
404 568
569 + return [
570 + 'abilities_api_available' => $abilities_api_available,
571 + 'mcp_endpoint' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint(),
572 + 'mcp_endpoint_rest' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint_fallback(),
573 + ];
574 + }
405 575
576 +
577 +
406 578 /**
407 579 * Render usage analytics page
408 580 *
409 581 * @return void
@@ -415,8 +587,45 @@
415 587 ]);
416 588 }
417 589
418 590 /**
591 + * Render the Import / Export page (ThinkRank's own data, out and back).
592 + *
593 + * Defense in depth: the submenu is only registered while the setting is on,
594 + * but re-check here so a direct hit on the page URL cannot bypass the gate.
595 + * The export REST routes carry their own `manage_options` check, so nothing
596 + * is protected by the page alone.
597 + *
598 + * @return void
599 + */
600 + public function render_import_export_page(): void {
601 + if (!(bool) Settings::instance()->get('enable_import_export', false)) {
602 + wp_die(esc_html__('Import / Export is not enabled.', 'thinkrank'));
603 + }
604 +
605 + $this->render_admin_page('import-export', [
606 + 'page_title' => __('Import / Export', 'thinkrank'),
607 + ]);
608 + }
609 +
610 + /**
611 + * Render the Migration page (import SEO data from another plugin).
612 + *
613 + * Same defense in depth as above, against its own setting.
614 + *
615 + * @return void
616 + */
617 + public function render_migration_page(): void {
618 + if (!(bool) Settings::instance()->get('enable_migration_tools', false)) {
619 + wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank'));
620 + }
621 +
622 + $this->render_admin_page('migration', [
623 + 'page_title' => __('Migration', 'thinkrank'),
624 + ]);
625 + }
626 +
627 + /**
419 628 * Render admin page template
420 629 *
421 630 * @param string $page Page identifier
422 631 * @param array $data Page data
@@ -424,14 +633,9 @@
424 633 */
425 634 private function render_admin_page(string $page, array $data): void {
426 635 ?>
427 636 <div class="wrap">
428 - <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page">
429 - <div class="thinkrank-loading">
430 - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoIGQ9Ik0xMC4zNzY4IDQ4LjU0QzExLjMyODEgNDguNTQgMTIuMDk5MyA0Ny43Njg4IDEyLjA5OTMgNDYuODE3NUMxMi4wOTkzIDQ1Ljg2NjIgMTEuMzI4MSA0NS4wOTUgMTAuMzc2OCA0NS4wOTVDOS40MjU0OSA0NS4wOTUgOC42NTQzIDQ1Ljg2NjIgOC42NTQzIDQ2LjgxNzVDOC42NTQzIDQ3Ljc2ODggOS40MjU0OSA0OC41NCAxMC4zNzY4IDQ4LjU0WiIgZmlsbD0iIzAwMDMyOSIgZmlsbC1vcGFjaXR5PSIwLjA1Ii8+CiAgICA8cGF0aCBkPSJNMy45MTE3MiAzNy4zNjVDNS4wNjQ2MSAzNy4zNjUgNS45OTkyMiAzNi40MzA0IDUuOTk5MjIgMzUuMjc3NUM1Ljk5OTIyIDM0LjEyNDYgNS4wNjQ2MSAzMy4xOSAzLjkxMTcyIDMzLjE5QzIuNzU4ODIgMzMuMTkgMS44MjQyMiAzNC4xMjQ2IDEuODI0MjIgMzUuMjc3NUMxLjgyNDIyIDM2LjQzMDQgMi43NTg4MiAzNy4zNjUgMy45MTE3MiAzNy4zNjVaIiBmaWxsPSIjMDAwMzI5IiBmaWxsLW9wYWNpdHk9IjAuMSIvPgogICAgPHBhdGggZD0iTTMuOTExNzIgMjQuMzU1QzUuMjM3MiAyNC4zNTUgNi4zMTE3MiAyMy4yODA1IDYuMzExNzIgMjEuOTU1QzYuMzExNzIgMjAuNjI5NSA1LjIzNzIgMTkuNTU1IDMuOTExNzIgMTkuNTU1QzIuNTg2MjQgMTkuNTU1IDEuNTExNzIgMjAuNjI5NSAxLjUxMTcyIDIxLjk1NUMxLjUxMTcyIDIzLjI4MDUgMi41ODYyNCAyNC4zNTUgMy45MTE3MiAyNC4zNTVaIiBmaWxsPSIjMDAwMzI5IiBmaWxsLW9wYWNpdHk9IjAuMTUiLz4KICAgIDxwYXRoIGQ9Ik0xMC41NzE5IDEzLjEzQzEyLjA2OTkgMTMuMTMgMTMuMjg0NCAxMS45MTU2IDEzLjI4NDQgMTAuNDE3NUMxMy4yODQ0IDguOTE5NDQgMTIuMDY5OSA3LjcwNTAyIDEwLjU3MTkgNy43MDUwMkM5LjA3MzggNy43MDUwMiA3Ljg1OTM4IDguOTE5NDQgNy44NTkzOCAxMC40MTc1QzcuODU5MzggMTEuOTE1NiA5LjA3MzggMTMuMTMgMTAuNTcxOSAxMy4xM1oiIGZpbGw9IiMwMDAzMjkiIGZpbGwtb3BhY2l0eT0iMC4yIi8+CiAgICA8cGF0aCBkPSJNMjIgN0MyMy42NTY5IDcgMjUgNS42NTY4NSAyNSA0QzI1IDIuMzQzMTUgMjMuNjU2OSAxIDIyIDFDMjAuMzQzMSAxIDE5IDIuMzQzMTUgMTkgNEMxOSA1LjY1Njg1IDIwLjM0MzEgNyAyMiA3WiIgZmlsbD0iIzAwMDMyOSIgZmlsbC1vcGFjaXR5PSIwLjMiLz4KICAgIDxwYXRoIGQ9Ik0zNS4xMzY3IDYuNzA3NTJDMzYuOTMxNiA2LjcwNzUyIDM4LjM4NjcgNS4yNTI0NCAzOC4zODY3IDMuNDU3NTJDMzguMzg2NyAxLjY2MjU5IDM2LjkzMTYgMC4yMDc1MiAzNS4xMzY3IDAuMjA3NTJDMzMuMzQxOCAwLjIwNzUyIDMxLjg4NjcgMS42NjI1OSAzMS44ODY3IDMuNDU3NTJDMzEuODg2NyA1LjI1MjQ0IDMzLjM0MTggNi43MDc1MiAzNS4xMzY3IDYuNzA3NTJaIiBmaWxsPSIjMDAwMzI5IiBmaWxsLW9wYWNpdHk9IjAuNCIvPgogICAgPHBhdGggZD0iTTQ2LjUgMTRDNDguNDMzIDE0IDUwIDEyLjQzMyA1MCAxMC41QzUwIDguNTY3IDQ4LjQzMyA3IDQ2LjUgN0M0NC41NjcgNyA0MyA4LjU2NyA0MyAxMC41QzQzIDEyLjQzMyA0NC41NjcgMTQgNDYuNSAxNFoiIGZpbGw9IiMwMDAzMjkiIGZpbGwtb3BhY2l0eT0iMC41Ii8+CiAgICA8cGF0aCBkPSJNNTMgMjZDNTUuMjA5MSAyNiA1NyAyNC4yMDkxIDU3IDIyQzU3IDE5Ljc5MDkgNTUuMjA5MSAxOCA1MyAxOEM1MC43OTA5IDE4IDQ5IDE5Ljc5MDkgNDkgMjJDNDkgMjQuMjA5MSA1MC43OTA5IDI2IDUzIDI2WiIgZmlsbD0iIzAwMDMyOSIgZmlsbC1vcGFjaXR5PSIwLjYiLz4KICAgIDxwYXRoIGQ9Ik01My41IDQwQzU1Ljk4NTMgNDAgNTggMzcuOTg1MyA1OCAzNS41QzU4IDMzLjAxNDcgNTUuOTg1MyAzMSA1My41IDMxQzUxLjAxNDcgMzEgNDkgMzMuMDE0NyA0OSAzNS41QzQ5IDM3Ljk4NTMgNTEuMDE0NyA0MCA1My41IDQwWiIgZmlsbD0iIzAwMDMyOSIgZmlsbC1vcGFjaXR5PSIwLjciLz4KICAgIDxwYXRoIGQ9Ik00NyA1MkM0OS43NjE0IDUyIDUyIDQ5Ljc2MTQgNTIgNDdDNTIgNDQuMjM4NiA0OS43NjE0IDQyIDQ3IDQyQzQ0LjIzODYgNDIgNDIgNDQuMjM4NiA0MiA0N0M0MiA0OS43NjE0IDQ0LjIzODYgNTIgNDcgNTJaIiBmaWxsPSIjMDAwMzI5IiBmaWxsLW9wYWNpdHk9IjAuOCIvPgogICAgPHBhdGggZD0iTTM1LjUgNTlDMzguNTM3NiA1OSA0MSA1Ni41Mzc2IDQxIDUzLjVDNDEgNTAuNDYyNCAzOC41Mzc2IDQ4IDM1LjUgNDhDMzIuNDYyNCA0OCAzMCA1MC40NjI0IDMwIDUzLjVDMzAgNTYuNTM3NiAzMi40NjI0IDU5IDM1LjUgNTlaIiBmaWxsPSIjMDAwMzI5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgogICAgPHBhdGggZD0iTTIyIDU5QzI1LjMxMzcgNTkgMjggNTYuMzEzNyAyOCA1M0MyOCA0OS42ODYzIDI1LjMxMzcgNDcgMjIgNDdDMTguNjg2MyA0NyAxNiA0OS42ODYzIDE2IDUzQzE2IDU2LjMxMzcgMTguNjg2MyA1OSAyMiA1OVoiIGZpbGw9IiMwMDAzMjkiLz4KPC9zdmc+Cg==" alt="" aria-hidden="true" class="components-spinner thinkrank-svg-spinner thinkrank-animate-spin" style="width: 60px; height: 60px; display: inline-block; vertical-align: middle;">
431 - <p><?php esc_html_e('Loading ThinkRank...', 'thinkrank'); ?></p>
432 - </div>
433 - </div>
637 + <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
434 638 </div>
435 639 <?php
436 640 }
437 641
@@ -464,9 +668,8 @@
464 668 public function render_seo_meta_box(\WP_Post $post): void {
465 669 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
466 670
467 671 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
468 - echo '<div class="thinkrank-loading"><div class="spinner is-active"></div></div>';
469 672 echo '</div>';
470 673 }
471 674
472 675 /**
@@ -491,9 +694,19 @@
491 694 return;
492 695 }
493 696
494 697 // Save meta data (handled by AJAX in React components)
495 - do_action('thinkrank_save_post_meta', $post_id, $_POST);
698 + // Pass only sanitized ThinkRank-related fields to action hook
699 + $sanitized_data = [];
700 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above
701 + foreach ($_POST as $key => $value) {
702 + if (strpos($key, 'thinkrank_') === 0 || strpos($key, '_thinkrank_') === 0) {
703 + $sanitized_data[$key] = is_array($value)
704 + ? array_map('sanitize_text_field', wp_unslash($value))
705 + : sanitize_text_field(wp_unslash($value));
706 + }
707 + }
708 + do_action('thinkrank_save_post_meta', $post_id, $sanitized_data);
496 709 }
497 710
498 711 /**
499 712 * Show admin notices
@@ -509,22 +722,62 @@
509 722 *
510 723 * @return void
511 724 */
512 725 public function show_welcome_notice(): void {
726 + wp_enqueue_style(
727 + 'thinkrank-admin-notices',
728 + THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
729 + [],
730 + THINKRANK_VERSION
731 + );
513 732 ?>
514 - <div class="notice notice-success is-dismissible thinkrank-welcome-notice">
515 - <h3><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></h3>
516 - <p><?php esc_html_e('Thank you for installing ThinkRank. Get started by configuring your AI settings.', 'thinkrank'); ?></p>
517 - <p>
518 - <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
519 - <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
520 - </a>
521 - <a href="#" class="button thinkrank-dismiss-welcome">
522 - <?php esc_html_e('Dismiss', 'thinkrank'); ?>
523 - </a>
524 - </p>
733 + <div class="notice notice-success is-dismissible thinkrank-notice thinkrank-welcome-notice">
734 + <div class="thinkrank-notice__inner">
735 + <div class="thinkrank-notice__body">
736 + <p class="thinkrank-notice__title"><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></p>
737 + <p class="thinkrank-notice__text"><?php esc_html_e('Thanks for installing ThinkRank. Add an AI provider key to unlock automatic titles, descriptions, and SEO scoring.', 'thinkrank'); ?></p>
738 + <p class="thinkrank-notice__actions">
739 + <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
740 + <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
741 + </a>
742 + <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-welcome" data-nonce="<?php echo esc_attr(wp_create_nonce('thinkrank_admin')); ?>">
743 + <?php esc_html_e('Dismiss', 'thinkrank'); ?>
744 + </a>
745 + </p>
746 + </div>
747 + </div>
525 748 </div>
526 749 <?php
750 + // The notice renders on every admin screen, so the dismiss handler must
751 + // ship with it — the thinkrank-admin bundle only loads on ThinkRank pages.
752 + // Persist the dismissal for both our "Dismiss" link and core's × button.
753 + wp_print_inline_script_tag(
754 + '( function () {
755 + document.addEventListener( "click", function ( event ) {
756 + var notice = event.target.closest( ".thinkrank-welcome-notice" );
757 + if ( ! notice ) {
758 + return;
759 + }
760 + var link = event.target.closest( ".thinkrank-dismiss-welcome" );
761 + if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
762 + return;
763 + }
764 + if ( link ) {
765 + event.preventDefault();
766 + notice.style.display = "none";
767 + }
768 + window.fetch( window.ajaxurl, {
769 + method: "POST",
770 + credentials: "same-origin",
771 + body: new URLSearchParams( {
772 + action: "thinkrank_dismiss_notice",
773 + notice_type: "welcome",
774 + nonce: notice.querySelector( ".thinkrank-dismiss-welcome" ).dataset.nonce,
775 + } ),
776 + } );
777 + } );
778 + } )();'
779 + );
527 780 }
528 781
529 782 /**
530 783 * Dismiss notice via AJAX
@@ -533,8 +786,14 @@
533 786 */
534 787 public function dismiss_notice(): void {
535 788 check_ajax_referer('thinkrank_admin', 'nonce');
536 789
790 + // The nonce proves intent, not authorization — dismissing a site-wide
791 + // notice deletes an option, so require a capability as well.
792 + if (!current_user_can('edit_posts')) {
793 + wp_die(-1, 403);
794 + }
795 +
537 796 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
538 797
539 798 if ($notice_type === 'welcome') {
540 799 delete_option('thinkrank_show_welcome');
@@ -548,13 +807,14 @@
548 807 *
549 808 * @return bool True if at least one API key is configured
550 809 */
551 810 private function has_api_key_configured(): bool {
552 - $settings = new \ThinkRank\Core\Settings();
553 - $openai_key = $settings->get('openai_api_key');
554 - $claude_key = $settings->get('claude_api_key');
811 + $settings = \ThinkRank\Core\Settings::instance();
555 812
556 - return !empty($openai_key) || !empty($claude_key);
813 + return !empty($settings->get('openai_api_key'))
814 + || !empty($settings->get('claude_api_key'))
815 + || !empty($settings->get('gemini_api_key'))
816 + || !empty($settings->get('openrouter_api_key'));
557 817 }
558 818
559 819 /**
560 820 * Get menu icon
@@ -561,8 +821,9 @@
561 821 *
562 822 * @return string Menu icon
563 823 */
564 824 private function get_menu_icon(): string {
825 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a data: URI for the menu icon must be base64.
565 826 return 'data:image/svg+xml;base64,' . base64_encode(
566 827 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
567 828 <g clipPath="url(#thinkrank-clip)">
568 829 <g filter="url(#thinkrank-shadow)">
@@ -610,14 +871,31 @@
610 871 */
611 872 private function get_admin_settings(): array {
612 873 return [
613 874
614 - 'ai_provider' => $this->settings->get('ai_provider', 'openai'),
875 + 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
615 876 'cache_duration' => $this->settings->get('cache_duration', 3600),
616 877 ];
617 878 }
618 879
619 880 /**
881 + * Whether any AI provider API key is configured
882 + *
883 + * Mirrors the check used by ThinkRank\AI\Manager.
884 + *
885 + * @return bool
886 + */
887 + private function is_ai_configured(): bool {
888 + foreach (['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'] as $key) {
889 + if (!empty($this->settings->get($key, ''))) {
890 + return true;
891 + }
892 + }
893 +
894 + return false;
895 + }
896 +
897 + /**
620 898 * Get internationalization strings
621 899 *
622 900 * @return array I18n strings
623 901 */
@@ -645,11 +923,11 @@
645 923
646 924 $post_types_data = [];
647 925
648 926 foreach ($post_types as $post_type) {
649 - // Skip certain post types that shouldn't have SEO settings
650 - $excluded_types = ['elementor_library', 'oceanwp_library', 'ae_global_templates'];
651 - if (in_array($post_type->name, $excluded_types)) {
927 + // Shared eligibility policy (viewable + deny list) so the UI list and
928 + // the REST/ability write paths agree on which types are SEO targets.
929 + if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
652 930 continue;
653 931 }
654 932
655 933 $post_types_data[] = [
@@ -683,9 +961,9 @@
683 961
684 962 $screen->set_help_sidebar(
685 963 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
686 964 '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
687 - '<p><a href="https://thinkrank.ai/support" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
965 + '<p><a href="https://wpdeveloper.com/support/new-ticket/" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
688 966 );
689 967 }
690 968
691 969 /**
@@ -704,8 +982,14 @@
704 982 */
705 983 private function check_plugin_updates(): void {
706 984 $current_version = get_option('thinkrank_version');
707 985
986 + if (false === $current_version) {
987 + // Fresh install or missing option — record version without firing the update hook.
988 + update_option('thinkrank_version', THINKRANK_VERSION);
989 + return;
990 + }
991 +
708 992 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
709 993 // Handle plugin update
710 994 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
711 995 update_option('thinkrank_version', THINKRANK_VERSION);
@@ -711,115 +995,9 @@
711 995 update_option('thinkrank_version', THINKRANK_VERSION);
712 996 }
713 997 }
714 998
715 - /**
716 - * Get admin styles
717 - *
718 - * @return string CSS styles
719 - */
720 - private function get_admin_styles(): string {
721 - return '
722 - .thinkrank-loading {
723 - display: flex;
724 - flex-direction: column;
725 - align-items: center;
726 - justify-content: center;
727 - padding: 40px 20px;
728 - text-align: center;
729 - }
730 999
731 - .thinkrank-loading .spinner {
732 - margin-bottom: 16px;
733 - }
734 -
735 - .thinkrank-loading p {
736 - margin: 0;
737 - color: #666;
738 - font-size: 14px;
739 - }
740 -
741 - .thinkrank-app {
742 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
743 - }
744 -
745 - .thinkrank-header {
746 - display: flex;
747 - justify-content: space-between;
748 - align-items: center;
749 - padding: 20px 0;
750 - border-bottom: 1px solid #ddd;
751 - margin-bottom: 20px;
752 - }
753 -
754 - .thinkrank-header h1 {
755 - margin: 0;
756 - font-size: 24px;
757 - font-weight: 600;
758 - }
759 -
760 - .thinkrank-header .version {
761 - font-size: 12px;
762 - color: #666;
763 - font-weight: normal;
764 - margin-left: 8px;
765 - }
766 -
767 - .thinkrank-header .tagline {
768 - margin: 4px 0 0 0;
769 - color: #666;
770 - font-size: 14px;
771 - }
772 -
773 -
774 -
775 - .thinkrank-dashboard-stats {
776 - display: grid;
777 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
778 - gap: 20px;
779 - margin-top: 20px;
780 - }
781 -
782 - .stat-card {
783 - background: #f9f9f9;
784 - padding: 20px;
785 - border-radius: 8px;
786 - text-align: center;
787 - border: 1px solid #ddd;
788 - }
789 -
790 - .stat-card h3 {
791 - margin: 0 0 10px 0;
792 - font-size: 14px;
793 - color: #666;
794 - text-transform: uppercase;
795 - letter-spacing: 0.5px;
796 - }
797 -
798 - .stat-number {
799 - font-size: 32px;
800 - font-weight: 700;
801 - color: #0073aa;
802 - margin: 0;
803 - line-height: 1;
804 - }
805 -
806 - .stat-label {
807 - margin: 4px 0 0 0;
808 - font-size: 12px;
809 - color: #666;
810 - }
811 -
812 - .thinkrank-error {
813 - padding: 20px;
814 - }
815 -
816 - .thinkrank-error .notice {
817 - margin: 0;
818 - }
819 - ';
820 - }
821 -
822 1000 public function remove_admin_notice() {
823 1001 $current_screen = get_current_screen();
824 1002 if ( in_array( $current_screen->id, [
825 1003 'toplevel_page_thinkrank',
@@ -825,11 +1003,13 @@
825 1003 'toplevel_page_thinkrank',
826 1004 'thinkrank_page_thinkrank-essential-seo',
827 1005 'thinkrank_page_thinkrank-ai-tools',
828 1006 'thinkrank_page_thinkrank-settings',
829 - 'thinkrank_page_thinkrank-analytics',
830 - 'thinkrank_page_thinkrank-license'
831 - ] ) ) {
1007 + 'thinkrank_page_thinkrank-usages',
1008 + 'thinkrank_page_thinkrank-license',
1009 + 'thinkrank_page_thinkrank-import-export',
1010 + 'thinkrank_page_thinkrank-migration'
1011 + ] , true) ) {
832 1012
833 1013 remove_all_actions( 'user_admin_notices' );
834 1014 remove_all_actions( 'admin_notices' );
835 1015 remove_all_actions( 'all_admin_notices' );
@@ -836,9 +1016,9 @@
836 1016 remove_all_actions( 'network_admin_notices' );
837 1017
838 1018 // To showing notice in EA settings page we have to use 'eael_admin_notices' action hook
839 1019 add_action( 'admin_notices', function () {
840 - do_action( 'tr_admin_notices' );
1020 + do_action( 'thinkrank_admin_notices' );
841 1021 } );
842 1022 }
843 1023 }
844 1024 }