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

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