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
thinkrank / includes / admin / class-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.11.0, at includes/admin/class-manager.php

1,029 lines 37.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Admin Manager Class
5 *
6 * Handles WordPress admin interface integration
7 *
8 * @package ThinkRank\Admin
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin;
15
16 use ThinkRank\Core\Settings;
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;
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;
29
30 // Prevent direct access
31 if (!defined('ABSPATH')) {
32 exit;
33 }
34
35 /**
36 * Admin Manager Class
37 *
38 * Single Responsibility: Manage WordPress admin interface
39 *
40 * @since 1.0.0
41 */
42 class Manager {
43
44 /**
45 * Settings instance
46 *
47 * @var Settings
48 */
49 private Settings $settings;
50
51 /**
52 * Database instance
53 *
54 * @var Database
55 */
56 private Database $database;
57
58 /**
59 * Metabox manager instance
60 *
61 * @var Metabox_Manager
62 */
63 private Metabox_Manager $metabox_manager;
64
65 /**
66 * Elementor editor metabox integration instance
67 *
68 * @var Elementor_Metabox
69 */
70 private Elementor_Metabox $elementor_metabox;
71
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 /**
136 * Admin pages
137 *
138 * @var array
139 */
140 private array $pages = [];
141
142 /**
143 * Constructor
144 *
145 * @param Settings $settings Settings instance
146 * @param Database $database Database instance
147 */
148 public function __construct(?Settings $settings = null, ?Database $database = null) {
149 $this->settings = $settings ?? Settings::instance();
150 $this->database = $database ?? new Database();
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();
162 }
163
164 /**
165 * Initialize admin interface
166 *
167 * @return void
168 */
169 public function init(): void {
170 add_action('admin_menu', [$this, 'add_admin_menu']);
171 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
172 add_action('admin_init', [$this, 'handle_admin_init']);
173 add_action('admin_notices', [$this, 'show_admin_notices']);
174 add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
175 add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
176
177 // AJAX handlers
178 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
179
180 // Initialize metabox manager
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();
222 }
223
224 /**
225 * Add admin menu pages
226 *
227 * @return void
228 */
229 public function add_admin_menu(): void {
230 // Main menu page
231 $this->pages['dashboard'] = add_menu_page(
232 __('ThinkRank', 'thinkrank'),
233 __('ThinkRank', 'thinkrank'),
234 Capability_Manager::ACCESS,
235 'thinkrank',
236 [$this, 'render_dashboard_page'],
237 $this->get_menu_icon(),
238 30
239 );
240
241 // Dashboard submenu (same as main)
242 $this->pages['dashboard_sub'] = add_submenu_page(
243 'thinkrank',
244 __('Dashboard', 'thinkrank'),
245 __('Dashboard', 'thinkrank'),
246 Capability_Manager::ACCESS,
247 'thinkrank',
248 [$this, 'render_dashboard_page']
249 );
250
251 // Essential SEO page (React tabbed interface)
252 $this->pages['essential_seo'] = add_submenu_page(
253 'thinkrank',
254 __('Essential SEO', 'thinkrank'),
255 __('Essential SEO', 'thinkrank'),
256 Capability_Manager::ACCESS,
257 'thinkrank-essential-seo',
258 [$this, 'render_essential_seo_page']
259 );
260
261 // AI Tools page — gated by the AI Tools section capability.
262 $this->pages['ai_tools'] = add_submenu_page(
263 'thinkrank',
264 __('AI Tools', 'thinkrank'),
265 __('AI Tools', 'thinkrank'),
266 'thinkrank_content_tools',
267 'thinkrank-ai-tools',
268 [$this, 'render_ai_tools_page']
269 );
270
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.
282 $this->pages['settings'] = add_submenu_page(
283 'thinkrank',
284 __('Settings', 'thinkrank'),
285 __('Settings', 'thinkrank'),
286 'thinkrank_settings',
287 'thinkrank-settings',
288 [$this, 'render_settings_page']
289 );
290
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
326 // Hook for page-specific initialization
327 foreach ($this->pages as $page_hook) {
328 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
329 }
330 }
331
332 /**
333 * Enqueue admin scripts and styles
334 *
335 * APPROACH: Manual enqueuing with disabled webpack code splitting
336 * - All dependencies bundled into main admin.js (677KB)
337 * - Chart.js separated into charts.js (138KB) for performance
338 * - No dynamic chunks - predictable loading order
339 *
340 * @param string $hook_suffix Current admin page hook
341 * @return void
342 */
343 public function enqueue_admin_scripts(string $hook_suffix): void {
344 // Only load on our admin pages
345 if (!in_array($hook_suffix, $this->pages, true)) {
346 return;
347 }
348
349 // Get asset files for cache busting
350 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
351 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
352 'dependencies' => [],
353 'version' => THINKRANK_VERSION,
354 ];
355
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.
359 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
360 $should_enqueue_charts = true;
361
362 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
363 $charts_asset_data = include $charts_asset_file;
364 wp_enqueue_script(
365 'thinkrank-charts',
366 THINKRANK_PLUGIN_URL . 'assets/charts.js',
367 $charts_asset_data['dependencies'],
368 $charts_asset_data['version'],
369 true
370 );
371 // Add charts as dependency for admin script to ensure registration before use
372 $admin_dependencies = array_merge($admin_asset_data['dependencies'], ['thinkrank-charts']);
373 } else {
374 // Do not load charts on pages that don't need it
375 $admin_dependencies = $admin_asset_data['dependencies'];
376 }
377
378 wp_enqueue_script(
379 'thinkrank-admin',
380 THINKRANK_PLUGIN_URL . 'assets/admin.js',
381 $admin_dependencies,
382 $admin_asset_data['version'],
383 true
384 );
385
386 // Add defer attribute for better performance
387 wp_script_add_data('thinkrank-admin', 'defer', true);
388
389 // Enqueue admin styles
390 wp_enqueue_style(
391 'thinkrank-admin',
392 THINKRANK_PLUGIN_URL . 'assets/admin.css',
393 ['wp-components'],
394 $admin_asset_data['version']
395 );
396
397 // Enqueue WordPress media library for MediaPicker component
398 wp_enqueue_media();
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
421 // Localize script with data
422 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
423 'apiUrl' => rest_url('thinkrank/v1/'),
424 'restNonce' => wp_create_nonce('wp_rest'),
425 'adminNonce' => wp_create_nonce('thinkrank_admin'),
426 'currentUser' => wp_get_current_user()->ID,
427 'displayName' => wp_get_current_user()->display_name,
428 'capabilities' => $this->get_user_capabilities(),
429 'settings' => $this->get_admin_settings(),
430 'i18n' => $this->get_i18n_strings(),
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,
444 // Site information for default values
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'),
447 'siteUrl' => home_url(),
448 'faviconUrl' => get_site_icon_url(64) ?: '',
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 ],
485 ]);
486
487 }
488
489 /**
490 * Handle admin initialization
491 *
492 * @return void
493 */
494 public function handle_admin_init(): void {
495 // Show welcome screen for new installations (only if no API key configured)
496 if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
497 add_action('admin_notices', [$this, 'show_welcome_notice']);
498 }
499
500 // Check for plugin updates
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();
507 }
508
509 /**
510 * Load admin page
511 *
512 * @return void
513 */
514 public function load_admin_page(): void {
515 // Add screen options
516 $this->add_screen_options();
517 }
518
519 /**
520 * Render dashboard page
521 *
522 * @return void
523 */
524 public function render_dashboard_page(): void {
525 $this->render_admin_page('dashboard', [
526 'title' => __('ThinkRank Dashboard', 'thinkrank'),
527 'description' => __('AI-powered SEO optimization for WordPress', 'thinkrank'),
528 ]);
529 }
530
531 /**
532 * Render Essential SEO page
533 *
534 * @return void
535 */
536 public function render_essential_seo_page(): void {
537 $this->render_admin_page('essential-seo', [
538 'title' => __('Essential SEO', 'thinkrank'),
539 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
540 ]);
541 }
542
543 /**
544 * Render AI Tools page
545 *
546 * @return void
547 */
548 public function render_ai_tools_page(): void {
549 $this->render_admin_page('ai-tools', [
550 'title' => __('AI Tools', 'thinkrank'),
551 'description' => __('AI-powered content tools including Content Planner and Metadata Generator', 'thinkrank'),
552 ]);
553 }
554
555 /**
556 * Render settings page
557 *
558 * @return void
559 */
560 public function render_settings_page(): void {
561 $this->render_admin_page('settings', [
562 'title' => __('ThinkRank Settings', 'thinkrank'),
563 'description' => __('Configure your AI SEO settings', 'thinkrank'),
564 ]);
565 }
566
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 /**
593 * Render usage analytics page
594 *
595 * @return void
596 */
597 public function render_analytics_page(): void {
598 $this->render_admin_page('analytics', [
599 'title' => __('Usage Analytics', 'thinkrank'),
600 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
601 ]);
602 }
603
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 /**
642 * Render admin page template
643 *
644 * @param string $page Page identifier
645 * @param array $data Page data
646 * @return void
647 */
648 private function render_admin_page(string $page, array $data): void {
649 ?>
650 <div class="wrap">
651 <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
652 </div>
653 <?php
654 }
655
656 /**
657 * Add meta boxes to post edit screens
658 *
659 * @return void
660 */
661 public function add_meta_boxes(): void {
662 $post_types = get_post_types(['public' => true]);
663
664 foreach ($post_types as $post_type) {
665 add_meta_box(
666 'thinkrank-seo',
667 __('ThinkRank SEO', 'thinkrank'),
668 [$this, 'render_seo_meta_box'],
669 $post_type,
670 'normal',
671 'high'
672 );
673 }
674 }
675
676 /**
677 * Render SEO meta box
678 *
679 * @param \WP_Post $post Current post object
680 * @return void
681 */
682 public function render_seo_meta_box(\WP_Post $post): void {
683 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
684
685 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
686 echo '</div>';
687 }
688
689 /**
690 * Save meta box data
691 *
692 * @param int $post_id Post ID
693 * @return void
694 */
695 public function save_meta_boxes(int $post_id): void {
696 // Verify nonce
697 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
698 return;
699 }
700
701 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
702 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
703 return;
704 }
705
706 // Check permissions
707 if (!current_user_can('edit_post', $post_id)) {
708 return;
709 }
710
711 // Save meta data (handled by AJAX in React components)
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);
723 }
724
725 /**
726 * Show admin notices
727 *
728 * @return void
729 */
730 public function show_admin_notices(): void {
731 // Implementation will be added in next iteration
732 }
733
734 /**
735 * Show welcome notice
736 *
737 * @return void
738 */
739 public function show_welcome_notice(): void {
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>
762 </div>
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 );
794 }
795
796 /**
797 * Dismiss notice via AJAX
798 *
799 * @return void
800 */
801 public function dismiss_notice(): void {
802 check_ajax_referer('thinkrank_admin', 'nonce');
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
810 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
811
812 if ($notice_type === 'welcome') {
813 delete_option('thinkrank_show_welcome');
814 }
815
816 wp_die();
817 }
818
819 /**
820 * Check if the selected AI provider is configured
821 *
822 * @return bool True when the chosen provider has what it needs to run
823 */
824 private function has_api_key_configured(): bool {
825 return \ThinkRank\Core\Settings::instance()->has_ai_provider_configured();
826 }
827
828 /**
829 * Get menu icon
830 *
831 * @return string Menu icon
832 */
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.
835 return 'data:image/svg+xml;base64,' . base64_encode(
836 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
837 <g clipPath="url(#thinkrank-clip)">
838 <g filter="url(#thinkrank-shadow)">
839 <circle cx="14" cy="9.2" r="1.3" fill="#a7aaad"/>
840 </g>
841 <path d="M19.5 7v5.3c0 2.4 0 3.6-.5 4.5-.4.8-1 1.4-1.8 1.8-.9.5-2.1.5-4.5.5H7.3c-2.4 0-3.6 0-4.5-.5-.8-.4-1.4-1-1.8-1.8-.2-.3-.3-.7-.4-1.1.5-.4.9-.6 1.3-.7.5-.2.9-.2 1.4-.2.7.1 1.3.2 2 .3.7.1 1.4.2 2.1.1 1.5-.3 2.5-1 3.4-2 .5-.5.9-1 1.4-1.5.3-.3.6-.7.9-1 .3.1.6.2.9.2.9 0 1.7-.8 1.7-1.7 0-.2 0-.4-.1-.6.7-.4 1.4-.8 2.1-1.1.6-.3 1.2-.6 1.6-.8v.4zM12.5 0c2.4 0 3.6 0 4.5.5.8.4 1.4 1 1.8 1.8.4.7.5 1.6.5 3.1-.1 0-.2.1-.3.1-.5.2-1.1.5-1.8.8-.7.3-1.5.7-2.2 1.1-.3-.3-.7-.4-1.1-.4-.9 0-1.7.8-1.7 1.7 0 .3.1.6.3.9-.3.4-.6.7-.9 1-.5.6-.9 1.1-1.3 1.5-.9.9-1.8 1.5-3 1.7-.6.1-1.2.1-1.8 0-.6-.1-1.3-.3-2-.4-.6-.1-1.2-.1-1.8.1-.3.1-.7.3-1 .5 0-.6 0-1.4 0-2.3V7c0-2.4 0-3.6.5-4.5.4-.8 1-1.4 1.8-1.8C3.9 0 5.1 0 7.5 0h5zm-5.8 8.2c0-.1-.1-.1-.2 0l-.2.9c0 0 0 .1-.1.1l-.9.2c-.1 0-.1.1 0 .1l.9.2c0 0 .1 0 .1.1l.2.9c0 .1.1.1.2 0l.2-.9c0 0 0-.1.1-.1l.9-.2c.1 0 .1-.1 0-.1l-.9-.2c0 0-.1 0-.1-.1l-.2-.9zm8.8-.4c0 .1 0 .2 0 .3 0 .7-.6 1.3-1.3 1.3-.2 0-.4 0-.5-.1.1-.1.2-.2.3-.3.4-.4.9-.8 1.5-1.2zm-1.3-1c.3 0 .5.1.7.2-.6.4-1.1.8-1.5 1.2l-.1.1c-.1.1-.2.2-.3.3-.1-.2-.1-.4-.1-.6 0-.7.6-1.2 1.3-1.2zM8.6 2.5c-.1-.2-.4-.2-.4 0l-.4 1.4c0 .1-.1.1-.1.1L6.2 4.4c-.2.1-.2.4 0 .4l1.4.4c.1 0 .1.1.1.1l.4 1.4c.1.2.4.2.4 0l.4-1.4c0-.1.1-.1.1-.1l1.4-.4c.2-.1.2-.4 0-.4L8.9 4c-.1 0-.1-.1-.1-.1L8.6 2.5z" fill="#a7aaad"/>
842 </g>
843 <defs>
844 <filter id="thinkrank-shadow" x="11.2" y="7.2" width="5.6" height="5.6" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
845 <feFlood floodOpacity="0" result="BackgroundImageFix"/>
846 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
847 <feOffset dy="0.7"/>
848 <feGaussianBlur stdDeviation="0.7"/>
849 <feComposite in2="hardAlpha" operator="out"/>
850 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
851 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
852 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
853 </filter>
854 <clipPath id="thinkrank-clip">
855 <rect width="20" height="20" rx="5.5" fill="white"/>
856 </clipPath>
857 </defs>
858 </svg>'
859 );
860 }
861
862 /**
863 * Get user capabilities for current user
864 *
865 * @return array User capabilities
866 */
867 private function get_user_capabilities(): array {
868 return [
869 'manage_settings' => current_user_can('manage_options'),
870 'view_analytics' => current_user_can('edit_posts'),
871
872 'use_ai_features' => current_user_can('edit_posts'),
873 ];
874 }
875
876 /**
877 * Get admin settings for JavaScript
878 *
879 * @return array Admin settings
880 */
881 private function get_admin_settings(): array {
882 return [
883
884 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
885 'cache_duration' => $this->settings->get('cache_duration', 3600),
886 ];
887 }
888
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 /**
902 * Get internationalization strings
903 *
904 * @return array I18n strings
905 */
906 private function get_i18n_strings(): array {
907 return [
908 'loading' => __('Loading...', 'thinkrank'),
909 'error' => __('An error occurred', 'thinkrank'),
910 'success' => __('Success!', 'thinkrank'),
911 'confirm' => __('Are you sure?', 'thinkrank'),
912 'cancel' => __('Cancel', 'thinkrank'),
913 'save' => __('Save', 'thinkrank'),
914 ];
915 }
916
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 /**
953 * Add help tabs
954 *
955 * @return void
956 */
957 private function add_help_tabs(): void {
958 $screen = get_current_screen();
959
960 $screen->add_help_tab([
961 'id' => 'thinkrank-overview',
962 'title' => __('Overview', 'thinkrank'),
963 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
964 ]);
965
966 $screen->set_help_sidebar(
967 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></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>'
970 );
971 }
972
973 /**
974 * Add screen options
975 *
976 * @return void
977 */
978 private function add_screen_options(): void {
979 // Screen options will be added as needed
980 }
981
982 /**
983 * Check for plugin updates
984 *
985 * @return void
986 */
987 private function check_plugin_updates(): void {
988 $current_version = get_option('thinkrank_version');
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
996 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
997 // Handle plugin update
998 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
999 update_option('thinkrank_version', THINKRANK_VERSION);
1000 }
1001 }
1002
1003
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) ) {
1016
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' );
1021
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 }
1028 }
1029