PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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
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.5.0, at includes/admin/class-manager.php

1,028 lines 37.3 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\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;
28
29 // Prevent direct access
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * Admin Manager Class
36 *
37 * Single Responsibility: Manage WordPress admin interface
38 *
39 * @since 1.0.0
40 */
41 class Manager {
42
43 /**
44 * Settings instance
45 *
46 * @var Settings
47 */
48 private Settings $settings;
49
50 /**
51 * Database instance
52 *
53 * @var Database
54 */
55 private Database $database;
56
57 /**
58 * Metabox manager instance
59 *
60 * @var Metabox_Manager
61 */
62 private Metabox_Manager $metabox_manager;
63
64 /**
65 * Elementor editor metabox integration instance
66 *
67 * @var Elementor_Metabox
68 */
69 private Elementor_Metabox $elementor_metabox;
70
71 /**
72 * Oxygen / Breakdance editor metabox integration instance
73 *
74 * @var Oxygen_Metabox
75 */
76 private Oxygen_Metabox $oxygen_metabox;
77
78 /**
79 * Divi Visual Builder metabox integration instance
80 *
81 * @var Divi_Metabox
82 */
83 private Divi_Metabox $divi_metabox;
84
85 /**
86 * Bricks builder metabox integration instance
87 *
88 * @var Bricks_Metabox
89 */
90 private Bricks_Metabox $bricks_metabox;
91
92 /**
93 * Beaver Builder metabox integration
94 *
95 * @var Beaver_Metabox
96 */
97 private Beaver_Metabox $beaver_metabox;
98
99 /**
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 /**
135 * Admin pages
136 *
137 * @var array
138 */
139 private array $pages = [];
140
141 /**
142 * Constructor
143 *
144 * @param Settings $settings Settings instance
145 * @param Database $database Database instance
146 */
147 public function __construct(?Settings $settings = null, ?Database $database = null) {
148 $this->settings = $settings ?? Settings::instance();
149 $this->database = $database ?? new Database();
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();
161 }
162
163 /**
164 * Initialize admin interface
165 *
166 * @return void
167 */
168 public function init(): void {
169 add_action('admin_menu', [$this, 'add_admin_menu']);
170 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
171 add_action('admin_init', [$this, 'handle_admin_init']);
172 add_action('admin_notices', [$this, 'show_admin_notices']);
173 add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
174 add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
175
176 // AJAX handlers
177 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
178
179 // Initialize metabox manager
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();
221 }
222
223 /**
224 * Add admin menu pages
225 *
226 * @return void
227 */
228 public function add_admin_menu(): void {
229 // Main menu page
230 $this->pages['dashboard'] = add_menu_page(
231 __('ThinkRank', 'thinkrank'),
232 __('ThinkRank', 'thinkrank'),
233 Capability_Manager::ACCESS,
234 'thinkrank',
235 [$this, 'render_dashboard_page'],
236 $this->get_menu_icon(),
237 30
238 );
239
240 // Dashboard submenu (same as main)
241 $this->pages['dashboard_sub'] = add_submenu_page(
242 'thinkrank',
243 __('Dashboard', 'thinkrank'),
244 __('Dashboard', 'thinkrank'),
245 Capability_Manager::ACCESS,
246 'thinkrank',
247 [$this, 'render_dashboard_page']
248 );
249
250 // Essential SEO page (React tabbed interface)
251 $this->pages['essential_seo'] = add_submenu_page(
252 'thinkrank',
253 __('Essential SEO', 'thinkrank'),
254 __('Essential SEO', 'thinkrank'),
255 Capability_Manager::ACCESS,
256 'thinkrank-essential-seo',
257 [$this, 'render_essential_seo_page']
258 );
259
260 // AI Tools page — gated by the AI Tools section capability.
261 $this->pages['ai_tools'] = add_submenu_page(
262 'thinkrank',
263 __('AI Tools', 'thinkrank'),
264 __('AI Tools', 'thinkrank'),
265 'thinkrank_content_tools',
266 'thinkrank-ai-tools',
267 [$this, 'render_ai_tools_page']
268 );
269
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.
281 $this->pages['settings'] = add_submenu_page(
282 'thinkrank',
283 __('Settings', 'thinkrank'),
284 __('Settings', 'thinkrank'),
285 'thinkrank_settings',
286 'thinkrank-settings',
287 [$this, 'render_settings_page']
288 );
289
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
325 // Hook for page-specific initialization
326 foreach ($this->pages as $page_hook) {
327 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
328 }
329 }
330
331 /**
332 * Enqueue admin scripts and styles
333 *
334 * APPROACH: Manual enqueuing with disabled webpack code splitting
335 * - All dependencies bundled into main admin.js (677KB)
336 * - Chart.js separated into charts.js (138KB) for performance
337 * - No dynamic chunks - predictable loading order
338 *
339 * @param string $hook_suffix Current admin page hook
340 * @return void
341 */
342 public function enqueue_admin_scripts(string $hook_suffix): void {
343 // Only load on our admin pages
344 if (!in_array($hook_suffix, $this->pages, true)) {
345 return;
346 }
347
348 // Get asset files for cache busting
349 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
350 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
351 'dependencies' => [],
352 'version' => THINKRANK_VERSION,
353 ];
354
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.
358 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
359 $should_enqueue_charts = true;
360
361 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
362 $charts_asset_data = include $charts_asset_file;
363 wp_enqueue_script(
364 'thinkrank-charts',
365 THINKRANK_PLUGIN_URL . 'assets/charts.js',
366 $charts_asset_data['dependencies'],
367 $charts_asset_data['version'],
368 true
369 );
370 // Add charts as dependency for admin script to ensure registration before use
371 $admin_dependencies = array_merge($admin_asset_data['dependencies'], ['thinkrank-charts']);
372 } else {
373 // Do not load charts on pages that don't need it
374 $admin_dependencies = $admin_asset_data['dependencies'];
375 }
376
377 wp_enqueue_script(
378 'thinkrank-admin',
379 THINKRANK_PLUGIN_URL . 'assets/admin.js',
380 $admin_dependencies,
381 $admin_asset_data['version'],
382 true
383 );
384
385 // Add defer attribute for better performance
386 wp_script_add_data('thinkrank-admin', 'defer', true);
387
388 // Enqueue admin styles
389 wp_enqueue_style(
390 'thinkrank-admin',
391 THINKRANK_PLUGIN_URL . 'assets/admin.css',
392 ['wp-components'],
393 $admin_asset_data['version']
394 );
395
396 // Enqueue WordPress media library for MediaPicker component
397 wp_enqueue_media();
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
420 // Localize script with data
421 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
422 'apiUrl' => rest_url('thinkrank/v1/'),
423 'restNonce' => wp_create_nonce('wp_rest'),
424 'adminNonce' => wp_create_nonce('thinkrank_admin'),
425 'currentUser' => wp_get_current_user()->ID,
426 'displayName' => wp_get_current_user()->display_name,
427 'capabilities' => $this->get_user_capabilities(),
428 'settings' => $this->get_admin_settings(),
429 'i18n' => $this->get_i18n_strings(),
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(),
438 // Plugin version - directly available without API call
439 'version' => THINKRANK_VERSION,
440 // Site information for default values
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'),
443 'siteUrl' => home_url(),
444 'faviconUrl' => get_site_icon_url(64) ?: '',
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 // Data update frequency (Pro: daily, Free: every 3 days)
457 'dataUpdateFrequency' => Plan_Config::is_pro() ? 'daily' : '3days',
458 // NB: no per-feature capability maps here. Email Reporting reads
459 // its capabilities from GET /thinkrank/v1/email-report/config;
460 // localizing a second copy only invited the two to drift.
461 // MCP (Model Context Protocol) connection details for the MCP page.
462 'mcp' => $this->get_mcp_globals(),
463 // Google OAuth: JS only ever gets a nonce-signed admin-post URL.
464 // The consent URL, client ID, and scopes are assembled by the proxy,
465 // so no Google app credentials reach the browser or the bundle.
466 'googleOAuth' => [
467 'connectUrl' => \ThinkRank\Integrations\Google_OAuth_Proxy::get_connect_url(),
468 // '' when fine, otherwise why re-authorization is needed:
469 // 'contract' (upgraded off the old token flow) or
470 // 'credentials' (stored tokens no longer decryptable).
471 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
472 ],
473 // Setup Wizard state — the dashboard Quick Access widget surfaces a
474 // "Complete Setup" shortcut while onboarding is unfinished.
475 'setupWizard' => [
476 'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false),
477 'url' => admin_url('admin.php?page=' . Setup_Wizard::PAGE_SLUG),
478 ],
479 ]);
480
481 }
482
483 /**
484 * Handle admin initialization
485 *
486 * @return void
487 */
488 public function handle_admin_init(): void {
489 // Show welcome screen for new installations (only if no API key configured)
490 if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
491 add_action('admin_notices', [$this, 'show_welcome_notice']);
492 }
493
494 // Check for plugin updates
495 $this->check_plugin_updates();
496 }
497
498 /**
499 * Load admin page
500 *
501 * @return void
502 */
503 public function load_admin_page(): void {
504 // Add screen options
505 $this->add_screen_options();
506 }
507
508 /**
509 * Render dashboard page
510 *
511 * @return void
512 */
513 public function render_dashboard_page(): void {
514 $this->render_admin_page('dashboard', [
515 'title' => __('ThinkRank Dashboard', 'thinkrank'),
516 'description' => __('AI-powered SEO optimization for WordPress', 'thinkrank'),
517 ]);
518 }
519
520 /**
521 * Render Essential SEO page
522 *
523 * @return void
524 */
525 public function render_essential_seo_page(): void {
526 $this->render_admin_page('essential-seo', [
527 'title' => __('Essential SEO', 'thinkrank'),
528 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
529 ]);
530 }
531
532 /**
533 * Render AI Tools page
534 *
535 * @return void
536 */
537 public function render_ai_tools_page(): void {
538 $this->render_admin_page('ai-tools', [
539 'title' => __('AI Tools', 'thinkrank'),
540 'description' => __('AI-powered content tools including Content Planner and Metadata Generator', 'thinkrank'),
541 ]);
542 }
543
544 /**
545 * Render settings page
546 *
547 * @return void
548 */
549 public function render_settings_page(): void {
550 $this->render_admin_page('settings', [
551 'title' => __('ThinkRank Settings', 'thinkrank'),
552 'description' => __('Configure your AI SEO settings', 'thinkrank'),
553 ]);
554 }
555
556 /**
557 * Build the MCP connection globals passed to the admin app.
558 *
559 * The MCP page fetches live connection state from the
560 * /thinkrank/v1/mcp/connection route; these globals only carry what the
561 * page needs before that request resolves (endpoint URLs and whether the
562 * bundled Abilities API — the tool catalog — is available).
563 *
564 * @return array<string, mixed>
565 */
566 private function get_mcp_globals(): array {
567 // The tool catalog is the abilities registry; wp_register_ability
568 // comes from the bundled Abilities API under dependencies/. When it's
569 // missing (bundle not built), the MCP server has no tools to serve.
570 $abilities_api_available = function_exists('wp_register_ability');
571
572 return [
573 'abilities_api_available' => $abilities_api_available,
574 'mcp_endpoint' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint(),
575 'mcp_endpoint_rest' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint_fallback(),
576 ];
577 }
578
579
580
581 /**
582 * Render usage analytics page
583 *
584 * @return void
585 */
586 public function render_analytics_page(): void {
587 $this->render_admin_page('analytics', [
588 'title' => __('Usage Analytics', 'thinkrank'),
589 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
590 ]);
591 }
592
593 /**
594 * Render the Import / Export page (ThinkRank's own data, out and back).
595 *
596 * Defense in depth: the submenu is only registered while the setting is on,
597 * but re-check here so a direct hit on the page URL cannot bypass the gate.
598 * The export REST routes carry their own `manage_options` check, so nothing
599 * is protected by the page alone.
600 *
601 * @return void
602 */
603 public function render_import_export_page(): void {
604 if (!(bool) Settings::instance()->get('enable_import_export', false)) {
605 wp_die(esc_html__('Import / Export is not enabled.', 'thinkrank'));
606 }
607
608 $this->render_admin_page('import-export', [
609 'page_title' => __('Import / Export', 'thinkrank'),
610 ]);
611 }
612
613 /**
614 * Render the Migration page (import SEO data from another plugin).
615 *
616 * Same defense in depth as above, against its own setting.
617 *
618 * @return void
619 */
620 public function render_migration_page(): void {
621 if (!(bool) Settings::instance()->get('enable_migration_tools', false)) {
622 wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank'));
623 }
624
625 $this->render_admin_page('migration', [
626 'page_title' => __('Migration', 'thinkrank'),
627 ]);
628 }
629
630 /**
631 * Render admin page template
632 *
633 * @param string $page Page identifier
634 * @param array $data Page data
635 * @return void
636 */
637 private function render_admin_page(string $page, array $data): void {
638 ?>
639 <div class="wrap">
640 <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
641 </div>
642 <?php
643 }
644
645 /**
646 * Add meta boxes to post edit screens
647 *
648 * @return void
649 */
650 public function add_meta_boxes(): void {
651 $post_types = get_post_types(['public' => true]);
652
653 foreach ($post_types as $post_type) {
654 add_meta_box(
655 'thinkrank-seo',
656 __('ThinkRank SEO', 'thinkrank'),
657 [$this, 'render_seo_meta_box'],
658 $post_type,
659 'normal',
660 'high'
661 );
662 }
663 }
664
665 /**
666 * Render SEO meta box
667 *
668 * @param \WP_Post $post Current post object
669 * @return void
670 */
671 public function render_seo_meta_box(\WP_Post $post): void {
672 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
673
674 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
675 echo '</div>';
676 }
677
678 /**
679 * Save meta box data
680 *
681 * @param int $post_id Post ID
682 * @return void
683 */
684 public function save_meta_boxes(int $post_id): void {
685 // Verify nonce
686 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
687 return;
688 }
689
690 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
691 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
692 return;
693 }
694
695 // Check permissions
696 if (!current_user_can('edit_post', $post_id)) {
697 return;
698 }
699
700 // Save meta data (handled by AJAX in React components)
701 // Pass only sanitized ThinkRank-related fields to action hook
702 $sanitized_data = [];
703 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above
704 foreach ($_POST as $key => $value) {
705 if (strpos($key, 'thinkrank_') === 0 || strpos($key, '_thinkrank_') === 0) {
706 $sanitized_data[$key] = is_array($value)
707 ? array_map('sanitize_text_field', wp_unslash($value))
708 : sanitize_text_field(wp_unslash($value));
709 }
710 }
711 do_action('thinkrank_save_post_meta', $post_id, $sanitized_data);
712 }
713
714 /**
715 * Show admin notices
716 *
717 * @return void
718 */
719 public function show_admin_notices(): void {
720 // Implementation will be added in next iteration
721 }
722
723 /**
724 * Show welcome notice
725 *
726 * @return void
727 */
728 public function show_welcome_notice(): void {
729 wp_enqueue_style(
730 'thinkrank-admin-notices',
731 THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
732 [],
733 THINKRANK_VERSION
734 );
735 ?>
736 <div class="notice notice-success is-dismissible thinkrank-notice thinkrank-welcome-notice">
737 <div class="thinkrank-notice__inner">
738 <div class="thinkrank-notice__body">
739 <p class="thinkrank-notice__title"><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></p>
740 <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>
741 <p class="thinkrank-notice__actions">
742 <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
743 <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
744 </a>
745 <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-welcome" data-nonce="<?php echo esc_attr(wp_create_nonce('thinkrank_admin')); ?>">
746 <?php esc_html_e('Dismiss', 'thinkrank'); ?>
747 </a>
748 </p>
749 </div>
750 </div>
751 </div>
752 <?php
753 // The notice renders on every admin screen, so the dismiss handler must
754 // ship with it — the thinkrank-admin bundle only loads on ThinkRank pages.
755 // Persist the dismissal for both our "Dismiss" link and core's × button.
756 wp_print_inline_script_tag(
757 '( function () {
758 document.addEventListener( "click", function ( event ) {
759 var notice = event.target.closest( ".thinkrank-welcome-notice" );
760 if ( ! notice ) {
761 return;
762 }
763 var link = event.target.closest( ".thinkrank-dismiss-welcome" );
764 if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
765 return;
766 }
767 if ( link ) {
768 event.preventDefault();
769 notice.style.display = "none";
770 }
771 window.fetch( window.ajaxurl, {
772 method: "POST",
773 credentials: "same-origin",
774 body: new URLSearchParams( {
775 action: "thinkrank_dismiss_notice",
776 notice_type: "welcome",
777 nonce: notice.querySelector( ".thinkrank-dismiss-welcome" ).dataset.nonce,
778 } ),
779 } );
780 } );
781 } )();'
782 );
783 }
784
785 /**
786 * Dismiss notice via AJAX
787 *
788 * @return void
789 */
790 public function dismiss_notice(): void {
791 check_ajax_referer('thinkrank_admin', 'nonce');
792
793 // The nonce proves intent, not authorization — dismissing a site-wide
794 // notice deletes an option, so require a capability as well.
795 if (!current_user_can('edit_posts')) {
796 wp_die(-1, 403);
797 }
798
799 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
800
801 if ($notice_type === 'welcome') {
802 delete_option('thinkrank_show_welcome');
803 }
804
805 wp_die();
806 }
807
808 /**
809 * Check if API key is configured
810 *
811 * @return bool True if at least one API key is configured
812 */
813 private function has_api_key_configured(): bool {
814 $settings = \ThinkRank\Core\Settings::instance();
815
816 return !empty($settings->get('openai_api_key'))
817 || !empty($settings->get('claude_api_key'))
818 || !empty($settings->get('gemini_api_key'))
819 || !empty($settings->get('openrouter_api_key'));
820 }
821
822 /**
823 * Get menu icon
824 *
825 * @return string Menu icon
826 */
827 private function get_menu_icon(): string {
828 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a data: URI for the menu icon must be base64.
829 return 'data:image/svg+xml;base64,' . base64_encode(
830 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
831 <g clipPath="url(#thinkrank-clip)">
832 <g filter="url(#thinkrank-shadow)">
833 <circle cx="14" cy="9.2" r="1.3" fill="#a7aaad"/>
834 </g>
835 <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"/>
836 </g>
837 <defs>
838 <filter id="thinkrank-shadow" x="11.2" y="7.2" width="5.6" height="5.6" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
839 <feFlood floodOpacity="0" result="BackgroundImageFix"/>
840 <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"/>
841 <feOffset dy="0.7"/>
842 <feGaussianBlur stdDeviation="0.7"/>
843 <feComposite in2="hardAlpha" operator="out"/>
844 <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"/>
845 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
846 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
847 </filter>
848 <clipPath id="thinkrank-clip">
849 <rect width="20" height="20" rx="5.5" fill="white"/>
850 </clipPath>
851 </defs>
852 </svg>'
853 );
854 }
855
856 /**
857 * Get user capabilities for current user
858 *
859 * @return array User capabilities
860 */
861 private function get_user_capabilities(): array {
862 return [
863 'manage_settings' => current_user_can('manage_options'),
864 'view_analytics' => current_user_can('edit_posts'),
865
866 'use_ai_features' => current_user_can('edit_posts'),
867 ];
868 }
869
870 /**
871 * Get admin settings for JavaScript
872 *
873 * @return array Admin settings
874 */
875 private function get_admin_settings(): array {
876 return [
877
878 'ai_provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
879 'cache_duration' => $this->settings->get('cache_duration', 3600),
880 ];
881 }
882
883 /**
884 * Whether any AI provider API key is configured
885 *
886 * Mirrors the check used by ThinkRank\AI\Manager.
887 *
888 * @return bool
889 */
890 private function is_ai_configured(): bool {
891 foreach (['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'] as $key) {
892 if (!empty($this->settings->get($key, ''))) {
893 return true;
894 }
895 }
896
897 return false;
898 }
899
900 /**
901 * Get internationalization strings
902 *
903 * @return array I18n strings
904 */
905 private function get_i18n_strings(): array {
906 return [
907 'loading' => __('Loading...', 'thinkrank'),
908 'error' => __('An error occurred', 'thinkrank'),
909 'success' => __('Success!', 'thinkrank'),
910 'confirm' => __('Are you sure?', 'thinkrank'),
911 'cancel' => __('Cancel', 'thinkrank'),
912 'save' => __('Save', 'thinkrank'),
913 ];
914 }
915
916 /**
917 * Get all public post types for SEO configuration
918 *
919 * @return array Post types data
920 */
921 private function get_public_post_types(): array {
922 // Get all public post types (both built-in and custom)
923 $post_types = get_post_types([
924 'public' => true
925 ], 'objects');
926
927 $post_types_data = [];
928
929 foreach ($post_types as $post_type) {
930 // Shared eligibility policy (viewable + deny list) so the UI list and
931 // the REST/ability write paths agree on which types are SEO targets.
932 if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
933 continue;
934 }
935
936 $post_types_data[] = [
937 'name' => $post_type->name,
938 'slug' => $post_type->name,
939 'label' => $post_type->label,
940 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
941 'plural_name' => $post_type->label,
942 'public' => $post_type->public,
943 'has_archive' => $post_type->has_archive,
944 'hierarchical' => $post_type->hierarchical,
945 ];
946 }
947
948 return $post_types_data;
949 }
950
951 /**
952 * Add help tabs
953 *
954 * @return void
955 */
956 private function add_help_tabs(): void {
957 $screen = get_current_screen();
958
959 $screen->add_help_tab([
960 'id' => 'thinkrank-overview',
961 'title' => __('Overview', 'thinkrank'),
962 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
963 ]);
964
965 $screen->set_help_sidebar(
966 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
967 '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
968 '<p><a href="https://wpdeveloper.com/support/new-ticket/" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
969 );
970 }
971
972 /**
973 * Add screen options
974 *
975 * @return void
976 */
977 private function add_screen_options(): void {
978 // Screen options will be added as needed
979 }
980
981 /**
982 * Check for plugin updates
983 *
984 * @return void
985 */
986 private function check_plugin_updates(): void {
987 $current_version = get_option('thinkrank_version');
988
989 if (false === $current_version) {
990 // Fresh install or missing option — record version without firing the update hook.
991 update_option('thinkrank_version', THINKRANK_VERSION);
992 return;
993 }
994
995 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
996 // Handle plugin update
997 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
998 update_option('thinkrank_version', THINKRANK_VERSION);
999 }
1000 }
1001
1002
1003 public function remove_admin_notice() {
1004 $current_screen = get_current_screen();
1005 if ( in_array( $current_screen->id, [
1006 'toplevel_page_thinkrank',
1007 'thinkrank_page_thinkrank-essential-seo',
1008 'thinkrank_page_thinkrank-ai-tools',
1009 'thinkrank_page_thinkrank-settings',
1010 'thinkrank_page_thinkrank-usages',
1011 'thinkrank_page_thinkrank-license',
1012 'thinkrank_page_thinkrank-import-export',
1013 'thinkrank_page_thinkrank-migration'
1014 ] , true) ) {
1015
1016 remove_all_actions( 'user_admin_notices' );
1017 remove_all_actions( 'admin_notices' );
1018 remove_all_actions( 'all_admin_notices' );
1019 remove_all_actions( 'network_admin_notices' );
1020
1021 // To showing notice in EA settings page we have to use 'eael_admin_notices' action hook
1022 add_action( 'admin_notices', function () {
1023 do_action( 'thinkrank_admin_notices' );
1024 } );
1025 }
1026 }
1027 }
1028