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

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