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 +517 -228 1.0.22.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Admin Manager Class
4 5 *
5 6 * Handles WordPress admin interface integration
@@ -13,9 +14,18 @@
13 14 namespace ThinkRank\Admin;
14 15
15 16 use ThinkRank\Core\Settings;
16 17 use ThinkRank\Core\Database;
18 +use ThinkRank\Core\Plan_Config;
19 +use ThinkRank\Core\Capability_Manager;
17 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;
26 +use ThinkRank\Admin\Bulk_Action_Manager;
27 +use ThinkRank\Admin\Post_List_Filters;
18 28
19 29 // Prevent direct access
20 30 if (!defined('ABSPATH')) {
21 31 exit;
@@ -28,9 +38,9 @@
28 38 *
29 39 * @since 1.0.0
30 40 */
31 41 class Manager {
32 -
42 +
33 43 /**
34 44 * Settings instance
35 45 *
36 46 * @var Settings
@@ -35,9 +45,9 @@
35 45 *
36 46 * @var Settings
37 47 */
38 48 private Settings $settings;
39 -
49 +
40 50 /**
41 51 * Database instance
42 52 *
43 53 * @var Database
@@ -50,32 +60,110 @@
50 60 * @var Metabox_Manager
51 61 */
52 62 private Metabox_Manager $metabox_manager;
53 63
64 + /**
65 + * Elementor editor metabox integration instance
66 + *
67 + * @var Elementor_Metabox
68 + */
69 + private Elementor_Metabox $elementor_metabox;
54 70
55 -
56 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 + /**
100 + * Post list columns instance
101 + *
102 + * @var Post_List_Columns
103 + */
104 + private Post_List_Columns $post_list_columns;
105 +
106 + /**
107 + * Focus keyword AJAX handler instance
108 + *
109 + * @var Focus_Keyword_Ajax
110 + */
111 + private Focus_Keyword_Ajax $focus_keyword_ajax;
112 +
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 + /**
121 + * Bulk action manager instance
122 + *
123 + * @var Bulk_Action_Manager
124 + */
125 + private Bulk_Action_Manager $bulk_action_manager;
126 +
127 + /**
128 + * Post list filters instance
129 + *
130 + * @var Post_List_Filters
131 + */
132 + private Post_List_Filters $post_list_filters;
133 +
134 + /**
57 135 * Admin pages
58 - *
136 + *
59 137 * @var array
60 138 */
61 139 private array $pages = [];
62 -
140 +
63 141 /**
64 142 * Constructor
65 - *
143 + *
66 144 * @param Settings $settings Settings instance
67 145 * @param Database $database Database instance
68 146 */
69 - public function __construct(Settings $settings = null, Database $database = null) {
70 - $this->settings = $settings ?? new Settings();
147 + public function __construct(?Settings $settings = null, ?Database $database = null) {
148 + $this->settings = $settings ?? Settings::instance();
71 149 $this->database = $database ?? new Database();
72 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);
156 + $this->post_list_columns = new Post_List_Columns();
157 + $this->focus_keyword_ajax = new Focus_Keyword_Ajax();
158 + $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax();
159 + $this->bulk_action_manager = new Bulk_Action_Manager();
160 + $this->post_list_filters = new Post_List_Filters();
73 161 }
74 -
162 +
75 163 /**
76 164 * Initialize admin interface
77 - *
165 + *
78 166 * @return void
79 167 */
80 168 public function init(): void {
81 169 add_action('admin_menu', [$this, 'add_admin_menu']);
@@ -81,16 +169,58 @@
81 169 add_action('admin_menu', [$this, 'add_admin_menu']);
82 170 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
83 171 add_action('admin_init', [$this, 'handle_admin_init']);
84 172 add_action('admin_notices', [$this, 'show_admin_notices']);
85 -
173 + add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
174 + add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
175 +
86 176 // AJAX handlers
87 177 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
88 178
89 179 // Initialize metabox manager
90 180 $this->metabox_manager->init();
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 +
201 + // Initialize post list columns
202 + $this->post_list_columns->init();
203 +
204 + // Initialize focus keyword AJAX handler
205 + $this->focus_keyword_ajax->init();
206 +
207 + // Initialize SEO Quick Edit modal AJAX handler
208 + $this->seo_quick_edit_ajax->init();
209 +
210 + // Initialize Bulk Action Manager
211 + $this->bulk_action_manager->init();
212 +
213 + // Initialize Post List Filters
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();
91 221 }
92 -
222 +
93 223 /**
94 224 * Add admin menu pages
95 225 *
96 226 * @return void
@@ -99,71 +229,106 @@
99 229 // Main menu page
100 230 $this->pages['dashboard'] = add_menu_page(
101 231 __('ThinkRank', 'thinkrank'),
102 232 __('ThinkRank', 'thinkrank'),
103 - 'manage_options',
233 + Capability_Manager::ACCESS,
104 234 'thinkrank',
105 235 [$this, 'render_dashboard_page'],
106 236 $this->get_menu_icon(),
107 237 30
108 238 );
109 -
239 +
110 240 // Dashboard submenu (same as main)
111 241 $this->pages['dashboard_sub'] = add_submenu_page(
112 242 'thinkrank',
113 243 __('Dashboard', 'thinkrank'),
114 244 __('Dashboard', 'thinkrank'),
115 - 'manage_options',
245 + Capability_Manager::ACCESS,
116 246 'thinkrank',
117 247 [$this, 'render_dashboard_page']
118 248 );
119 -
249 +
120 250 // Essential SEO page (React tabbed interface)
121 251 $this->pages['essential_seo'] = add_submenu_page(
122 252 'thinkrank',
123 253 __('Essential SEO', 'thinkrank'),
124 254 __('Essential SEO', 'thinkrank'),
125 - 'manage_options',
255 + Capability_Manager::ACCESS,
126 256 'thinkrank-essential-seo',
127 257 [$this, 'render_essential_seo_page']
128 258 );
129 259
130 - // AI Tools page
260 + // AI Tools page — gated by the AI Tools section capability.
131 261 $this->pages['ai_tools'] = add_submenu_page(
132 262 'thinkrank',
133 263 __('AI Tools', 'thinkrank'),
134 264 __('AI Tools', 'thinkrank'),
135 - 'edit_posts',
265 + 'thinkrank_content_tools',
136 266 'thinkrank-ai-tools',
137 267 [$this, 'render_ai_tools_page']
138 268 );
139 269
140 - // 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.
141 281 $this->pages['settings'] = add_submenu_page(
142 282 'thinkrank',
143 283 __('Settings', 'thinkrank'),
144 284 __('Settings', 'thinkrank'),
145 - 'manage_options',
285 + 'thinkrank_settings',
146 286 'thinkrank-settings',
147 287 [$this, 'render_settings_page']
148 288 );
149 289
150 - // Usage Analytics page
151 - $this->pages['analytics'] = add_submenu_page(
152 - 'thinkrank',
153 - __('Usage Analytics', 'thinkrank'),
154 - __('Usage Analytics', 'thinkrank'),
155 - 'edit_posts',
156 - 'thinkrank-analytics',
157 - [$this, 'render_analytics_page']
158 - );
159 -
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.
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 +
160 325 // Hook for page-specific initialization
161 326 foreach ($this->pages as $page_hook) {
162 327 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
163 328 }
164 329 }
165 -
330 +
166 331 /**
167 332 * Enqueue admin scripts and styles
168 333 *
169 334 * APPROACH: Manual enqueuing with disabled webpack code splitting
@@ -178,9 +343,9 @@
178 343 // Only load on our admin pages
179 344 if (!in_array($hook_suffix, $this->pages, true)) {
180 345 return;
181 346 }
182 -
347 +
183 348 // Get asset files for cache busting
184 349 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
185 350 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
186 351 'dependencies' => [],
@@ -186,14 +351,13 @@
186 351 'dependencies' => [],
187 352 'version' => THINKRANK_VERSION,
188 353 ];
189 354
190 - // 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.
191 358 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
192 - $should_enqueue_charts = in_array($hook_suffix, [
193 - $this->pages['analytics'] ?? '',
194 - $this->pages['essential_seo'] ?? '',
195 - ], true);
359 + $should_enqueue_charts = true;
196 360
197 361 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
198 362 $charts_asset_data = include $charts_asset_file;
199 363 wp_enqueue_script(
@@ -219,9 +383,9 @@
219 383 );
220 384
221 385 // Add defer attribute for better performance
222 386 wp_script_add_data('thinkrank-admin', 'defer', true);
223 -
387 +
224 388 // Enqueue admin styles
225 389 wp_enqueue_style(
226 390 'thinkrank-admin',
227 391 THINKRANK_PLUGIN_URL . 'assets/admin.css',
@@ -231,48 +395,89 @@
231 395
232 396 // Enqueue WordPress media library for MediaPicker component
233 397 wp_enqueue_media();
234 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 +
235 420 // Localize script with data
236 421 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
237 422 'apiUrl' => rest_url('thinkrank/v1/'),
238 - 'nonce' => wp_create_nonce('wp_rest'),
239 423 'restNonce' => wp_create_nonce('wp_rest'),
240 424 'adminNonce' => wp_create_nonce('thinkrank_admin'),
241 425 'currentUser' => wp_get_current_user()->ID,
426 + 'displayName' => wp_get_current_user()->display_name,
242 427 'capabilities' => $this->get_user_capabilities(),
243 428 'settings' => $this->get_admin_settings(),
244 429 'i18n' => $this->get_i18n_strings(),
245 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(),
246 438 // Plugin version - directly available without API call
247 439 'version' => THINKRANK_VERSION,
248 440 // Site information for default values
249 - 'siteName' => get_bloginfo('name'),
250 - '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'),
251 443 'siteUrl' => home_url(),
444 + 'faviconUrl' => get_site_icon_url(64) ?: '',
252 445 'adminEmail' => get_option('admin_email'),
446 + // Post types for Global SEO navigation
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),
454 + // Pro detection flag
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 + ],
253 476 ]);
254 477
255 - // Add welcome notice dismissal script
256 - if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
257 - wp_add_inline_script('thinkrank-admin', '
258 - jQuery(document).ready(function($) {
259 - $(".thinkrank-dismiss-welcome").on("click", function(e) {
260 - e.preventDefault();
478 + }
261 479
262 - $.post(ajaxurl, {
263 - action: "thinkrank_dismiss_notice",
264 - notice_type: "welcome",
265 - nonce: thinkrankAdmin.adminNonce
266 - }, function(response) {
267 - $(".thinkrank-welcome-notice").fadeOut();
268 - });
269 - });
270 - });
271 - ');
272 - }
273 - }
274 -
275 480 /**
276 481 * Handle admin initialization
277 482 *
278 483 * @return void
@@ -285,9 +490,9 @@
285 490
286 491 // Check for plugin updates
287 492 $this->check_plugin_updates();
288 493 }
289 -
494 +
290 495 /**
291 496 * Load admin page
292 497 *
293 498 * @return void
@@ -292,15 +497,12 @@
292 497 *
293 498 * @return void
294 499 */
295 500 public function load_admin_page(): void {
296 - // Add help tabs
297 - $this->add_help_tabs();
298 -
299 501 // Add screen options
300 502 $this->add_screen_options();
301 503 }
302 -
504 +
303 505 /**
304 506 * Render dashboard page
305 507 *
306 508 * @return void
@@ -322,9 +524,9 @@
322 524 'title' => __('Essential SEO', 'thinkrank'),
323 525 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
324 526 ]);
325 527 }
326 -
528 +
327 529 /**
328 530 * Render AI Tools page
329 531 *
330 532 * @return void
@@ -346,12 +548,35 @@
346 548 'title' => __('ThinkRank Settings', 'thinkrank'),
347 549 'description' => __('Configure your AI SEO settings', 'thinkrank'),
348 550 ]);
349 551 }
350 -
351 552
352 -
353 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');
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 + }
575 +
576 +
577 +
578 + /**
354 579 * Render usage analytics page
355 580 *
356 581 * @return void
357 582 */
@@ -360,10 +585,47 @@
360 585 'title' => __('Usage Analytics', 'thinkrank'),
361 586 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
362 587 ]);
363 588 }
364 -
589 +
365 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 + /**
366 628 * Render admin page template
367 629 *
368 630 * @param string $page Page identifier
369 631 * @param array $data Page data
@@ -369,20 +631,15 @@
369 631 * @param array $data Page data
370 632 * @return void
371 633 */
372 634 private function render_admin_page(string $page, array $data): void {
373 - ?>
635 +?>
374 636 <div class="wrap">
375 - <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page">
376 - <div class="thinkrank-loading">
377 - <div class="spinner is-active"></div>
378 - <p><?php esc_html_e('Loading ThinkRank...', 'thinkrank'); ?></p>
379 - </div>
380 - </div>
637 + <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
381 638 </div>
382 - <?php
639 + <?php
383 640 }
384 -
641 +
385 642 /**
386 643 * Add meta boxes to post edit screens
387 644 *
388 645 * @return void
@@ -388,9 +645,9 @@
388 645 * @return void
389 646 */
390 647 public function add_meta_boxes(): void {
391 648 $post_types = get_post_types(['public' => true]);
392 -
649 +
393 650 foreach ($post_types as $post_type) {
394 651 add_meta_box(
395 652 'thinkrank-seo',
396 653 __('ThinkRank SEO', 'thinkrank'),
@@ -400,9 +657,9 @@
400 657 'high'
401 658 );
402 659 }
403 660 }
404 -
661 +
405 662 /**
406 663 * Render SEO meta box
407 664 *
408 665 * @param \WP_Post $post Current post object
@@ -409,14 +666,13 @@
409 666 * @return void
410 667 */
411 668 public function render_seo_meta_box(\WP_Post $post): void {
412 669 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
413 -
670 +
414 671 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
415 - echo '<div class="thinkrank-loading"><div class="spinner is-active"></div></div>';
416 672 echo '</div>';
417 673 }
418 -
674 +
419 675 /**
420 676 * Save meta box data
421 677 *
422 678 * @param int $post_id Post ID
@@ -426,23 +682,33 @@
426 682 // Verify nonce
427 683 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
428 684 return;
429 685 }
430 -
686 +
431 687 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
432 688 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
433 689 return;
434 690 }
435 -
691 +
436 692 // Check permissions
437 693 if (!current_user_can('edit_post', $post_id)) {
438 694 return;
439 695 }
440 -
696 +
441 697 // Save meta data (handled by AJAX in React components)
442 - 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);
443 709 }
444 -
710 +
445 711 /**
446 712 * Show admin notices
447 713 *
448 714 * @return void
@@ -449,9 +715,9 @@
449 715 */
450 716 public function show_admin_notices(): void {
451 717 // Implementation will be added in next iteration
452 718 }
453 -
719 +
454 720 /**
455 721 * Show welcome notice
456 722 *
457 723 * @return void
@@ -456,24 +722,64 @@
456 722 *
457 723 * @return void
458 724 */
459 725 public function show_welcome_notice(): void {
460 - ?>
461 - <div class="notice notice-success is-dismissible thinkrank-welcome-notice">
462 - <h3><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></h3>
463 - <p><?php esc_html_e('Thank you for installing ThinkRank. Get started by configuring your AI settings.', 'thinkrank'); ?></p>
464 - <p>
465 - <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
466 - <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
467 - </a>
468 - <a href="#" class="button thinkrank-dismiss-welcome">
469 - <?php esc_html_e('Dismiss', 'thinkrank'); ?>
470 - </a>
471 - </p>
726 + wp_enqueue_style(
727 + 'thinkrank-admin-notices',
728 + THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
729 + [],
730 + THINKRANK_VERSION
731 + );
732 + ?>
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>
472 748 </div>
473 - <?php
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 + );
474 780 }
475 -
781 +
476 782 /**
477 783 * Dismiss notice via AJAX
478 784 *
479 785 * @return void
@@ -479,15 +785,21 @@
479 785 * @return void
480 786 */
481 787 public function dismiss_notice(): void {
482 788 check_ajax_referer('thinkrank_admin', 'nonce');
483 -
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 +
484 796 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
485 -
797 +
486 798 if ($notice_type === 'welcome') {
487 799 delete_option('thinkrank_show_welcome');
488 800 }
489 -
801 +
490 802 wp_die();
491 803 }
492 804
493 805 /**
@@ -495,13 +807,14 @@
495 807 *
496 808 * @return bool True if at least one API key is configured
497 809 */
498 810 private function has_api_key_configured(): bool {
499 - $settings = new \ThinkRank\Core\Settings();
500 - $openai_key = $settings->get('openai_api_key');
501 - $claude_key = $settings->get('claude_api_key');
811 + $settings = \ThinkRank\Core\Settings::instance();
502 812
503 - 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'));
504 817 }
505 818
506 819 /**
507 820 * Get menu icon
@@ -508,8 +821,9 @@
508 821 *
509 822 * @return string Menu icon
510 823 */
511 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.
512 826 return 'data:image/svg+xml;base64,' . base64_encode(
513 827 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
514 828 <g clipPath="url(#thinkrank-clip)">
515 829 <g filter="url(#thinkrank-shadow)">
@@ -534,9 +848,9 @@
534 848 </defs>
535 849 </svg>'
536 850 );
537 851 }
538 -
852 +
539 853 /**
540 854 * Get user capabilities for current user
541 855 *
542 856 * @return array User capabilities
@@ -548,9 +862,9 @@
548 862
549 863 'use_ai_features' => current_user_can('edit_posts'),
550 864 ];
551 865 }
552 -
866 +
553 867 /**
554 868 * Get admin settings for JavaScript
555 869 *
556 870 * @return array Admin settings
@@ -557,16 +871,33 @@
557 871 */
558 872 private function get_admin_settings(): array {
559 873 return [
560 874
561 - 'ai_provider' => $this->settings->get('ai_provider', 'openai'),
875 + 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
562 876 'cache_duration' => $this->settings->get('cache_duration', 3600),
563 877 ];
564 878 }
565 -
879 +
566 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 + /**
567 898 * Get internationalization strings
568 - *
899 + *
569 900 * @return array I18n strings
570 901 */
571 902 private function get_i18n_strings(): array {
572 903 return [
@@ -577,10 +908,45 @@
577 908 'cancel' => __('Cancel', 'thinkrank'),
578 909 'save' => __('Save', 'thinkrank'),
579 910 ];
580 911 }
581 -
912 +
582 913 /**
914 + * Get all public post types for SEO configuration
915 + *
916 + * @return array Post types data
917 + */
918 + private function get_public_post_types(): array {
919 + // Get all public post types (both built-in and custom)
920 + $post_types = get_post_types([
921 + 'public' => true
922 + ], 'objects');
923 +
924 + $post_types_data = [];
925 +
926 + foreach ($post_types as $post_type) {
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)) {
930 + continue;
931 + }
932 +
933 + $post_types_data[] = [
934 + 'name' => $post_type->name,
935 + 'slug' => $post_type->name,
936 + 'label' => $post_type->label,
937 + 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
938 + 'plural_name' => $post_type->label,
939 + 'public' => $post_type->public,
940 + 'has_archive' => $post_type->has_archive,
941 + 'hierarchical' => $post_type->hierarchical,
942 + ];
943 + }
944 +
945 + return $post_types_data;
946 + }
947 +
948 + /**
583 949 * Add help tabs
584 950 *
585 951 * @return void
586 952 */
@@ -585,22 +951,22 @@
585 951 * @return void
586 952 */
587 953 private function add_help_tabs(): void {
588 954 $screen = get_current_screen();
589 -
955 +
590 956 $screen->add_help_tab([
591 957 'id' => 'thinkrank-overview',
592 958 'title' => __('Overview', 'thinkrank'),
593 959 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
594 960 ]);
595 -
961 +
596 962 $screen->set_help_sidebar(
597 963 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
598 - '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
599 - '<p><a href="https://thinkrank.ai/support" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
964 + '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
965 + '<p><a href="https://wpdeveloper.com/support/new-ticket/" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
600 966 );
601 967 }
602 -
968 +
603 969 /**
604 970 * Add screen options
605 971 *
606 972 * @return void
@@ -607,9 +973,9 @@
607 973 */
608 974 private function add_screen_options(): void {
609 975 // Screen options will be added as needed
610 976 }
611 -
977 +
612 978 /**
613 979 * Check for plugin updates
614 980 *
615 981 * @return void
@@ -616,8 +982,14 @@
616 982 */
617 983 private function check_plugin_updates(): void {
618 984 $current_version = get_option('thinkrank_version');
619 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 +
620 992 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
621 993 // Handle plugin update
622 994 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
623 995 update_option('thinkrank_version', THINKRANK_VERSION);
@@ -623,113 +995,30 @@
623 995 update_option('thinkrank_version', THINKRANK_VERSION);
624 996 }
625 997 }
626 998
627 - /**
628 - * Get admin styles
629 - *
630 - * @return string CSS styles
631 - */
632 - private function get_admin_styles(): string {
633 - return '
634 - .thinkrank-loading {
635 - display: flex;
636 - flex-direction: column;
637 - align-items: center;
638 - justify-content: center;
639 - padding: 40px 20px;
640 - text-align: center;
641 - }
642 999
643 - .thinkrank-loading .spinner {
644 - margin-bottom: 16px;
645 - }
1000 + public function remove_admin_notice() {
1001 + $current_screen = get_current_screen();
1002 + if ( in_array( $current_screen->id, [
1003 + 'toplevel_page_thinkrank',
1004 + 'thinkrank_page_thinkrank-essential-seo',
1005 + 'thinkrank_page_thinkrank-ai-tools',
1006 + 'thinkrank_page_thinkrank-settings',
1007 + 'thinkrank_page_thinkrank-usages',
1008 + 'thinkrank_page_thinkrank-license',
1009 + 'thinkrank_page_thinkrank-import-export',
1010 + 'thinkrank_page_thinkrank-migration'
1011 + ] , true) ) {
646 1012
647 - .thinkrank-loading p {
648 - margin: 0;
649 - color: #666;
650 - font-size: 14px;
651 - }
1013 + remove_all_actions( 'user_admin_notices' );
1014 + remove_all_actions( 'admin_notices' );
1015 + remove_all_actions( 'all_admin_notices' );
1016 + remove_all_actions( 'network_admin_notices' );
652 1017
653 - .thinkrank-app {
654 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
655 - }
656 -
657 - .thinkrank-header {
658 - display: flex;
659 - justify-content: space-between;
660 - align-items: center;
661 - padding: 20px 0;
662 - border-bottom: 1px solid #ddd;
663 - margin-bottom: 20px;
664 - }
665 -
666 - .thinkrank-header h1 {
667 - margin: 0;
668 - font-size: 24px;
669 - font-weight: 600;
670 - }
671 -
672 - .thinkrank-header .version {
673 - font-size: 12px;
674 - color: #666;
675 - font-weight: normal;
676 - margin-left: 8px;
677 - }
678 -
679 - .thinkrank-header .tagline {
680 - margin: 4px 0 0 0;
681 - color: #666;
682 - font-size: 14px;
683 - }
684 -
685 -
686 -
687 - .thinkrank-dashboard-stats {
688 - display: grid;
689 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
690 - gap: 20px;
691 - margin-top: 20px;
692 - }
693 -
694 - .stat-card {
695 - background: #f9f9f9;
696 - padding: 20px;
697 - border-radius: 8px;
698 - text-align: center;
699 - border: 1px solid #ddd;
700 - }
701 -
702 - .stat-card h3 {
703 - margin: 0 0 10px 0;
704 - font-size: 14px;
705 - color: #666;
706 - text-transform: uppercase;
707 - letter-spacing: 0.5px;
708 - }
709 -
710 - .stat-number {
711 - font-size: 32px;
712 - font-weight: 700;
713 - color: #0073aa;
714 - margin: 0;
715 - line-height: 1;
716 - }
717 -
718 - .stat-label {
719 - margin: 4px 0 0 0;
720 - font-size: 12px;
721 - color: #666;
722 - }
723 -
724 - .thinkrank-error {
725 - padding: 20px;
726 - }
727 -
728 - .thinkrank-error .notice {
729 - margin: 0;
730 - }
731 - ';
732 - }
733 -
734 -
1018 + // To showing notice in EA settings page we have to use 'eael_admin_notices' action hook
1019 + add_action( 'admin_notices', function () {
1020 + do_action( 'thinkrank_admin_notices' );
1021 + } );
1022 + }
1023 + }
735 1024 }