PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.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 All 52 releases
← All changes | includes/admin/class-manager.php +526 -231 1.0.0 → 2.11.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,19 @@
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;
20 +use ThinkRank\Config\Local_Business_Types_Config;
17 21 use ThinkRank\Admin\Metabox_Manager;
22 +use ThinkRank\Admin\Elementor_Metabox;
23 +use ThinkRank\Admin\Oxygen_Metabox;
24 +use ThinkRank\Admin\Divi_Metabox;
25 +use ThinkRank\Admin\Bricks_Metabox;
26 +use ThinkRank\Admin\Beaver_Metabox;
27 +use ThinkRank\Admin\Bulk_Action_Manager;
28 +use ThinkRank\Admin\Post_List_Filters;
18 29
19 30 // Prevent direct access
20 31 if (!defined('ABSPATH')) {
21 32 exit;
@@ -28,9 +39,9 @@
28 39 *
29 40 * @since 1.0.0
30 41 */
31 42 class Manager {
32 -
43 +
33 44 /**
34 45 * Settings instance
35 46 *
36 47 * @var Settings
@@ -35,9 +46,9 @@
35 46 *
36 47 * @var Settings
37 48 */
38 49 private Settings $settings;
39 -
50 +
40 51 /**
41 52 * Database instance
42 53 *
43 54 * @var Database
@@ -50,32 +61,110 @@
50 61 * @var Metabox_Manager
51 62 */
52 63 private Metabox_Manager $metabox_manager;
53 64
65 + /**
66 + * Elementor editor metabox integration instance
67 + *
68 + * @var Elementor_Metabox
69 + */
70 + private Elementor_Metabox $elementor_metabox;
54 71
55 -
56 72 /**
73 + * Oxygen / Breakdance editor metabox integration instance
74 + *
75 + * @var Oxygen_Metabox
76 + */
77 + private Oxygen_Metabox $oxygen_metabox;
78 +
79 + /**
80 + * Divi Visual Builder metabox integration instance
81 + *
82 + * @var Divi_Metabox
83 + */
84 + private Divi_Metabox $divi_metabox;
85 +
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 + /**
101 + * Post list columns instance
102 + *
103 + * @var Post_List_Columns
104 + */
105 + private Post_List_Columns $post_list_columns;
106 +
107 + /**
108 + * Focus keyword AJAX handler instance
109 + *
110 + * @var Focus_Keyword_Ajax
111 + */
112 + private Focus_Keyword_Ajax $focus_keyword_ajax;
113 +
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 + /**
122 + * Bulk action manager instance
123 + *
124 + * @var Bulk_Action_Manager
125 + */
126 + private Bulk_Action_Manager $bulk_action_manager;
127 +
128 + /**
129 + * Post list filters instance
130 + *
131 + * @var Post_List_Filters
132 + */
133 + private Post_List_Filters $post_list_filters;
134 +
135 + /**
57 136 * Admin pages
58 - *
137 + *
59 138 * @var array
60 139 */
61 140 private array $pages = [];
62 -
141 +
63 142 /**
64 143 * Constructor
65 - *
144 + *
66 145 * @param Settings $settings Settings instance
67 146 * @param Database $database Database instance
68 147 */
69 - public function __construct(Settings $settings = null, Database $database = null) {
70 - $this->settings = $settings ?? new Settings();
148 + public function __construct(?Settings $settings = null, ?Database $database = null) {
149 + $this->settings = $settings ?? Settings::instance();
71 150 $this->database = $database ?? new Database();
72 151 $this->metabox_manager = new Metabox_Manager($this->settings);
152 + $this->elementor_metabox = new Elementor_Metabox($this->metabox_manager);
153 + $this->oxygen_metabox = new Oxygen_Metabox($this->metabox_manager);
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);
157 + $this->post_list_columns = new Post_List_Columns();
158 + $this->focus_keyword_ajax = new Focus_Keyword_Ajax();
159 + $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax();
160 + $this->bulk_action_manager = new Bulk_Action_Manager();
161 + $this->post_list_filters = new Post_List_Filters();
73 162 }
74 -
163 +
75 164 /**
76 165 * Initialize admin interface
77 - *
166 + *
78 167 * @return void
79 168 */
80 169 public function init(): void {
81 170 add_action('admin_menu', [$this, 'add_admin_menu']);
@@ -81,16 +170,58 @@
81 170 add_action('admin_menu', [$this, 'add_admin_menu']);
82 171 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
83 172 add_action('admin_init', [$this, 'handle_admin_init']);
84 173 add_action('admin_notices', [$this, 'show_admin_notices']);
85 -
174 + add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
175 + add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
176 +
86 177 // AJAX handlers
87 178 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
88 179
89 180 // Initialize metabox manager
90 181 $this->metabox_manager->init();
182 +
183 + // Initialize Elementor editor integration (hooks no-op without Elementor)
184 + $this->elementor_metabox->init();
185 +
186 + // Initialize Oxygen / Breakdance editor integration (hooks gate on the
187 + // builder request, so they no-op without Oxygen)
188 + $this->oxygen_metabox->init();
189 +
190 + // Initialize Divi Visual Builder integration (hooks gate on the VB
191 + // request, so they no-op without Divi)
192 + $this->divi_metabox->init();
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 +
202 + // Initialize post list columns
203 + $this->post_list_columns->init();
204 +
205 + // Initialize focus keyword AJAX handler
206 + $this->focus_keyword_ajax->init();
207 +
208 + // Initialize SEO Quick Edit modal AJAX handler
209 + $this->seo_quick_edit_ajax->init();
210 +
211 + // Initialize Bulk Action Manager
212 + $this->bulk_action_manager->init();
213 +
214 + // Initialize Post List Filters
215 + $this->post_list_filters->init();
216 +
217 + // Initialize Setup Wizard (onboarding) controller
218 + (new Setup_Wizard())->init();
219 +
220 + // Initialize the wp-admin Dashboard widget (ThinkRank Website Insights)
221 + (new Dashboard_Widget())->init();
91 222 }
92 -
223 +
93 224 /**
94 225 * Add admin menu pages
95 226 *
96 227 * @return void
@@ -99,71 +230,106 @@
99 230 // Main menu page
100 231 $this->pages['dashboard'] = add_menu_page(
101 232 __('ThinkRank', 'thinkrank'),
102 233 __('ThinkRank', 'thinkrank'),
103 - 'manage_options',
234 + Capability_Manager::ACCESS,
104 235 'thinkrank',
105 236 [$this, 'render_dashboard_page'],
106 237 $this->get_menu_icon(),
107 238 30
108 239 );
109 -
240 +
110 241 // Dashboard submenu (same as main)
111 242 $this->pages['dashboard_sub'] = add_submenu_page(
112 243 'thinkrank',
113 244 __('Dashboard', 'thinkrank'),
114 245 __('Dashboard', 'thinkrank'),
115 - 'manage_options',
246 + Capability_Manager::ACCESS,
116 247 'thinkrank',
117 248 [$this, 'render_dashboard_page']
118 249 );
119 -
250 +
120 251 // Essential SEO page (React tabbed interface)
121 252 $this->pages['essential_seo'] = add_submenu_page(
122 253 'thinkrank',
123 254 __('Essential SEO', 'thinkrank'),
124 255 __('Essential SEO', 'thinkrank'),
125 - 'manage_options',
256 + Capability_Manager::ACCESS,
126 257 'thinkrank-essential-seo',
127 258 [$this, 'render_essential_seo_page']
128 259 );
129 260
130 - // AI Tools page
261 + // AI Tools page — gated by the AI Tools section capability.
131 262 $this->pages['ai_tools'] = add_submenu_page(
132 263 'thinkrank',
133 264 __('AI Tools', 'thinkrank'),
134 265 __('AI Tools', 'thinkrank'),
135 - 'edit_posts',
266 + 'thinkrank_content_tools',
136 267 'thinkrank-ai-tools',
137 268 [$this, 'render_ai_tools_page']
138 269 );
139 270
140 - // Settings page
271 + // Usages page — gated by the Analytics section capability.
272 + $this->pages['analytics'] = add_submenu_page(
273 + 'thinkrank',
274 + __('Usages', 'thinkrank'),
275 + __('Usages', 'thinkrank'),
276 + 'thinkrank_analytics',
277 + 'thinkrank-usages',
278 + [$this, 'render_analytics_page']
279 + );
280 +
281 + // Settings page — gated by the Settings & API Keys section capability.
141 282 $this->pages['settings'] = add_submenu_page(
142 283 'thinkrank',
143 284 __('Settings', 'thinkrank'),
144 285 __('Settings', 'thinkrank'),
145 - 'manage_options',
286 + 'thinkrank_settings',
146 287 'thinkrank-settings',
147 288 [$this, 'render_settings_page']
148 289 );
149 290
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 -
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)) {
316 + $this->pages['migration'] = add_submenu_page(
317 + 'thinkrank',
318 + __('Migration', 'thinkrank'),
319 + __('Migration', 'thinkrank'),
320 + 'manage_options',
321 + 'thinkrank-migration',
322 + [$this, 'render_migration_page']
323 + );
324 + }
325 +
160 326 // Hook for page-specific initialization
161 327 foreach ($this->pages as $page_hook) {
162 328 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
163 329 }
164 330 }
165 -
331 +
166 332 /**
167 333 * Enqueue admin scripts and styles
168 334 *
169 335 * APPROACH: Manual enqueuing with disabled webpack code splitting
@@ -178,9 +344,9 @@
178 344 // Only load on our admin pages
179 345 if (!in_array($hook_suffix, $this->pages, true)) {
180 346 return;
181 347 }
182 -
348 +
183 349 // Get asset files for cache busting
184 350 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
185 351 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
186 352 'dependencies' => [],
@@ -186,14 +352,13 @@
186 352 'dependencies' => [],
187 353 'version' => THINKRANK_VERSION,
188 354 ];
189 355
190 - // Enqueue Chart.js bundle only on pages that render charts (Analytics and Essential SEO Performance)
356 + // Enqueue the Chart.js bundle on every ThinkRank page: the admin app
357 + // is a SPA, so chart pages (Usages, Essential SEO Performance) are
358 + // reachable from any other ThinkRank page without a reload.
191 359 $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);
360 + $should_enqueue_charts = true;
196 361
197 362 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
198 363 $charts_asset_data = include $charts_asset_file;
199 364 wp_enqueue_script(
@@ -219,9 +384,9 @@
219 384 );
220 385
221 386 // Add defer attribute for better performance
222 387 wp_script_add_data('thinkrank-admin', 'defer', true);
223 -
388 +
224 389 // Enqueue admin styles
225 390 wp_enqueue_style(
226 391 'thinkrank-admin',
227 392 THINKRANK_PLUGIN_URL . 'assets/admin.css',
@@ -231,46 +396,97 @@
231 396
232 397 // Enqueue WordPress media library for MediaPicker component
233 398 wp_enqueue_media();
234 399
400 + // Preload the REST responses every ThinkRank admin page requests on
401 + // mount (Site Kit pattern: rest_preload_api_request piped into an
402 + // apiFetch preloading middleware) so first paint needs zero
403 + // round-trips for them. Only cheap, local settings endpoints belong
404 + // here — never Google-backed report data.
405 + $preload_paths = apply_filters('thinkrank_apifetch_preload_paths', [
406 + '/thinkrank/v1/site-identity/settings',
407 + '/thinkrank/v1/site-identity/title/templates',
408 + '/thinkrank/v1/site-identity/breadcrumbs/types',
409 + ]);
410 + $preload_data = array_reduce($preload_paths, 'rest_preload_api_request', []);
411 + wp_add_inline_script(
412 + 'thinkrank-admin',
413 + sprintf('window.thinkrankApiPreload = %s;', wp_json_encode((object) $preload_data)),
414 + 'before'
415 + );
416 +
417 + // Site info saved in ThinkRank Site Identity takes precedence over
418 + // the WordPress defaults so previews reflect what the user saved.
419 + $site_identity_settings = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
420 +
235 421 // Localize script with data
236 422 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
237 423 'apiUrl' => rest_url('thinkrank/v1/'),
238 - 'nonce' => wp_create_nonce('wp_rest'),
239 424 'restNonce' => wp_create_nonce('wp_rest'),
240 425 'adminNonce' => wp_create_nonce('thinkrank_admin'),
241 426 'currentUser' => wp_get_current_user()->ID,
427 + 'displayName' => wp_get_current_user()->display_name,
242 428 'capabilities' => $this->get_user_capabilities(),
243 429 'settings' => $this->get_admin_settings(),
244 430 'i18n' => $this->get_i18n_strings(),
245 431 'isAdmin' => current_user_can('manage_options'),
432 + // Simple / Advanced navigation for this user (#730). Read once
433 + // when the page is built; the header switch updates it in place.
434 + 'uiMode' => UI_Mode::get(),
435 + // One flag per half of the Import / Export page: the "import from
436 + // another SEO plugin" section, and ThinkRank's own export/restore.
437 + 'migrationToolsEnabled' => (bool) $this->settings->get('enable_migration_tools', false),
438 + 'importExportEnabled' => (bool) $this->settings->get('enable_import_export', false),
439 + // Whether any AI provider API key is configured — used to gate
440 + // "Generate with AI" buttons in the UI
441 + 'aiConfigured' => $this->is_ai_configured(),
442 + // Plugin version - directly available without API call
443 + 'version' => THINKRANK_VERSION,
246 444 // Site information for default values
247 - 'siteName' => get_bloginfo('name'),
248 - 'siteDescription' => get_bloginfo('description'),
445 + 'siteName' => !empty($site_identity_settings['site_name']) ? $site_identity_settings['site_name'] : get_bloginfo('name'),
446 + 'siteDescription' => !empty($site_identity_settings['site_description']) ? $site_identity_settings['site_description'] : get_bloginfo('description'),
249 447 'siteUrl' => home_url(),
448 + 'faviconUrl' => get_site_icon_url(64) ?: '',
250 449 'adminEmail' => get_option('admin_email'),
450 + // Post types for Global SEO navigation
451 + 'postTypes' => $this->get_public_post_types(),
452 + // schema.org LocalBusiness subtypes for the Local SEO control.
453 + // Localized rather than mirrored in a JS config: the list runs to
454 + // ~150 entries and is also the MCP ability's enum, so a second copy
455 + // would be a second thing to keep in step (#623).
456 + 'localBusinessTypes' => Local_Business_Types_Config::get_options(),
457 + // Role Manager: capabilities the current user holds + the
458 + // section → capability map, so the SPA can hide sections a role
459 + // cannot access. Administrators receive every capability.
460 + 'caps' => Capability_Manager::user_capabilities(),
461 + 'sectionCaps' => Capability_Manager::section_map(),
462 + 'canManageRoles' => Capability_Manager::current_user_can(Capability_Manager::MANAGE_ROLES),
463 + // Pro detection flag
464 + 'isPro' => Plan_Config::is_pro(),
465 + // NB: no per-feature capability maps here; each screen reads its
466 + // own state over REST, so a localized copy cannot drift from it.
467 + // MCP (Model Context Protocol) connection details for the MCP page.
468 + 'mcp' => $this->get_mcp_globals(),
469 + // Google OAuth: JS only ever gets a nonce-signed admin-post URL.
470 + // The consent URL, client ID, and scopes are assembled by the proxy,
471 + // so no Google app credentials reach the browser or the bundle.
472 + 'googleOAuth' => [
473 + 'connectUrl' => \ThinkRank\Integrations\Google_OAuth_Proxy::get_connect_url(),
474 + // '' when fine, otherwise why re-authorization is needed:
475 + // 'contract' (upgraded off the old token flow) or
476 + // 'credentials' (stored tokens no longer decryptable).
477 + 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
478 + ],
479 + // Setup Wizard state — the dashboard Quick Access widget surfaces a
480 + // "Complete Setup" shortcut while onboarding is unfinished.
481 + 'setupWizard' => [
482 + 'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false),
483 + 'url' => admin_url('admin.php?page=' . Setup_Wizard::PAGE_SLUG),
484 + ],
251 485 ]);
252 486
253 - // Add welcome notice dismissal script
254 - if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
255 - wp_add_inline_script('thinkrank-admin', '
256 - jQuery(document).ready(function($) {
257 - $(".thinkrank-dismiss-welcome").on("click", function(e) {
258 - e.preventDefault();
487 + }
259 488
260 - $.post(ajaxurl, {
261 - action: "thinkrank_dismiss_notice",
262 - notice_type: "welcome",
263 - nonce: thinkrankAdmin.adminNonce
264 - }, function(response) {
265 - $(".thinkrank-welcome-notice").fadeOut();
266 - });
267 - });
268 - });
269 - ');
270 - }
271 - }
272 -
273 489 /**
274 490 * Handle admin initialization
275 491 *
276 492 * @return void
@@ -282,10 +498,15 @@
282 498 }
283 499
284 500 // Check for plugin updates
285 501 $this->check_plugin_updates();
502 +
503 + // One-time: a Key Features value saved while commas were delimiters
504 + // becomes one feature per line, so the next regeneration publishes the
505 + // bullets the site already had rather than one merged line.
506 + \ThinkRank\SEO\LLMs_Txt_Manager::maybe_migrate_legacy_key_features();
286 507 }
287 -
508 +
288 509 /**
289 510 * Load admin page
290 511 *
291 512 * @return void
@@ -290,15 +511,12 @@
290 511 *
291 512 * @return void
292 513 */
293 514 public function load_admin_page(): void {
294 - // Add help tabs
295 - $this->add_help_tabs();
296 -
297 515 // Add screen options
298 516 $this->add_screen_options();
299 517 }
300 -
518 +
301 519 /**
302 520 * Render dashboard page
303 521 *
304 522 * @return void
@@ -320,9 +538,9 @@
320 538 'title' => __('Essential SEO', 'thinkrank'),
321 539 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
322 540 ]);
323 541 }
324 -
542 +
325 543 /**
326 544 * Render AI Tools page
327 545 *
328 546 * @return void
@@ -344,12 +562,35 @@
344 562 'title' => __('ThinkRank Settings', 'thinkrank'),
345 563 'description' => __('Configure your AI SEO settings', 'thinkrank'),
346 564 ]);
347 565 }
348 -
349 566
350 -
351 567 /**
568 + * Build the MCP connection globals passed to the admin app.
569 + *
570 + * The MCP page fetches live connection state from the
571 + * /thinkrank/v1/mcp/connection route; these globals only carry what the
572 + * page needs before that request resolves (endpoint URLs and whether the
573 + * bundled Abilities API — the tool catalog — is available).
574 + *
575 + * @return array<string, mixed>
576 + */
577 + private function get_mcp_globals(): array {
578 + // The tool catalog is the abilities registry; wp_register_ability
579 + // comes from the bundled Abilities API under dependencies/. When it's
580 + // missing (bundle not built), the MCP server has no tools to serve.
581 + $abilities_api_available = function_exists('wp_register_ability');
582 +
583 + return [
584 + 'abilities_api_available' => $abilities_api_available,
585 + 'mcp_endpoint' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint(),
586 + 'mcp_endpoint_rest' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint_fallback(),
587 + ];
588 + }
589 +
590 +
591 +
592 + /**
352 593 * Render usage analytics page
353 594 *
354 595 * @return void
355 596 */
@@ -358,10 +599,47 @@
358 599 'title' => __('Usage Analytics', 'thinkrank'),
359 600 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
360 601 ]);
361 602 }
362 -
603 +
363 604 /**
605 + * Render the Import / Export page (ThinkRank's own data, out and back).
606 + *
607 + * Defense in depth: the submenu is only registered while the setting is on,
608 + * but re-check here so a direct hit on the page URL cannot bypass the gate.
609 + * The export REST routes carry their own `manage_options` check, so nothing
610 + * is protected by the page alone.
611 + *
612 + * @return void
613 + */
614 + public function render_import_export_page(): void {
615 + if (!(bool) Settings::instance()->get('enable_import_export', false)) {
616 + wp_die(esc_html__('Import / Export is not enabled.', 'thinkrank'));
617 + }
618 +
619 + $this->render_admin_page('import-export', [
620 + 'page_title' => __('Import / Export', 'thinkrank'),
621 + ]);
622 + }
623 +
624 + /**
625 + * Render the Migration page (import SEO data from another plugin).
626 + *
627 + * Same defense in depth as above, against its own setting.
628 + *
629 + * @return void
630 + */
631 + public function render_migration_page(): void {
632 + if (!(bool) Settings::instance()->get('enable_migration_tools', false)) {
633 + wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank'));
634 + }
635 +
636 + $this->render_admin_page('migration', [
637 + 'page_title' => __('Migration', 'thinkrank'),
638 + ]);
639 + }
640 +
641 + /**
364 642 * Render admin page template
365 643 *
366 644 * @param string $page Page identifier
367 645 * @param array $data Page data
@@ -367,20 +645,15 @@
367 645 * @param array $data Page data
368 646 * @return void
369 647 */
370 648 private function render_admin_page(string $page, array $data): void {
371 - ?>
649 +?>
372 650 <div class="wrap">
373 - <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page">
374 - <div class="thinkrank-loading">
375 - <div class="spinner is-active"></div>
376 - <p><?php esc_html_e('Loading ThinkRank...', 'thinkrank'); ?></p>
377 - </div>
378 - </div>
651 + <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
379 652 </div>
380 - <?php
653 + <?php
381 654 }
382 -
655 +
383 656 /**
384 657 * Add meta boxes to post edit screens
385 658 *
386 659 * @return void
@@ -386,9 +659,9 @@
386 659 * @return void
387 660 */
388 661 public function add_meta_boxes(): void {
389 662 $post_types = get_post_types(['public' => true]);
390 -
663 +
391 664 foreach ($post_types as $post_type) {
392 665 add_meta_box(
393 666 'thinkrank-seo',
394 667 __('ThinkRank SEO', 'thinkrank'),
@@ -398,9 +671,9 @@
398 671 'high'
399 672 );
400 673 }
401 674 }
402 -
675 +
403 676 /**
404 677 * Render SEO meta box
405 678 *
406 679 * @param \WP_Post $post Current post object
@@ -407,14 +680,13 @@
407 680 * @return void
408 681 */
409 682 public function render_seo_meta_box(\WP_Post $post): void {
410 683 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
411 -
684 +
412 685 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
413 - echo '<div class="thinkrank-loading"><div class="spinner is-active"></div></div>';
414 686 echo '</div>';
415 687 }
416 -
688 +
417 689 /**
418 690 * Save meta box data
419 691 *
420 692 * @param int $post_id Post ID
@@ -424,23 +696,33 @@
424 696 // Verify nonce
425 697 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
426 698 return;
427 699 }
428 -
700 +
429 701 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
430 702 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
431 703 return;
432 704 }
433 -
705 +
434 706 // Check permissions
435 707 if (!current_user_can('edit_post', $post_id)) {
436 708 return;
437 709 }
438 -
710 +
439 711 // Save meta data (handled by AJAX in React components)
440 - do_action('thinkrank_save_post_meta', $post_id, $_POST);
712 + // Pass only sanitized ThinkRank-related fields to action hook
713 + $sanitized_data = [];
714 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above
715 + foreach ($_POST as $key => $value) {
716 + if (strpos($key, 'thinkrank_') === 0 || strpos($key, '_thinkrank_') === 0) {
717 + $sanitized_data[$key] = is_array($value)
718 + ? array_map('sanitize_text_field', wp_unslash($value))
719 + : sanitize_text_field(wp_unslash($value));
720 + }
721 + }
722 + do_action('thinkrank_save_post_meta', $post_id, $sanitized_data);
441 723 }
442 -
724 +
443 725 /**
444 726 * Show admin notices
445 727 *
446 728 * @return void
@@ -447,9 +729,9 @@
447 729 */
448 730 public function show_admin_notices(): void {
449 731 // Implementation will be added in next iteration
450 732 }
451 -
733 +
452 734 /**
453 735 * Show welcome notice
454 736 *
455 737 * @return void
@@ -454,24 +736,64 @@
454 736 *
455 737 * @return void
456 738 */
457 739 public function show_welcome_notice(): void {
458 - ?>
459 - <div class="notice notice-success is-dismissible thinkrank-welcome-notice">
460 - <h3><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></h3>
461 - <p><?php esc_html_e('Thank you for installing ThinkRank. Get started by configuring your AI settings.', 'thinkrank'); ?></p>
462 - <p>
463 - <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
464 - <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
465 - </a>
466 - <a href="#" class="button thinkrank-dismiss-welcome">
467 - <?php esc_html_e('Dismiss', 'thinkrank'); ?>
468 - </a>
469 - </p>
740 + wp_enqueue_style(
741 + 'thinkrank-admin-notices',
742 + THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
743 + [],
744 + THINKRANK_VERSION
745 + );
746 + ?>
747 + <div class="notice notice-success is-dismissible thinkrank-notice thinkrank-welcome-notice">
748 + <div class="thinkrank-notice__inner">
749 + <div class="thinkrank-notice__body">
750 + <p class="thinkrank-notice__title"><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></p>
751 + <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>
752 + <p class="thinkrank-notice__actions">
753 + <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
754 + <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
755 + </a>
756 + <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-welcome" data-nonce="<?php echo esc_attr(wp_create_nonce('thinkrank_admin')); ?>">
757 + <?php esc_html_e('Dismiss', 'thinkrank'); ?>
758 + </a>
759 + </p>
760 + </div>
761 + </div>
470 762 </div>
471 - <?php
763 +<?php
764 + // The notice renders on every admin screen, so the dismiss handler must
765 + // ship with it — the thinkrank-admin bundle only loads on ThinkRank pages.
766 + // Persist the dismissal for both our "Dismiss" link and core's × button.
767 + wp_print_inline_script_tag(
768 + '( function () {
769 + document.addEventListener( "click", function ( event ) {
770 + var notice = event.target.closest( ".thinkrank-welcome-notice" );
771 + if ( ! notice ) {
772 + return;
773 + }
774 + var link = event.target.closest( ".thinkrank-dismiss-welcome" );
775 + if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
776 + return;
777 + }
778 + if ( link ) {
779 + event.preventDefault();
780 + notice.style.display = "none";
781 + }
782 + window.fetch( window.ajaxurl, {
783 + method: "POST",
784 + credentials: "same-origin",
785 + body: new URLSearchParams( {
786 + action: "thinkrank_dismiss_notice",
787 + notice_type: "welcome",
788 + nonce: notice.querySelector( ".thinkrank-dismiss-welcome" ).dataset.nonce,
789 + } ),
790 + } );
791 + } );
792 + } )();'
793 + );
472 794 }
473 -
795 +
474 796 /**
475 797 * Dismiss notice via AJAX
476 798 *
477 799 * @return void
@@ -477,29 +799,31 @@
477 799 * @return void
478 800 */
479 801 public function dismiss_notice(): void {
480 802 check_ajax_referer('thinkrank_admin', 'nonce');
481 -
803 +
804 + // The nonce proves intent, not authorization — dismissing a site-wide
805 + // notice deletes an option, so require a capability as well.
806 + if (!current_user_can('edit_posts')) {
807 + wp_die(-1, 403);
808 + }
809 +
482 810 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
483 -
811 +
484 812 if ($notice_type === 'welcome') {
485 813 delete_option('thinkrank_show_welcome');
486 814 }
487 -
815 +
488 816 wp_die();
489 817 }
490 818
491 819 /**
492 - * Check if API key is configured
820 + * Check if the selected AI provider is configured
493 821 *
494 - * @return bool True if at least one API key is configured
822 + * @return bool True when the chosen provider has what it needs to run
495 823 */
496 824 private function has_api_key_configured(): bool {
497 - $settings = new \ThinkRank\Core\Settings();
498 - $openai_key = $settings->get('openai_api_key');
499 - $claude_key = $settings->get('claude_api_key');
500 -
501 - return !empty($openai_key) || !empty($claude_key);
825 + return \ThinkRank\Core\Settings::instance()->has_ai_provider_configured();
502 826 }
503 827
504 828 /**
505 829 * Get menu icon
@@ -506,8 +830,9 @@
506 830 *
507 831 * @return string Menu icon
508 832 */
509 833 private function get_menu_icon(): string {
834 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a data: URI for the menu icon must be base64.
510 835 return 'data:image/svg+xml;base64,' . base64_encode(
511 836 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
512 837 <g clipPath="url(#thinkrank-clip)">
513 838 <g filter="url(#thinkrank-shadow)">
@@ -532,9 +857,9 @@
532 857 </defs>
533 858 </svg>'
534 859 );
535 860 }
536 -
861 +
537 862 /**
538 863 * Get user capabilities for current user
539 864 *
540 865 * @return array User capabilities
@@ -546,9 +871,9 @@
546 871
547 872 'use_ai_features' => current_user_can('edit_posts'),
548 873 ];
549 874 }
550 -
875 +
551 876 /**
552 877 * Get admin settings for JavaScript
553 878 *
554 879 * @return array Admin settings
@@ -555,16 +880,28 @@
555 880 */
556 881 private function get_admin_settings(): array {
557 882 return [
558 883
559 - 'ai_provider' => $this->settings->get('ai_provider', 'openai'),
884 + 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
560 885 'cache_duration' => $this->settings->get('cache_duration', 3600),
561 886 ];
562 887 }
563 -
888 +
564 889 /**
890 + * Whether the selected AI provider is configured
891 + *
892 + * Same answer as ThinkRank\AI\Manager — both read
893 + * Settings::has_ai_provider_configured().
894 + *
895 + * @return bool
896 + */
897 + private function is_ai_configured(): bool {
898 + return $this->settings->has_ai_provider_configured();
899 + }
900 +
901 + /**
565 902 * Get internationalization strings
566 - *
903 + *
567 904 * @return array I18n strings
568 905 */
569 906 private function get_i18n_strings(): array {
570 907 return [
@@ -575,10 +912,45 @@
575 912 'cancel' => __('Cancel', 'thinkrank'),
576 913 'save' => __('Save', 'thinkrank'),
577 914 ];
578 915 }
579 -
916 +
580 917 /**
918 + * Get all public post types for SEO configuration
919 + *
920 + * @return array Post types data
921 + */
922 + private function get_public_post_types(): array {
923 + // Get all public post types (both built-in and custom)
924 + $post_types = get_post_types([
925 + 'public' => true
926 + ], 'objects');
927 +
928 + $post_types_data = [];
929 +
930 + foreach ($post_types as $post_type) {
931 + // Shared eligibility policy (viewable + deny list) so the UI list and
932 + // the REST/ability write paths agree on which types are SEO targets.
933 + if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
934 + continue;
935 + }
936 +
937 + $post_types_data[] = [
938 + 'name' => $post_type->name,
939 + 'slug' => $post_type->name,
940 + 'label' => $post_type->label,
941 + 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
942 + 'plural_name' => $post_type->label,
943 + 'public' => $post_type->public,
944 + 'has_archive' => $post_type->has_archive,
945 + 'hierarchical' => $post_type->hierarchical,
946 + ];
947 + }
948 +
949 + return $post_types_data;
950 + }
951 +
952 + /**
581 953 * Add help tabs
582 954 *
583 955 * @return void
584 956 */
@@ -583,22 +955,22 @@
583 955 * @return void
584 956 */
585 957 private function add_help_tabs(): void {
586 958 $screen = get_current_screen();
587 -
959 +
588 960 $screen->add_help_tab([
589 961 'id' => 'thinkrank-overview',
590 962 'title' => __('Overview', 'thinkrank'),
591 963 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
592 964 ]);
593 -
965 +
594 966 $screen->set_help_sidebar(
595 967 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
596 - '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
597 - '<p><a href="https://thinkrank.ai/support" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
968 + '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
969 + '<p><a href="https://wpdeveloper.com/support/new-ticket/" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
598 970 );
599 971 }
600 -
972 +
601 973 /**
602 974 * Add screen options
603 975 *
604 976 * @return void
@@ -605,9 +977,9 @@
605 977 */
606 978 private function add_screen_options(): void {
607 979 // Screen options will be added as needed
608 980 }
609 -
981 +
610 982 /**
611 983 * Check for plugin updates
612 984 *
613 985 * @return void
@@ -614,8 +986,14 @@
614 986 */
615 987 private function check_plugin_updates(): void {
616 988 $current_version = get_option('thinkrank_version');
617 989
990 + if (false === $current_version) {
991 + // Fresh install or missing option — record version without firing the update hook.
992 + update_option('thinkrank_version', THINKRANK_VERSION);
993 + return;
994 + }
995 +
618 996 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
619 997 // Handle plugin update
620 998 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
621 999 update_option('thinkrank_version', THINKRANK_VERSION);
@@ -621,113 +999,30 @@
621 999 update_option('thinkrank_version', THINKRANK_VERSION);
622 1000 }
623 1001 }
624 1002
625 - /**
626 - * Get admin styles
627 - *
628 - * @return string CSS styles
629 - */
630 - private function get_admin_styles(): string {
631 - return '
632 - .thinkrank-loading {
633 - display: flex;
634 - flex-direction: column;
635 - align-items: center;
636 - justify-content: center;
637 - padding: 40px 20px;
638 - text-align: center;
639 - }
640 1003
641 - .thinkrank-loading .spinner {
642 - margin-bottom: 16px;
643 - }
1004 + public function remove_admin_notice() {
1005 + $current_screen = get_current_screen();
1006 + if ( in_array( $current_screen->id, [
1007 + 'toplevel_page_thinkrank',
1008 + 'thinkrank_page_thinkrank-essential-seo',
1009 + 'thinkrank_page_thinkrank-ai-tools',
1010 + 'thinkrank_page_thinkrank-settings',
1011 + 'thinkrank_page_thinkrank-usages',
1012 + 'thinkrank_page_thinkrank-license',
1013 + 'thinkrank_page_thinkrank-import-export',
1014 + 'thinkrank_page_thinkrank-migration'
1015 + ] , true) ) {
644 1016
645 - .thinkrank-loading p {
646 - margin: 0;
647 - color: #666;
648 - font-size: 14px;
649 - }
1017 + remove_all_actions( 'user_admin_notices' );
1018 + remove_all_actions( 'admin_notices' );
1019 + remove_all_actions( 'all_admin_notices' );
1020 + remove_all_actions( 'network_admin_notices' );
650 1021
651 - .thinkrank-app {
652 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
653 - }
654 -
655 - .thinkrank-header {
656 - display: flex;
657 - justify-content: space-between;
658 - align-items: center;
659 - padding: 20px 0;
660 - border-bottom: 1px solid #ddd;
661 - margin-bottom: 20px;
662 - }
663 -
664 - .thinkrank-header h1 {
665 - margin: 0;
666 - font-size: 24px;
667 - font-weight: 600;
668 - }
669 -
670 - .thinkrank-header .version {
671 - font-size: 12px;
672 - color: #666;
673 - font-weight: normal;
674 - margin-left: 8px;
675 - }
676 -
677 - .thinkrank-header .tagline {
678 - margin: 4px 0 0 0;
679 - color: #666;
680 - font-size: 14px;
681 - }
682 -
683 -
684 -
685 - .thinkrank-dashboard-stats {
686 - display: grid;
687 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
688 - gap: 20px;
689 - margin-top: 20px;
690 - }
691 -
692 - .stat-card {
693 - background: #f9f9f9;
694 - padding: 20px;
695 - border-radius: 8px;
696 - text-align: center;
697 - border: 1px solid #ddd;
698 - }
699 -
700 - .stat-card h3 {
701 - margin: 0 0 10px 0;
702 - font-size: 14px;
703 - color: #666;
704 - text-transform: uppercase;
705 - letter-spacing: 0.5px;
706 - }
707 -
708 - .stat-number {
709 - font-size: 32px;
710 - font-weight: 700;
711 - color: #0073aa;
712 - margin: 0;
713 - line-height: 1;
714 - }
715 -
716 - .stat-label {
717 - margin: 4px 0 0 0;
718 - font-size: 12px;
719 - color: #666;
720 - }
721 -
722 - .thinkrank-error {
723 - padding: 20px;
724 - }
725 -
726 - .thinkrank-error .notice {
727 - margin: 0;
728 - }
729 - ';
730 - }
731 -
732 -
1022 + // To showing notice in EA settings page we have to use 'eael_admin_notices' action hook
1023 + add_action( 'admin_notices', function () {
1024 + do_action( 'thinkrank_admin_notices' );
1025 + } );
1026 + }
1027 + }
733 1028 }