settings = $settings ?? Settings::instance(); $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('admin_notices', [$this, 'render_redirect_notice']); 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']); // Full metabox save used by editors that don't submit the #post form // (e.g. the Elementor editor). Persists every metabox field at once. add_action('wp_ajax_thinkrank_save_metabox', [$this, 'ajax_save_metabox']); // 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'); } ]); register_post_meta('', '_thinkrank_additional_schemas', [ 'show_in_rest' => true, 'single' => true, 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_json_ld_field'], 'auth_callback' => function() { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); // SEO meta fields for import support $string_meta_fields = [ '_thinkrank_canonical_url', '_thinkrank_og_title', '_thinkrank_og_description', '_thinkrank_og_image', '_thinkrank_twitter_title', '_thinkrank_twitter_description', '_thinkrank_twitter_image', '_thinkrank_imported_from', ]; foreach ($string_meta_fields as $meta_key) { register_post_meta('', $meta_key, [ 'show_in_rest' => true, 'single' => true, 'type' => 'string', 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); } // Multiple focus keywords (array). The legacy single-value // `_thinkrank_focus_keyword` is kept in sync by Focus_Keywords for // backward compatibility and registered for REST as a string elsewhere. register_post_meta('', Focus_Keywords::META_KEY, [ 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => ['type' => 'string'], ], ], 'single' => true, 'type' => 'array', 'sanitize_callback' => function ($value) { return Focus_Keywords::normalize($value); }, 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', Focus_Keywords::LEGACY_META_KEY, [ 'show_in_rest' => true, 'single' => true, 'type' => 'string', 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', '_thinkrank_robots_meta_enabled', [ 'show_in_rest' => true, 'single' => true, 'type' => 'integer', 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', '_thinkrank_robots_meta', [ '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_advanced_robots_meta', [ '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\SEO\Content_Visibility::SEARCH_META, [ 'show_in_rest' => true, 'single' => true, 'type' => 'integer', 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, [ 'show_in_rest' => true, 'single' => true, 'type' => 'integer', 'auth_callback' => function () { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', '_thinkrank_primary_category', [ 'show_in_rest' => true, 'single' => true, 'type' => 'integer', '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) { // 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; } /** * Sanitize JSON-LD meta field data * * Validates JSON structure, recursively sanitizes all string values, * and preserves @ characters in keys (crucial for JSON-LD). * * @param string $value Raw JSON string value * * @return string Sanitized JSON string or empty string if invalid */ public function sanitize_json_ld_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 ) > 200000 ) { // Limit to 200KB for larger schemas return ''; } // Recursively sanitize all values $sanitized = $this->sanitize_json_ld_recursively( $decoded ); // Re-encode as JSON $result = wp_json_encode( $sanitized ); return $result !== false ? $result : ''; } /** * Recursively sanitize JSON-LD data * * Similar to sanitize_json_recursively but preserves @ symbol and case in keys. * * @param mixed $data Data to sanitize * @param int $depth Current recursion depth * * @return mixed Sanitized data */ private function sanitize_json_ld_recursively( $data, int $depth = 0 ) { // Prevent deep recursion attacks if ( $depth > 10 ) { return null; } if ( is_array( $data ) ) { $sanitized = []; foreach ( $data as $key => $value ) { // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD) // Also preserve case as JSON-LD keys are case-sensitive $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key ); $sanitized[ $clean_key ] = $this->sanitize_json_ld_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(); $is_block_editor = $this->is_block_editor_screen(); foreach ($post_types as $post_type) { add_meta_box( 'thinkrank-seo-metabox', __('ThinkRank SEO', 'thinkrank'), [$this, 'render_seo_metabox'], $post_type, 'normal', 'high' ); // Classic Editor sidebar quick-access widget (mirrors SureRank's // "Manage your SEO" sidebar box). Skipped in the Block Editor, which // surfaces the full panel below the content instead. if (!$is_block_editor) { add_meta_box( 'thinkrank-seo-sidebar', __('ThinkRank', 'thinkrank'), [$this, 'render_sidebar_meta_box'], $post_type, 'side', 'high' ); } } } /** * Whether the current edit screen is using the Block Editor. * * @return bool True when the Block Editor is active, false for Classic Editor. */ private function is_block_editor_screen(): bool { if (!function_exists('get_current_screen')) { return false; } $screen = get_current_screen(); return $screen instanceof \WP_Screen && method_exists($screen, 'is_block_editor') && $screen->is_block_editor(); } /** * Render the Classic Editor sidebar quick-access widget. * * Shows a short label plus a button that scrolls to (and expands) the full * "ThinkRank SEO" panel in the main column. * * @param \WP_Post $post Post object. * @return void */ public function render_sidebar_meta_box(\WP_Post $post): void { $box_title = apply_filters('thinkrank_seo_sidebar_box_title', __('Optimize this content for search & AI with ThinkRank.', 'thinkrank'), $post); $cta_label = apply_filters('thinkrank_seo_sidebar_cta_label', __('Open ThinkRank SEO', 'thinkrank'), $post); ?>

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 ?>
persist_metadata($post_id, wp_unslash($_POST)); // The redirect is the one field here that can be refused outright. The // response to this request is a redirect back to the editor, so the // reason has to survive one page load to be seen at all. $redirect_error = $this->get_last_redirect_error(); if (null !== $redirect_error) { $this->store_redirect_error($redirect_error); } } /** * Persist metabox fields for a post from a form-field-name => value map, * reusing the shared metabox persistence (sanitization, JSON encoding, * focus-keyword normalization, empty-value deletion). * * Intended for non-form callers such as the MCP abilities layer. The * caller is responsible for authorization; keys use the same * `thinkrank_*` field names accepted by the metabox form (e.g. * `thinkrank_seo_title`, `thinkrank_meta_description`, `thinkrank_robots_meta`). * * @param int $post_id Post to update. * @param array $fields Field name => value map. * @return void */ public function save_seo_fields(int $post_id, array $fields): void { $this->persist_metadata($post_id, $fields); } /** * Persist all metabox fields for a post from a $_POST-shaped (already * unslashed) source array. * * Shared by `save_meta_boxes()` (classic/block editor form submit) and * `ajax_save_metabox()` (editors like Elementor that don't submit the #post * form). Nonce/permission checks are the caller's responsibility. Each field * is independently sanitized; missing keys are left untouched. * * @param int $post_id Post to update. * @param array $src Field name => raw value map (unslashed). * @return void */ private function persist_metadata(int $post_id, array $src): void { // Title & meta description are handled separately below: they can be // written out-of-band (Auto AI on publish, imports) // after an editor was opened, so a plain save from that now-stale editor // would clobber the generated value with a blank. Focus keywords are // likewise handled separately (array meta) via Focus_Keywords below. // // Both fields may hold variable tags, so they are sanitized as templates: // sanitize_text_field()/sanitize_textarea_field() strip %date% and // %category% as percent-encoding and store "te%" / "tegory%" (#521). $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']); $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']); $fields = [ 'thinkrank_seo_score' => 'absint', 'thinkrank_generated_at' => 'sanitize_text_field', 'thinkrank_pillar_content' => 'sanitize_text_field', 'thinkrank_exclude_from_search' => 'sanitize_text_field', 'thinkrank_exclude_from_archives' => 'sanitize_text_field', ]; // Focus keywords: prefer the JSON array field; fall back to the legacy // single string. Focus_Keywords::save() normalizes (dedupe, drop empty, // cap at MAX) and keeps the legacy single-value meta in sync. if (isset($src['thinkrank_focus_keywords'])) { $raw_keywords = $src['thinkrank_focus_keywords']; $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords; Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []); } elseif (isset($src['thinkrank_focus_keyword'])) { Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']); } // Update the post slug (post_name) when the metabox permalink field // was edited. This touches the WP post itself, not post meta. // // The baseline is what the field was RENDERED with. Without it the // guard here was a bare isset(), and the hidden input is always // posted — so a user who edited WordPress's own permalink field in // the Classic Editor had their new slug written by core and then // overwritten by this page-load snapshot (#441). if (isset($src['thinkrank_post_slug'])) { $this->maybe_update_slug( $post_id, (string) $src['thinkrank_post_slug'], isset($src['thinkrank_post_slug_baseline']) ? (string) $src['thinkrank_post_slug_baseline'] : null ); } // Save canonical URL separately with URL sanitization if (isset($src['thinkrank_canonical_url'])) { $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']); if (empty($canonical_url)) { delete_post_meta($post_id, '_thinkrank_canonical_url'); } else { update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url); } } $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src); foreach ($fields as $field => $sanitize_callback) { if (isset($src[$field])) { $value = call_user_func($sanitize_callback, $src[$field]); update_post_meta($post_id, "_{$field}", $value); } } $this->save_robots_meta($post_id, $src); $this->save_visibility_meta($post_id, $src); $this->save_social_meta($post_id, $src); // Update last modified timestamp update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql')); } /** * Persist one SEO text field with an out-of-band-write guard. * * Auto AI (on publish) and imports write the SEO title / meta description * directly to post meta. When that happens after an editor * was opened, the editor's hidden input is a stale blank; a normal save * would overwrite the freshly generated value with that blank. This guard * skips the write only when the submitted value is empty AND it was also * empty when the form was rendered (the `__orig` mirror), yet the * stored value is now non-empty — i.e. a background writer won the race. * * A deliberate clear still applies: if the field held a value at render and * is submitted empty, `$orig` is non-empty so the guard does not trigger. * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor * paths) keep the plain write behavior. * * @param int $post_id Post being saved. * @param array $src Unslashed field map. * @param string $field POST field name (e.g. thinkrank_seo_title). * @param string $meta_key Target post meta key. * @param callable $sanitize Sanitizer applied to the submitted value. * @return void */ private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void { if (!isset($src[$field])) { return; // Field absent from this submit → leave the stored value untouched. } $submitted = (string) call_user_func($sanitize, (string) $src[$field]); if ('' === $submitted && isset($src[$field . '__orig'])) { $orig = (string) $src[$field . '__orig']; $stored = (string) get_post_meta($post_id, $meta_key, true); // Blank now, blank at render, but populated in storage → a background // write landed after this editor loaded; don't clobber it. if ('' === $orig && '' !== $stored) { return; } } update_post_meta($post_id, $meta_key, $submitted); } /** * Update the post slug (post_name) from the metabox permalink field. * * Runs inside the save_post cycle, so wp_update_post() would recurse — a * static guard prevents re-entry. WordPress applies wp_unique_post_slug(), * so a colliding slug is de-duplicated automatically. Empty input is left * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are * skipped so we don't fight the editor's own slug generation. * * @param int $post_id Post to update. * @param string $raw_slug Desired slug from the metabox. * @return void */ private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void { static $updating = false; if ($updating) { return; } $post = get_post($post_id); if (!$post || wp_is_post_revision($post_id)) { return; } if (in_array($post->post_status, ['auto-draft', 'trash'], true)) { return; } $desired = sanitize_title($raw_slug); if ($desired === '') { return; } // Unchanged from what the form was rendered with, so the user did not // choose this value — they left it alone. Writing it back would undo // whatever core already saved from WordPress's own permalink field a // moment ago, on the same save_post priority (#441). // // Compared against the BASELINE rather than the current post_name on // purpose: by the time this runs core has already updated post_name, // so that comparison cannot tell a deliberate edit from a stale one. if ($baseline !== null && $desired === sanitize_title($baseline)) { return; } if ($desired === $post->post_name) { return; } $updating = true; wp_update_post([ 'ID' => $post_id, 'post_name' => $desired, ]); $updating = false; } /** * Persist per-post Open Graph and Twitter Card overrides. * * Empty values delete the meta entry so the frontend falls back to the * SEO title / meta description / featured image chain. */ private function save_social_meta(int $post_id, array $src): void { $text_fields = [ 'thinkrank_og_title' => '_thinkrank_og_title', 'thinkrank_og_description' => '_thinkrank_og_description', 'thinkrank_twitter_title' => '_thinkrank_twitter_title', 'thinkrank_twitter_description' => '_thinkrank_twitter_description', ]; foreach ($text_fields as $field => $meta_key) { if (!isset($src[$field])) { continue; } // Template fields: the frontend resolves their variable tags, so the // %tokens% have to survive the save (#521). $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]); if ($value === '') { delete_post_meta($post_id, $meta_key); } else { update_post_meta($post_id, $meta_key, $value); } } $url_fields = [ 'thinkrank_og_image' => '_thinkrank_og_image', 'thinkrank_twitter_image' => '_thinkrank_twitter_image', ]; foreach ($url_fields as $field => $meta_key) { if (!isset($src[$field])) { continue; } $value = esc_url_raw((string) $src[$field]); if ($value === '') { delete_post_meta($post_id, $meta_key); } else { update_post_meta($post_id, $meta_key, $value); } } } /** * Save per-post robots meta and advanced robots meta from the metabox. * * Stores the robots payloads as JSON-encoded strings, sanitized via * sanitize_json_meta_field. The toggle plus the two JSON blobs are the * single source of truth for per-post robots overrides. */ private function save_robots_meta(int $post_id, array $src): void { if (!isset($src['thinkrank_robots_meta_enabled'])) { return; } $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled']; update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled); if (isset($src['thinkrank_robots_meta'])) { update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta'])); } if (isset($src['thinkrank_advanced_robots_meta'])) { update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta'])); } } /** * Save the per-post listing-visibility switches. * * Stored as 1 or deleted rather than 1/0: the excluded set is read with a * `meta_value = '1'` query, so a row holding 0 would be dead weight on every * post anyone ever unticked. Deleting keeps the postmeta table proportional * to the number of posts actually hidden. * * @since 2.7.0 * * @param int $post_id Post being saved. * @param array $src Submitted fields. * @return void */ private function save_visibility_meta(int $post_id, array $src): void { $fields = [ 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META, 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, ]; foreach ($fields as $field => $meta_key) { if (!isset($src[$field])) { continue; } if ((bool) $src[$field]) { update_post_meta($post_id, $meta_key, 1); } else { delete_post_meta($post_id, $meta_key); } } \ThinkRank\SEO\Content_Visibility::flush(); } /** * 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'], true)) { 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(), true)) { return; } // Get post object for additional data global $post; // Ensure wp.media is available for the social-image media picker. wp_enqueue_media(); // 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 the asset file. The pinned "Configure // SEO" launcher needs wp-plugins (already listed by the build) and // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do // NOT add wp-interface here: it is not a registered script handle on // WP 7.x, and an unmet dependency would drop the whole metabox script. $dependencies = $asset['dependencies']; wp_enqueue_script( 'thinkrank-metabox', THINKRANK_PLUGIN_URL . 'assets/metabox.js', $dependencies, $asset['version'], true ); // Localize script data (shared builder; reused by the Elementor editor // integration, which has no #post form / hidden inputs of its own). wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID)); // Add defer attribute for non-blocking script loading wp_script_add_data('thinkrank-metabox', 'defer', true); // Enqueue metabox styles wp_enqueue_style( 'thinkrank-metabox', THINKRANK_PLUGIN_URL . 'assets/metabox.css', ['wp-components'], THINKRANK_VERSION ); // Classic Editor sidebar widget: lightweight styles + a vanilla-JS // handler so the "Optimize Here" button works without depending on the // React bundle. The box itself is only registered in the Classic Editor. $sidebar_css = <<<'CSS' .thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;} .thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;} CSS; wp_add_inline_style('thinkrank-metabox', $sidebar_css); $sidebar_js = <<<'JS' (function(){ document.addEventListener('click', function(e){ var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta'); if(!btn){return;} e.preventDefault(); // Open the ThinkRank SEO drawer mounted by the React metabox app. window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer')); }); })(); JS; wp_add_inline_script('thinkrank-metabox', $sidebar_js); } /** * Build the data object localized into the metabox script (`thinkrankMetabox`). * * Extracted so the Elementor editor integration can reuse the exact same * configuration. Callers that run outside the #post form (Elementor) also * read `existingMetadata`/`contentPreview`, which they add on top of this. * * @param int $post_id Post being edited. * @return array Localized config consumed by the React metabox. */ public function get_localized_data(int $post_id): array { $post = get_post($post_id); $post_type = $post ? $post->post_type : 'post'; // Resolve the correct REST API base for the current post type. // Falls back to the post type slug for any type without a rest_base. $post_type_obj = get_post_type_object($post_type); $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base)) ? $post_type_obj->rest_base : $post_type; return [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'), 'postId' => $post_id, 'postType' => $post_type, 'postRestBase' => $post_rest_base, 'postPermalink' => get_permalink($post_id), 'postSlug' => $post ? $post->post_name : '', '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'), ], 'seoScore' => $this->get_persisted_seo_score($post_id), 'postModified' => $post ? get_the_modified_date('c', $post) : '', 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type), 'postStatus' => get_post_status($post_id), 'isPro' => Plan_Config::is_pro(), // Whether a provider (Pro's Redirections feature) can actually store // a redirect. False renders the field as an upsell rather than an // input that accepts text nothing will ever act on. 'redirectSupported' => Object_Redirect::is_supported(), 'redirectTypes' => Object_Redirect::TYPES, /** * Filter the editor SEO panel's post-load refresh behaviour. * * The panel re-checks `/metadata/{id}` after load so values written * by a background writer (Auto AI on publish, imports) appear * without a reload. It only polls while the server * reports a write in flight, but the poll lives in JavaScript, so * the switch has to be localized into the bundle rather than being * a PHP-side filter alone (#329). * * Set `enabled` to false to switch the refresh off entirely. * * @since 1.30.0 * * @param array $config enabled (bool), intervalMs (int), maxTicks (int). * @param int $post_id Post being edited. */ 'seoRefresh' => apply_filters( 'thinkrank_metabox_seo_refresh', [ 'enabled' => true, 'intervalMs' => 4000, 'maxTicks' => 10, ], $post_id ), // Whether any AI provider API key is configured — gates the // "Generate with AI" button in the metabox 'aiConfigured' => !empty($this->settings->get('openai_api_key', '')) || !empty($this->settings->get('claude_api_key', '')) || !empty($this->settings->get('gemini_api_key', '')) || !empty($this->settings->get('openrouter_api_key', '')), // Resolved Global/Bulk SEO variable-tag patterns for this post, shown // as placeholder previews when a field is empty (the frontend applies // these same patterns on output). Typing a value overrides them. 'patternPreviews' => Pattern_Resolver::previews($post_id), // Token => value map (keys without %), for live client-side preview of // a custom pattern typed into a metabox field. 'patternVariables' => Pattern_Resolver::variables($post_id), ]; } /** * Latest persisted SEO score for a post. * * Read from the scores table — the same source the posts list column and the * Analysis panel use — so the editor badge agrees with them on load rather * than showing 0 until the Analysis panel mounts and fetches the score. * * The `thinkrank_seo_score` form field is unusable for this: it is only * written by the AI "Analyze Content" flow and is reset to 0 on every save. * * @param int $post_id Post ID * @return int|null Score, or null when the post has never been analyzed. */ private function get_persisted_seo_score(int $post_id): ?int { $existing = $this->seo_calculator->get_existing_score_data($post_id); $score = $existing['overall_score'] ?? null; return $score !== null ? (int) $score : null; } /** * Check if link suggestions are enabled for a post type * * @param string $post_type Post type to check * @return bool True if enabled, false otherwise */ private function is_link_suggestions_enabled(string $post_type): bool { $settings = get_option('thinkrank_global_seo_settings', []); if (isset($settings[$post_type]['link_suggestions'])) { return (bool) $settings[$post_type]['link_suggestions']; } return true; } /** * 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 */ public 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, true)) { $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, ]); // WordPress internals that should never carry an SEO metabox. Fixed, // so it is built once rather than per post type. $wp_internal_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', ]; // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are // layout fragments, not pages with their own SEO. Global SEO already // refuses them; this list is shared with that policy so the two cannot // drift apart again (#621). if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php'; } foreach ($custom_post_types as $post_type) { // Resolved per post type, not hoisted: the shared list runs through // a public filter that receives the post-type object, so an // integrator can answer differently for different post types. $excluded_types = array_merge( $wp_internal_types, \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type)) ); if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) { $default_types[] = $post_type; } } return apply_filters('thinkrank_supported_post_types', $default_types); } /** * Transient holding the last redirect error for the current user. */ private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_'; /** * Why the redirect field was refused on the most recent persist, if it was. * * @var \WP_Error|null */ private ?\WP_Error $last_redirect_error = null; /** * Persist the edit-screen redirect field. * * Absent keys are left alone, so a caller that never rendered the field * (the AJAX save from an editor that submits a subset, an import) cannot * clear a redirect by omission. * * The destination is not post meta — Pro's rules table holds it — so unlike * every other field here this save can fail for reasons the editor needs to * hear about: no Pro, plain permalinks, a destination that is the page's own * URL. Failing silently would be the worst of both, since the field would * redisplay empty on the next load with no explanation, so the reason is * stashed for the notice rendered on the next screen. * * @param string $object_type 'post' or 'term'. * @param int $object_id Object ID. * @param array $src Field name => raw value map. * @return void */ private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error { if (!array_key_exists('thinkrank_redirect_url', $src)) { return null; } $url = (string) $src['thinkrank_redirect_url']; // With no provider there is nothing to store and nothing to clear. // Staying quiet when the field was submitted empty keeps every ordinary // save on a free site from raising an error about a field the editor // never touched. if (!Object_Redirect::is_supported()) { if ('' !== trim($url)) { return new \WP_Error( 'thinkrank_redirect_unsupported', __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank') ); } return null; } $type = array_key_exists('thinkrank_redirect_type', $src) ? $src['thinkrank_redirect_type'] : Object_Redirect::DEFAULT_TYPE; $result = Object_Redirect::save($object_type, $object_id, $url, $type); return is_wp_error($result) ? $result : null; } /** * Why the last persist_metadata() call could not store the redirect. * * Every other metabox field either saves or is sanitized into something * that does; this one can be refused, and each caller reports that * differently — a notice for the form post, a JSON field for the AJAX save, * an error message for the MCP ability. * * @return \WP_Error|null */ public function get_last_redirect_error(): ?\WP_Error { return $this->last_redirect_error; } /** * Remember why a redirect could not be saved, for the next admin screen. * * @param \WP_Error $error Failure. * @return void */ private function store_redirect_error(\WP_Error $error): void { $user_id = get_current_user_id(); if ($user_id <= 0) { return; } set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS); } /** * Show, once, why the last redirect save failed. * * @return void */ public function render_redirect_notice(): void { $user_id = get_current_user_id(); if ($user_id <= 0) { return; } $key = self::REDIRECT_ERROR_TRANSIENT . $user_id; $message = get_transient($key); if (!is_string($message) || '' === $message) { return; } delete_transient($key); printf( '

