# thinkrank/1.0.2/includes/admin/class-metabox-manager.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 1.0.2. 632 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/1.0.2/code/includes/admin/class-metabox-manager.php
- Raw: https://pluginprobe.com/plugins/thinkrank/1.0.2/raw/includes/admin/class-metabox-manager.php
- Modified: 2025-08-10T07:35:04+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/1.0.2/code/includes/admin/class-metabox-manager.php#L10-L20`.

```php
<?php
/**
 * Meta Box Manager
 * 
 * Handles ThinkRank meta boxes in post/page edit screens
 * 
 * @package ThinkRank\Admin
 * @since 1.0.0
 */

declare(strict_types=1);

namespace ThinkRank\Admin;

use ThinkRank\AI\Metadata_Generator;
use ThinkRank\AI\SEOScoreCalculator;
use ThinkRank\Core\Settings;
use ThinkRank\Core\Database;

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Meta Box Manager Class
 * 
 * Single Responsibility: Manage post/page meta boxes
 * 
 * @since 1.0.0
 */
class Metabox_Manager {
    
    /**
     * Settings instance
     * 
     * @var Settings
     */
    private Settings $settings;
    
    /**
     * Metadata generator instance
     *
     * @var Metadata_Generator
     */
    private Metadata_Generator $metadata_generator;

    /**
     * SEO Score Calculator instance
     *
     * @var SEOScoreCalculator
     */
    private SEOScoreCalculator $seo_calculator;
    
    /**
     * Constructor
     *
     * @param Settings|null $settings Settings instance
     * @param Metadata_Generator|null $metadata_generator Metadata generator instance
     * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
     */
    public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
        $this->settings = $settings ?? new Settings();
        $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
        $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
    }
    
    /**
     * Initialize meta box manager
     * 
     * @return void
     */
    public function init(): void {
        add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
        add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
        add_action('init', [$this, 'register_meta_fields']);

        // AJAX handlers for meta box functionality
        add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
        add_action('wp_ajax_thinkrank_save_post_metadata', [$this, 'ajax_save_post_metadata']);

        // Removed debug hooks
    }

    /**
     * Register meta fields for REST API access
     *
     * @return void
     */
    public function register_meta_fields(): void {
        // Register schema form data meta fields
        register_post_meta('', '_thinkrank_schema_form_data', [
            'show_in_rest' => true,
            'single' => true,
            'type' => 'string',
            'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
            'auth_callback' => function() {
                return current_user_can('edit_posts') || current_user_can('edit_pages');
            }
        ]);

        register_post_meta('', '_thinkrank_selected_schema_type', [
            'show_in_rest' => true,
            'single' => true,
            'type' => 'string',
            'auth_callback' => function() {
                return current_user_can('edit_posts') || current_user_can('edit_pages');
            }
        ]);
    }

    /**
     * Sanitize JSON meta field data
     *
     * Validates JSON structure and recursively sanitizes all string values
     * to prevent XSS and injection attacks.
     *
     * @param string $value Raw JSON string value
     * @return string Sanitized JSON string or empty string if invalid
     */
    public function sanitize_json_meta_field(string $value): string {
        // Return empty string for non-string values
        if (!is_string($value) || empty($value)) {
            return '';
        }

        // Validate JSON structure
        $decoded = json_decode($value, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            // Invalid JSON - return empty string
            return '';
        }

        // Check for reasonable data size (prevent JSON bombs)
        if (strlen($value) > 50000) { // 50KB limit
            return '';
        }

        // Recursively sanitize all values
        $sanitized = $this->sanitize_json_recursively($decoded);

        // Re-encode as JSON
        $result = wp_json_encode($sanitized);
        return $result !== false ? $result : '';
    }

    /**
     * Recursively sanitize JSON data
     *
     * @param mixed $data Data to sanitize
     * @param int $depth Current recursion depth
     * @return mixed Sanitized data
     */
    private function sanitize_json_recursively($data, int $depth = 0): mixed {
        // Prevent deep recursion attacks
        if ($depth > 10) {
            return null;
        }

        if (is_array($data)) {
            $sanitized = [];
            foreach ($data as $key => $value) {
                $clean_key = sanitize_key($key);
                $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1);
            }
            return $sanitized;
        }

        if (is_string($data)) {
            // Sanitize string data to prevent XSS
            return sanitize_textarea_field($data);
        }

        if (is_numeric($data)) {
            return $data;
        }

        if (is_bool($data)) {
            return $data;
        }

        // For any other data type, return null
        return null;
    }

    // Removed debug methods

    /**
     * Add ThinkRank meta boxes
     *
     * @return void
     */
    public function add_meta_boxes(): void {
        $post_types = $this->get_supported_post_types();
        
        foreach ($post_types as $post_type) {
            add_meta_box(
                'thinkrank-seo-metabox',
                __('ThinkRank SEO', 'thinkrank'),
                [$this, 'render_seo_metabox'],
                $post_type,
                'normal',
                'high'
            );
        }
    }
    
    /**
     * Render SEO meta box
     * 
     * @param \WP_Post $post Post object
     * @return void
     */
    public function render_seo_metabox(\WP_Post $post): void {
        // Add nonce for security
        wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce');

        // Get existing metadata
        $existing_metadata = $this->get_post_metadata($post->ID);

        // Get post content for AI analysis
        $content_preview = $this->get_content_preview($post);

        // Render React metabox container with hidden form fields for data
        ?>
        <div id="thinkrank-metabox-container" class="thinkrank-metabox">
            <div class="thinkrank-loading">
                <span class="spinner is-active"></span>
                <p><?php esc_html_e('Loading ThinkRank metabox...', 'thinkrank'); ?></p>
            </div>

            <!-- Hidden form fields for React to read initial data -->
            <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
            <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
            <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
            <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
            <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
            <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
        </div>
        <?php

    }

    /**
     * Save meta box data
     *
     * @param int $post_id Post ID
     * @param \WP_Post $post Post object
     * @return void
     */
    public function save_meta_boxes(int $post_id, \WP_Post $post): void {
        // Verify nonce
        if (!isset($_POST['thinkrank_metabox_nonce'])) {
            return;
        }
        
        $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
        if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
            return;
        }

        // Check permissions
        if (!current_user_can('edit_post', $post_id)) {
            return;
        }

        // Skip autosave
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        // Save metadata
        $fields = [
            'thinkrank_seo_title' => 'sanitize_text_field',
            'thinkrank_meta_description' => 'sanitize_textarea_field',
            'thinkrank_focus_keyword' => 'sanitize_text_field',
            'thinkrank_seo_score' => 'absint',
            'thinkrank_generated_at' => 'sanitize_text_field',
        ];

        foreach ($fields as $field => $sanitize_callback) {
            if (isset($_POST[$field])) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized via callback below
                $raw_value = wp_unslash($_POST[$field]);
                $value = call_user_func($sanitize_callback, $raw_value);
                update_post_meta($post_id, "_{$field}", $value);
            }
        }

        // Update last modified timestamp
        update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
    }

    
    /**
     * Enqueue meta box scripts
     *
     * @param string $hook Current admin page hook
     * @return void
     */
    public function enqueue_metabox_scripts(string $hook): void {
        // Only load on post edit screens (including block editor)
        if (!in_array($hook, ['post.php', 'post-new.php'])) {
            return;
        }

        // Get current post type - handle both classic and block editor contexts
        $current_post_type = $this->get_current_post_type();
        if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types())) {
            return;
        }

        // Get post object for additional data
        global $post;
        
        // No chunk dependencies needed - all bundled into main metabox.js
        // Enqueue React metabox script with direct dependencies
        $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
        $asset = file_exists($asset_file) ? include $asset_file : [
            'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
            'version' => THINKRANK_VERSION
        ];

        // Use dependencies directly from asset file
        $dependencies = $asset['dependencies'];

        wp_enqueue_script(
            'thinkrank-metabox',
            THINKRANK_PLUGIN_URL . 'assets/metabox.js',
            $dependencies,
            $asset['version'],
            true
        );
        
        // Localize script data
        wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
            'postId' => $post->ID,
            'postType' => $post->post_type,
            'postPermalink' => get_permalink($post->ID),
            'restUrl' => rest_url('thinkrank/v1/'),
            'restNonce' => wp_create_nonce('wp_rest'),
            'homeUrl' => home_url(),
            'siteName' => get_bloginfo('name'),
            'faviconUrl' => $this->get_site_favicon_url(),
            'featuredImageUrl' => $this->get_post_featured_image_url($post->ID),
            'strings' => [
                'generating' => __('Generating...', 'thinkrank'),
                'analyzing' => __('Analyzing...', 'thinkrank'),
                'error' => __('Error occurred', 'thinkrank'),
                'success' => __('Success!', 'thinkrank'),
                'generated' => __('Metadata generated successfully', 'thinkrank'),
                'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
                'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
            ],
        ]);
        
        // Enqueue metabox styles
        wp_enqueue_style(
            'thinkrank-metabox',
            THINKRANK_PLUGIN_URL . 'assets/metabox.css',
            [],
            THINKRANK_VERSION
        );


    }
    
    /**
     * Get current post type in admin context
     *
     * Handles both classic editor and block editor contexts
     *
     * @return string|null Current post type or null if not found
     */
    private function get_current_post_type(): ?string {
        global $post, $typenow, $current_screen;

        // Try to get post type from various sources
        if ($post && !empty($post->post_type)) {
            return $post->post_type;
        }

        if (!empty($typenow)) {
            return $typenow;
        }

        if ($current_screen && !empty($current_screen->post_type)) {
            return $current_screen->post_type;
        }

        // Fallback: check URL parameters for block editor
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
        if (isset($_GET['post_type'])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
            return sanitize_text_field(wp_unslash($_GET['post_type']));
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
        if (isset($_GET['post'])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
            $post_id = absint($_GET['post']);
            $post_type = get_post_type($post_id);
            if ($post_type) {
                return $post_type;
            }
        }

        return null;
    }

    /**
     * Get supported post types
     *
     * @return array Supported post types
     */
    private function get_supported_post_types(): array {
        $default_types = ['post', 'page'];

        // Add WooCommerce product if available
        if (class_exists('WooCommerce')) {
            $default_types[] = 'product';
        }

        // Add other common e-commerce post types
        $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
        foreach ($ecommerce_types as $type) {
            if (post_type_exists($type) && !in_array($type, $default_types)) {
                $default_types[] = $type;
            }
        }

        // Add custom post types that are public and have UI
        $custom_post_types = get_post_types([
            'public' => true,
            'show_ui' => true,
            '_builtin' => false,
        ]);

        foreach ($custom_post_types as $post_type) {
            // Skip certain post types that shouldn't have SEO metabox
            $excluded_types = [
                'attachment',
                'revision',
                'nav_menu_item',
                'custom_css',
                'customize_changeset',
                'oembed_cache',
                'user_request',
                'wp_block',
                'wp_template',
                'wp_template_part',
                'wp_global_styles',
                'wp_navigation',
                'acf-field',
                'acf-field-group',
            ];

            if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) {
                $default_types[] = $post_type;
            }
        }

        return apply_filters('thinkrank_supported_post_types', $default_types);
    }
    
    /**
     * Get existing post metadata
     *
     * @param int $post_id Post ID
     * @return array Existing metadata
     */
    private function get_post_metadata(int $post_id): array {
        return [
            'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
            'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
            'focus_keyword' => get_post_meta($post_id, '_thinkrank_focus_keyword', true),
            'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
            'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
        ];
    }

    /**
     * Get site favicon URL
     *
     * @return string Favicon URL
     */
    private function get_site_favicon_url(): string {
        // Try to get site icon first (WordPress 4.3+)
        $site_icon_id = get_option('site_icon');
        if ($site_icon_id) {
            $site_icon_url = wp_get_attachment_image_url($site_icon_id, 'full');
            if ($site_icon_url) {
                return $site_icon_url;
            }
        }

        // Fallback to common favicon locations
        $favicon_paths = [
            '/favicon.ico',
            '/favicon.png',
            '/apple-touch-icon.png',
        ];

        foreach ($favicon_paths as $path) {
            $favicon_url = home_url($path);
            $response = wp_remote_head($favicon_url);
            if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
                return $favicon_url;
            }
        }

        // Final fallback - return a default favicon URL
        return home_url('/favicon.ico');
    }

    /**
     * Get post featured image URL
     *
     * @param int $post_id Post ID
     * @return string|null Featured image URL or null if not available
     */
    private function get_post_featured_image_url(int $post_id): ?string {
        $thumbnail_id = get_post_thumbnail_id($post_id);
        if ($thumbnail_id) {
            $image_url = wp_get_attachment_image_url($thumbnail_id, 'medium');
            return $image_url ?: null;
        }
        return null;
    }
    
    /**
     * Get content preview for AI analysis
     * 
     * @param \WP_Post $post Post object
     * @return string Content preview
     */
    private function get_content_preview(\WP_Post $post): string {
        $content = $post->post_title . "\n\n";
        
        if (!empty($post->post_excerpt)) {
            $content .= $post->post_excerpt . "\n\n";
        }
        
        $content .= $post->post_content;
        
        // Clean and limit content
        $content = wp_strip_all_tags($content);
        $content = preg_replace('/\s+/', ' ', $content);
        
        return trim(substr($content, 0, 4000));
    }
    
    /**
     * AJAX handler for generating post metadata
     * 
     * @return void
     */
    public function ajax_generate_post_metadata(): void {
        // Verify nonce
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
            wp_die('Security check failed');
        }
        
        // Check permissions
        $post_id = absint($_POST['post_id'] ?? 0);
        if (!current_user_can('edit_post', $post_id)) {
            wp_die('Insufficient permissions');
        }
        
        try {
            $options = [
                'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
                'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
                'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
            ];
            
            $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
            
            wp_send_json_success([
                'metadata' => $metadata,
                'message' => __('SEO metadata generated successfully!', 'thinkrank'),
            ]);
            
        } catch (\Exception $e) {
            wp_send_json_error([
                'message' => $e->getMessage(),
            ]);
        }
    }
    
    /**
     * AJAX handler for saving post metadata
     * 
     * @return void
     */
    public function ajax_save_post_metadata(): void {
        // Verify nonce
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
            wp_die('Security check failed');
        }
        
        // Check permissions
        $post_id = absint($_POST['post_id'] ?? 0);
        if (!current_user_can('edit_post', $post_id)) {
            wp_die('Insufficient permissions');
        }
        
        // Save metadata
        $metadata = [
            'title' => sanitize_text_field(wp_unslash($_POST['title'] ?? '')),
            'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')),
            'focus_keyword' => sanitize_text_field(wp_unslash($_POST['focus_keyword'] ?? '')),
            'seo_score' => absint($_POST['seo_score'] ?? 0),
        ];
        
        foreach ($metadata as $key => $value) {
            update_post_meta($post_id, "_thinkrank_{$key}", $value);
        }
        
        update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
        
        wp_send_json_success([
            'message' => __('Metadata saved successfully!', 'thinkrank'),
        ]);
    }
}

```