%s

', esc_html( sprintf( /* translators: %s: reason the redirect was not saved. */ __('ThinkRank could not save the redirect: %s', 'thinkrank'), $message ) ) ); } /** * Get existing post metadata * * @param int $post_id Post ID * @return array Existing metadata */ public function get_post_metadata(int $post_id): array { // One lookup: get() goes through a filter Pro answers from the database. $redirect = Object_Redirect::get('post', $post_id); return [ 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true), 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true), 'focus_keyword' => Focus_Keywords::get_primary($post_id), 'focus_keywords' => Focus_Keywords::get($post_id), 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true), 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true), 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true), 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true), 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true), 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true), // Not post meta: the rule in Pro's redirections table is the value. // See ThinkRank\SEO\Object_Redirect. 'redirect_url' => $redirect['url'], 'redirect_type' => $redirect['type'], 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true), 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true), 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true), 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true), 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true), 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true), 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true), 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true), 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true), ]; } /** * Get site favicon URL * * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is * configured does it probe common favicon locations — and that probe is * cached (including a "no favicon" sentinel) so the editor never fires * blocking loopback HEAD requests on every load. * * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe. * @return string Favicon URL, or '' when none can be resolved. */ private function get_site_favicon_url(): string { // Try the native Site Icon first (WordPress 4.3+) — no HTTP required. $site_icon_url = get_site_icon_url(); if ($site_icon_url) { return $site_icon_url; } // No Site Icon configured: fall back to probing common favicon paths. // Cache the resolved value (empty string included) so the blocking // loopback probe runs at most once per ~12h per site host instead of on // every editor load. $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST)); $cached = get_transient($cache_key); if (false !== $cached) { return (string) $cached; } $favicon_url = ''; $favicon_paths = [ '/favicon.ico', '/favicon.png', '/apple-touch-icon.png', ]; foreach ($favicon_paths as $path) { $candidate = home_url($path); $response = wp_remote_head($candidate, ['timeout' => 3]); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { $favicon_url = $candidate; break; } } set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS); return $favicon_url; } /** * 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) { // Use the full-size image — social share images need a large source // (the preview scales it down), not a small thumbnail. $image_url = wp_get_attachment_image_url($thumbnail_id, 'full'); return $image_url ?: null; } return null; } /** * Get content preview for AI analysis * * @param \WP_Post $post Post object * @return string Content preview */ public 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"; } // Resolve through Builder_Content: a page builder keeps its words // outside post_content (Oxygen empties it entirely), and the editor // cannot reach postmeta, so without this the preview handed to the // browser is just the title and the live panel reports "No content". if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php'; } $content .= \ThinkRank\SEO\Builder_Content::resolve($post); // Clean and limit content $content = wp_strip_all_tags($content); $content = preg_replace('/\s+/', ' ', $content); // substr() counts BYTES: on Thai or CJK this handed the model a third // of the intended content and cut the last character in half (#687). return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 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: persist the full set of metabox fields for a post. * * Used by editors that don't submit the #post form (Elementor). Expects the * same field names the classic/block metabox submits, sent as POST params. * $_POST is slashed by WordPress, matching what `persist_metadata()` expects. * * @return void */ public function ajax_save_metabox(): void { $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')); if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) { wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403); } $post_id = absint($_POST['post_id'] ?? 0); if (!$post_id || !current_user_can('edit_post', $post_id)) { wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata() $this->persist_metadata($post_id, wp_unslash($_POST)); // Everything else saved; only the redirect can have been refused. Report // it in this response rather than as a notice on some later screen — // this caller never reloads the page. $redirect_error = $this->get_last_redirect_error(); if (null !== $redirect_error) { wp_send_json_error([ 'message' => sprintf( /* translators: %s: reason the redirect was not saved. */ __('Saved, except the redirect: %s', 'thinkrank'), $redirect_error->get_error_message() ), ], 400); } wp_send_json_success([ 'message' => __('SEO settings saved successfully!', 'thinkrank'), ]); } }