init_hooks(); } /** * Check if testing mode is enabled. * * Testing mode requires both a constant to be set AND admin capability. * To enable testing mode, add this to wp-config.php: * define( 'AEO_BOB_LITE_TESTING_MODE', true ); * * @since 1.0.0 * * @return bool True if testing mode is active. */ public static function is_testing_mode() { $current_user = wp_get_current_user(); // Default: testing mode OFF. $is_testing_mode = false; // Testing mode ON only when constant is set AND user is admin. if ( defined( 'AEO_BOB_LITE_TESTING_MODE' ) && AEO_BOB_LITE_TESTING_MODE ) { if ( current_user_can( 'manage_options' ) ) { $is_testing_mode = true; } } // Allow override via filter. return apply_filters( 'aeo_bob_lite_is_testing_mode', $is_testing_mode, $current_user ); } /** * Check if debug mode is enabled * Debug mode controls console logging and debug output * * @return bool True if debug mode is active */ public static function is_debug_mode() { // Debug mode follows testing mode by default $is_debug_mode = self::is_testing_mode(); // Allow separate override via filter return apply_filters('aeo_bob_lite_enable_debug', $is_debug_mode); } /** * Initialize hooks */ private function init_hooks() { // AJAX handlers for score analysis add_action('wp_ajax_aeo_bob_lite_analyze', array($this, 'ajax_analyze_content')); add_action('wp_ajax_aeo_bob_lite_get_scores', array($this, 'ajax_get_scores')); // v1.5.56: Get existing scores // AJAX handlers for optimization workflow add_action('wp_ajax_aeo_bob_lite_analyze_voice', array($this, 'ajax_analyze_voice')); add_action('wp_ajax_aeo_bob_lite_generate_suggestions', array($this, 'ajax_generate_suggestions')); add_action('wp_ajax_aeo_bob_lite_apply_suggestions', array($this, 'ajax_apply_suggestions')); // Enqueue optimization panel script (Block Editor and Classic Editor compatible) add_action('admin_enqueue_scripts', array($this, 'enqueue_optimization_panel_script')); } /** * AJAX: Analyze content and return scores */ public function ajax_analyze_content() { // Verify nonce and permissions if (!check_ajax_referer('aeo_bob_lite_nonce', 'nonce', false)) { wp_send_json_error(array('message' => esc_html__('Security check failed: invalid nonce. Please refresh the page.', 'aeo-bob-lite'))); return; } if (!isset($_POST['post_id'])) { wp_send_json_error(array('message' => esc_html__('Security check failed: post_id missing', 'aeo-bob-lite'))); return; } $post_id = absint(wp_unslash($_POST['post_id'])); if (!current_user_can('edit_post', $post_id)) { wp_send_json_error(array('message' => esc_html__('Security check failed: insufficient permissions', 'aeo-bob-lite'))); return; } // Get post data $post = get_post($post_id); if (!$post) { wp_send_json_error(array('message' => esc_html__('Post not found', 'aeo-bob-lite'))); return; } // v1.5.84: ANALYZE, RE-ANALYZE, RE-SCORE and the Elementor panel all score the SAVED post // through rescore() - the same input as save_post and Apply. RE-ANALYZE used to score the // editor's HTML (and any unsaved edits), so the same content could get two numbers (live: // post 681, 60 after Apply, 55 on RE-ANALYZE). Editor content in the request is ignored. try { $source = isset($_POST['source']) ? sanitize_key(wp_unslash($_POST['source'])) : 'analyze'; $scores = $this->rescore($post_id, $source); $aeo_score = $scores['aeo']; $seo_score = $scores['seo']; // Return the scores and their breakdown wp_send_json_success(array( 'scores' => array( 'aeo' => (int)$aeo_score, 'seo' => (int)$seo_score, ), 'scored' => 'saved', 'details' => $this->score_details_for_js($post_id), )); } catch (Exception $e) { wp_send_json_error(array('message' => esc_html__('Analysis error: ', 'aeo-bob-lite') . $e->getMessage())); } } /** * AJAX handler to get existing scores (for initial page load). * v1.5.56: Fetch scores without re-analyzing */ public function ajax_get_scores() { // Verify nonce and permissions if (!check_ajax_referer('aeo_bob_lite_nonce', 'nonce', false)) { wp_send_json_error(array('message' => esc_html__('Security check failed', 'aeo-bob-lite'))); return; } if (!isset($_POST['post_id'])) { wp_send_json_error(array('message' => esc_html__('Post ID missing', 'aeo-bob-lite'))); return; } $post_id = absint(wp_unslash($_POST['post_id'])); if (!current_user_can('edit_post', $post_id)) { wp_send_json_error(array('message' => esc_html__('Permission denied', 'aeo-bob-lite'))); return; } // Get scores from post meta (v1.5.84: rescored first if the post changed since they were stored) $this->fresh_scores($post_id); $seo_score_raw = get_post_meta($post_id, '_aeo_bob_seo_score', true); $aeo_score_raw = get_post_meta($post_id, '_aeo_bob_aeo_score', true); $last_analyzed = get_post_meta($post_id, '_aeo_bob_last_analyzed', true); // Validate and convert $seo_score = ($seo_score_raw !== '' && $seo_score_raw !== false) ? (int)$seo_score_raw : null; $aeo_score = ($aeo_score_raw !== '' && $aeo_score_raw !== false) ? (int)$aeo_score_raw : null; $has_scores = ($seo_score !== null && $aeo_score !== null); wp_send_json_success(array( 'has_scores' => $has_scores, 'seo_score' => $seo_score, 'aeo_score' => $aeo_score, 'updated_at' => $last_analyzed, 'details' => $this->score_details_for_js($post_id), )); } /** * Get overall site statistics (from post meta, not database table) */ public function get_site_stats() { global $wpdb; // Count posts with AEO scores in post meta // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Analytics query for dashboard display $stats = $wpdb->get_row( "SELECT COUNT(DISTINCT pm1.post_id) as analyzed, AVG(LEAST(CAST(pm1.meta_value AS UNSIGNED), 100)) as avg_aeo, AVG(LEAST(CAST(pm2.meta_value AS UNSIGNED), 100)) as avg_seo FROM " . $wpdb->postmeta . " pm1 LEFT JOIN " . $wpdb->postmeta . " pm2 ON pm1.post_id = pm2.post_id AND pm2.meta_key = '_aeo_bob_seo_score' WHERE pm1.meta_key = '_aeo_bob_aeo_score' AND pm1.meta_value != ''", ARRAY_A ); // Count only published posts and pages (exclude drafts, trash, etc.) $total_posts = wp_count_posts('post'); $total_pages = wp_count_posts('page'); // Only count publish status $published_posts = isset($total_posts->publish) ? intval($total_posts->publish) : 0; $published_pages = isset($total_pages->publish) ? intval($total_pages->publish) : 0; return array( 'total_posts' => $published_posts + $published_pages, 'analyzed' => intval($stats['analyzed'] ?? 0), 'avg_aeo' => round(floatval($stats['avg_aeo'] ?? 0), 1), 'avg_seo' => round(floatval($stats['avg_seo'] ?? 0), 1), ); } /** * v1.5.80: the one place Lite writes scores (Analyze, Re-analyze, Re-Score and Apply). * * AEO Bob Pro (2.6.6+) shares these meta keys and trusts a score only when it carries * Pro's scorer-version stamp. Lite's numbers come from Lite's scorer, so the stamp is * removed: after an upgrade Pro shows "Not analyzed" for the post instead of mixing a * Lite number into its dashboard (Pro rescores on save or with Rescore all). */ private function store_scores( $post_id, $aeo_score, $seo_score, $hash = '' ) { update_post_meta( $post_id, '_aeo_bob_aeo_score', $aeo_score ); update_post_meta( $post_id, '_aeo_bob_seo_score', $seo_score ); update_post_meta( $post_id, '_aeo_bob_last_analyzed', current_time( 'mysql' ) ); update_post_meta( $post_id, self::SCORE_HASH_META, $hash ); delete_post_meta( $post_id, '_aeo_bob_scorer_version' ); } /** * v1.5.86: may the current user use AI suggestions and Apply (on this post)? AI calls are billed * to the site's OpenAI key, so by default only editors and administrators (edit_others_posts); * change it with the aeo_bob_lite_ai_capability filter. manage_options always qualifies. */ public static function can_use_ai( $post_id = 0 ) { $cap = (string) apply_filters( 'aeo_bob_lite_ai_capability', 'edit_others_posts' ); $allowed = current_user_can( 'manage_options' ) || ( $cap !== '' && current_user_can( $cap ) ); return $allowed && ( ! $post_id || current_user_can( 'edit_post', (int) $post_id ) ); } /** * v1.5.84: THE scoring function. Every path - save_post, Apply, ANALYZE, RE-ANALYZE, * RE-SCORE, the Elementor panel, and any screen that finds a stale score - calls this, and it * always scores the SAVED post (fresh from the database), so the same content gets the same * number everywhere. Input: the saved title, the saved content (Elementor: _elementor_data), * the meta the rules read (meta description, schema, Lite's applied-FAQ list), normalized by * normalize_for_scoring(). Stores the scores with a fingerprint of that input. * * @return array {aeo:int, seo:int} */ public function rescore( $post_id, $source = 'analyze' ) { $post_id = (int) $post_id; clean_post_cache( $post_id ); wp_cache_delete( $post_id, 'post_meta' ); // v1.5.85: never score from a stale (object-)cached meta copy $post = get_post( $post_id ); if ( ! $post ) { return array( 'aeo' => 0, 'seo' => 0 ); } $aeo_factors = $this->aeo_factors( $post ); $seo_factors = $this->seo_factors( $post ); $aeo = (int) $this->compute_aeo_score( $post ); $seo = (int) $this->compute_seo_score( $post ); $hash = $this->score_hash( $post ); $this->store_scores( $post_id, $aeo, $seo, $hash ); // v1.5.85: what the box shows under "Score details" $source = in_array( $source, self::SOURCES, true ) ? $source : 'analyze'; update_post_meta( $post_id, self::DETAILS_META, wp_json_encode( array( 'aeo' => $aeo_factors, 'seo' => $seo_factors, 'scored_at' => gmdate( 'Y-m-d H:i:s' ), 'source' => $source, 'hash' => $hash, ) ) ); unset( self::$queue[ $post_id ] ); // just scored after the save: nothing left to do at shutdown self::log_rescore( $source, $post_id, $hash, $aeo, $seo, $aeo_factors, $seo_factors ); return array( 'aeo' => $aeo, 'seo' => $seo ); } /** v1.5.85: where a score came from (shown in the box and logged). */ const SOURCES = array( 'save', 'apply', 'analyze', 're-analyze', 're-score', 'elementor', 'view' ); const DETAILS_META = '_aeo_bob_lite_score_details'; /** v1.5.85: posts saved during this request, scored once at shutdown (after every plugin's writes). */ private static $queue = array(); /** * v1.5.85: save_post (registered for EVERY request - the block editor saves through the REST API, * where is_admin() is false, so the 1.5.84 admin-only hook never ran there). Revisions, autosaves * and auto-drafts are not the real post and are skipped. The scoring itself waits for shutdown: * other plugins (SEO plugins, the block editor's REST meta) write meta after save_post, and one * Update can fire save_post several times. */ public static function queue_rescore( $post_id, $post = null ) { if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $post = $post ? $post : get_post( $post_id ); if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) || in_array( isset( $post->post_status ) ? $post->post_status : '', array( 'auto-draft', 'trash', 'inherit' ), true ) ) { return; } if ( ! self::$queue ) { add_action( 'shutdown', array( __CLASS__, 'run_queue' ) ); } self::$queue[ (int) $post_id ] = true; } /** v1.5.85: score every post saved in this request, once, with its final saved state. */ public static function run_queue() { foreach ( array_keys( self::$queue ) as $post_id ) { self::get_instance()->rescore( $post_id, 'save' ); } self::$queue = array(); } /** * v1.5.85: with WP_DEBUG on, one line per rescore - path, post, input fingerprint and every * factor - so a live mismatch between two paths can be traced in debug.log. */ private static function log_rescore( $source, $post_id, $hash, $aeo, $seo, $aeo_factors, $seo_factors ) { if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { return; } $short = function ( $factors ) { $out = array(); foreach ( $factors as $f ) { $out[] = $f['key'] . '=' . $f['points'] . '/' . $f['max']; } return implode( ' ', $out ); }; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug builds only (WP_DEBUG) error_log( sprintf( '[AEO Bob Lite] rescore path=%s post=%d hash=%s aeo=%d seo=%d | aeo: %s | seo: %s', $source, $post_id, $hash, $aeo, $seo, $short( $aeo_factors ), $short( $seo_factors ) ) ); } /** v1.5.85: breakdown for the box's JS, with the time in the site's timezone and format. */ public function score_details_for_js( $post_id ) { $d = $this->score_details( $post_id ); if ( ! $d ) { return null; } $d['scored_at_display'] = $this->scored_at_display( $d['scored_at'] ); unset( $d['hash'] ); return $d; } /** v1.5.85: "October 4, 2026 3:12 pm" (site timezone) for a GMT 'Y-m-d H:i:s'. */ public function scored_at_display( $gmt ) { $ts = strtotime( $gmt . ' UTC' ); if ( ! $ts ) { return ''; } return function_exists( 'wp_date' ) ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $ts ) : gmdate( 'Y-m-d H:i', $ts ) . ' UTC'; } /** v1.5.85: the stored breakdown for the box ({aeo, seo, scored_at, source}) or null. */ public function score_details( $post_id ) { $d = json_decode( (string) get_post_meta( (int) $post_id, self::DETAILS_META, true ), true ); return is_array( $d ) && isset( $d['aeo'], $d['seo'] ) ? $d : null; } /** * v1.5.84: the stored scores, rescored first when they no longer describe the saved post * (content, title or scoring meta changed, or they predate this scorer). A post that was * never scored stays unscored. Used by every screen that shows a score. * * @return array|null {aeo:int, seo:int} or null when the post has no scores */ public function fresh_scores( $post_id ) { $post_id = (int) $post_id; $aeo = get_post_meta( $post_id, '_aeo_bob_aeo_score', true ); $seo = get_post_meta( $post_id, '_aeo_bob_seo_score', true ); if ( $aeo === '' || $aeo === false || $seo === '' || $seo === false ) { return null; } $post = get_post( $post_id ); if ( $post && ( (string) get_post_meta( $post_id, self::SCORE_HASH_META, true ) !== $this->score_hash( $post ) || ! $this->score_details( $post_id ) ) ) { return $this->rescore( $post_id, 'view' ); } return array( 'aeo' => (int) $aeo, 'seo' => (int) $seo ); } /** v1.5.84: Lite scorer version - part of the fingerprint, so a rule change rescores everything on view. */ const SCORER_VERSION = 2; const SCORE_HASH_META = '_aeo_bob_lite_score_hash'; /** * v1.5.84: fingerprint of everything the scorer reads for this post. */ private function score_hash( $post ) { $meta = array(); foreach ( array( '_aeo_bob_meta_description', '_yoast_wpseo_metadesc', '_aioseo_description', '_rank_math_description', '_aeo_bob_schema_markup', '_aeo_bob_lite_structured_data', '_aeo_bob_lite_schema', '_aeo_bob_lite_faq_section' ) as $key ) { $value = get_post_meta( $post->ID, $key, true ); $meta[ $key ] = is_scalar( $value ) ? (string) $value : wp_json_encode( $value ); } return md5( wp_json_encode( array( self::SCORER_VERSION, $this->normalize_for_scoring( $post->post_title, true ), $this->normalize_for_scoring( $this->get_scoreable_content( $post ) ), $meta, untrailingslashit( home_url() ), ) ) ); } /** * v1.5.80 (Pro 2.6.6 AEO_Bob_Score_Engine::normalize): same input, same text. Entities * decoded and typographic quotes folded, so editor content (’) and saved content * (’) score the same; curly quotes no longer split words or lengthen titles. */ private function normalize_for_scoring( $text, $is_title = false ) { if ( $is_title ) { $text = wp_strip_all_tags( (string) $text ); } if ( ! $is_title ) { // v1.5.84: storage format must not change the score. Block comments, shortcode tags // ([purchase_link ...]) and inline emphasis ( in an FAQ question) carry no // content; WordPress's classic editor stores paragraphs without

(has_faq_structure // accepts both). Links () and headings stay - the rules count them. $text = preg_replace( '//s', '', (string) $text ); $text = preg_replace( '/\[\/?[a-zA-Z_][\w-]*(?:\s[^\]]*)?\]/', '', $text ); $text = preg_replace( '/<\/?(?:strong|b|em|i|u|span|mark|small)\b[^>]*>/i', '', $text ); } $text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = str_replace( array( "\u{2018}", "\u{2019}", "\u{201A}", "\u{2032}" ), "'", $text ); $text = str_replace( array( "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}" ), '"', $text ); return $is_title ? trim( preg_replace( '/\s+/u', ' ', $text ) ) : $text; } /** * v1.5.80 (Pro 2.6.6): word count that does not depend on markup - tags become spaces, * so "

a

b

" (editor) and "a\n\nb" (stored) both count 2. */ private function scoring_word_count( $html ) { return str_word_count( wp_strip_all_tags( preg_replace( '/<[^>]*>/', ' ', (string) $html ) ) ); } /** * v1.5.80 (Pro 2.6.8 scorer 3, AEO_Bob_Score_Engine::has_faq_structure): does the content * carry an FAQ in a form we recognise? An FAQ heading followed, before the next H2, by a * question heading and an answer paragraph (the markup Lite inserts), AEO Bob FAQ classes, * or the old "Q:"/"A:" text. */ private function has_faq_structure( $content ) { $content = (string) $content; if ( preg_match( '/class="[^"]*\baeo-bob-faq(?:-q|-a)?\b/i', $content ) ) { return true; } if ( preg_match( '/]*>[^<]*\b(faq|frequently asked questions)\b[^<]*<\/h[1-6]>/i', $content, $m, PREG_OFFSET_CAPTURE ) ) { $after = substr( $content, $m[0][1] + strlen( $m[0][0] ) ); $next = preg_match( '/]*>/i', $after, $nm, PREG_OFFSET_CAPTURE ) ? $nm[0][1] : strlen( $after ); // v1.5.84 (Lite): the answer may be a

(as Lite inserts it) or plain text (as the classic // editor stores it after Update - removep). Pro 2.6.8 requires

; that cost 5 points here. if ( preg_match( '/]*>(?:(?!<\/h[34]>).)*\?\s*<\/h[34]>\s*(?:= 2; } /** * Get the content the scorers should read * * Elementor keeps the real content in _elementor_data and leaves * post_content near-empty, so score its markup instead. */ private function get_scoreable_content( $post ) { if ( ! class_exists( 'AEO_Bob_Lite_Optimizer' ) ) { return $post->post_content; } $optimizer = AEO_Bob_Lite_Optimizer::get_instance(); if ( ! $optimizer->is_elementor_post( $post->ID ) ) { return $post->post_content; } $html = $optimizer->extract_html_from_elementor( $post->ID ); return ! empty( $html ) ? $html : $post->post_content; } /** * SEO score (rules from Pro v2.3.09, unchanged): the sum of the factors, capped at 100. * v1.5.85: computed from seo_factors() so the box can show each factor's points. */ private function compute_seo_score( $post ) { return $this->factor_total( $this->seo_factors( $post ) ); } /** * AEO score (rules from Pro v2.3.09 / 2.6.8 scorer 3, unchanged): the sum of the factors, capped at 100. */ private function compute_aeo_score( $post ) { return $this->factor_total( $this->aeo_factors( $post ) ); } private function factor_total( array $factors ) { $sum = 0; foreach ( $factors as $f ) { $sum += (int) $f['points']; } return min( 100, $sum ); } private function factor( $key, $label, $points, $max, $note = '' ) { return array( 'key' => $key, 'label' => $label, 'points' => (int) $points, 'max' => (int) $max, 'note' => (string) $note ); } /** Points for $value from [threshold => points] tiers (highest threshold first). */ private function tier( $value, array $tiers ) { foreach ( $tiers as $min => $points ) { if ( $value >= $min ) { return $points; } } return 0; } /** * v1.5.85: SEO factors with points and maximum (sums to 100). * * @return array[] each {key, label, points, max, note} */ private function seo_factors( $post ) { $content = $this->normalize_for_scoring( $this->get_scoreable_content( $post ) ); $title = $this->normalize_for_scoring( $post->post_title, true ); $post_id = $post->ID; $factors = array(); // Title length (15) $len = strlen( $title ); $points = 0; if ( $len >= 30 && $len <= 60 ) { $points = 15; } elseif ( $len >= 25 && $len <= 65 ) { $points = 12; } elseif ( $len >= 20 && $len <= 70 ) { $points = 8; } elseif ( $len > 0 ) { $points = 4; } /* translators: %d: number of characters in the title */ $factors[] = $this->factor( 'title_length', __( 'Title length', 'aeo-bob-lite' ), $points, 15, sprintf( __( '%d characters (best: 30-60)', 'aeo-bob-lite' ), $len ) ); // Title wording (10): question word 5, power word 5 $question = (bool) preg_match( '/\b(how|what|why|when|where|which|who)\b/i', $title ); $power = (bool) preg_match( '/\b(best|top|guide|tips|secrets|ultimate|complete|essential|proven|easy)\b/i', $title ); $notes = array(); $notes[] = $question ? __( 'question word', 'aeo-bob-lite' ) : __( 'no question word', 'aeo-bob-lite' ); $notes[] = $power ? __( 'power word', 'aeo-bob-lite' ) : __( 'no power word', 'aeo-bob-lite' ); $factors[] = $this->factor( 'title_words', __( 'Title wording', 'aeo-bob-lite' ), ( $question ? 5 : 0 ) + ( $power ? 5 : 0 ), 10, implode( ', ', $notes ) ); // Content length (25) $words = $this->scoring_word_count( $content ); /* translators: %d: number of words */ $factors[] = $this->factor( 'content_length', __( 'Content length', 'aeo-bob-lite' ), $this->tier( $words, array( 2000 => 25, 1500 => 23, 1000 => 20, 750 => 17, 500 => 14, 300 => 10, 100 => 5 ) ), 25, sprintf( __( '%d words', 'aeo-bob-lite' ), $words ) ); // Headings (25) $headings = (int) preg_match_all( '/]*>/i', $content ); /* translators: %d: number of headings */ $factors[] = $this->factor( 'headings', __( 'Headings', 'aeo-bob-lite' ), $this->tier( $headings, array( 6 => 25, 5 => 23, 4 => 20, 3 => 16, 2 => 12, 1 => 6 ) ), 25, sprintf( __( '%d headings', 'aeo-bob-lite' ), $headings ) ); // Internal links (15): absolute to this site, root-relative, and relative $home = untrailingslashit( home_url() ); $links = (int) preg_match_all( '/]*href="[^"]*' . preg_quote( $home, '/' ) . '(?:[\/?#]|$)[^"]*"[^>]*>/i', $content ) + (int) preg_match_all( '/]*href="\/(?!\/)[^"]*"[^>]*>/i', $content ) + (int) preg_match_all( '/]*href="(?!https?:|mailto:|tel:|#|\/\/)[^"\/][^"]*"[^>]*>/i', $content ); /* translators: %d: number of internal links */ $factors[] = $this->factor( 'internal_links', __( 'Internal links', 'aeo-bob-lite' ), $this->tier( $links, array( 5 => 15, 4 => 13, 3 => 11, 2 => 8, 1 => 5 ) ), 15, sprintf( __( '%d internal links', 'aeo-bob-lite' ), $links ) ); // Meta description (10): Lite/Pro, Yoast, AIOSEO, Rank Math $meta_description = ''; foreach ( array( '_aeo_bob_meta_description', '_yoast_wpseo_metadesc', '_aioseo_description', '_rank_math_description' ) as $key ) { $value = get_post_meta( $post_id, $key, true ); if ( ! empty( $value ) ) { $meta_description = (string) $value; break; } } $meta_len = strlen( $meta_description ); $points = 0; if ( $meta_len >= 120 && $meta_len <= 160 ) { $points = 10; } elseif ( $meta_len >= 100 && $meta_len <= 180 ) { $points = 7; } elseif ( $meta_len >= 50 ) { $points = 4; } $factors[] = $this->factor( 'meta_description', __( 'Meta description', 'aeo-bob-lite' ), $points, 10, $meta_len ? sprintf( /* translators: %d: number of characters */ __( '%d characters (best: 120-160)', 'aeo-bob-lite' ), $meta_len ) : __( 'none found', 'aeo-bob-lite' ) ); return $factors; } /** * v1.5.85: AEO factors with points and maximum (sums to 100). * * @return array[] each {key, label, points, max, note} */ private function aeo_factors( $post ) { $content = $this->normalize_for_scoring( $this->get_scoreable_content( $post ) ); $post_id = $post->ID; $factors = array(); // FAQ / Q&A (30): FAQ heading 10, question headings 2 each (max 10), applied-FAQ list 5, FAQ structure 5 $faq_heading = (bool) preg_match( '/]*>.*\b(faq|frequently asked questions)\b.*<\/h[1-6]>/i', $content ) || (bool) preg_match( '/]*>.*\b(questions? and answers?|q\s*&\s*a)\b.*<\/h[1-6]>/i', $content ); $question_headings = (int) preg_match_all( '/]*>[^<]*\?<\/h[2-4]>/i', $content ); $stored = get_post_meta( $post_id, '_aeo_bob_lite_faq_section', true ); $stored = is_string( $stored ) ? json_decode( $stored, true ) : $stored; $stored_list = is_array( $stored ) && count( $stored ) >= 3; $structure = $this->has_faq_structure( $content ); $points = ( $faq_heading ? 10 : 0 ) + min( 10, $question_headings * 2 ) + ( $stored_list ? 5 : 0 ) + ( $structure ? 5 : 0 ); $notes = array(); $notes[] = $faq_heading ? __( 'FAQ heading +10', 'aeo-bob-lite' ) : __( 'no FAQ heading', 'aeo-bob-lite' ); /* translators: 1: number of question headings, 2: points */ $notes[] = sprintf( __( '%1$d question headings +%2$d', 'aeo-bob-lite' ), $question_headings, min( 10, $question_headings * 2 ) ); $notes[] = $structure ? __( 'FAQ structure +5', 'aeo-bob-lite' ) : __( 'no FAQ structure', 'aeo-bob-lite' ); if ( $stored_list ) { $notes[] = __( 'applied FAQ list +5', 'aeo-bob-lite' ); } $factors[] = $this->factor( 'faq', __( 'FAQ and Q&A', 'aeo-bob-lite' ), min( 30, $points ), 30, implode( ', ', $notes ) ); // Structure (25) $headings = (int) preg_match_all( '/]*>/i', $content ); /* translators: %d: number of headings */ $factors[] = $this->factor( 'structure', __( 'Structure (headings)', 'aeo-bob-lite' ), $this->tier( $headings, array( 5 => 25, 4 => 22, 3 => 18, 2 => 14, 1 => 8 ) ), 25, sprintf( __( '%d headings', 'aeo-bob-lite' ), $headings ) ); // Schema (20): stored schema 15, JSON-LD in content +10 (max 20), FAQPage 20 $schema = 0; $faqpage = false; foreach ( array( '_aeo_bob_schema_markup', '_aeo_bob_lite_structured_data', '_aeo_bob_lite_schema' ) as $key ) { $data = get_post_meta( $post_id, $key, true ); if ( ! empty( $data ) ) { $schema = 15; $faqpage = (bool) preg_match( '/"@type"\s*:\s*"FAQPage"/i', is_string( $data ) ? $data : wp_json_encode( $data ) ); break; } } if ( preg_match( '/]*type=["\']application\/ld\+json["\'][^>]*>/i', $content ) ) { $schema = min( 20, $schema + 10 ); } if ( $faqpage || preg_match( '/"@type"\s*:\s*"FAQPage"/i', $content ) ) { $schema = 20; } $factors[] = $this->factor( 'schema', __( 'Schema markup', 'aeo-bob-lite' ), $schema, 20, $schema ? ( $schema === 20 ? __( 'FAQPage schema', 'aeo-bob-lite' ) : __( 'schema found', 'aeo-bob-lite' ) ) : __( 'none found', 'aeo-bob-lite' ) ); // Depth (15) $words = $this->scoring_word_count( $content ); /* translators: %d: number of words */ $factors[] = $this->factor( 'depth', __( 'Content depth', 'aeo-bob-lite' ), $this->tier( $words, array( 2000 => 15, 1500 => 13, 1000 => 11, 800 => 9, 500 => 6, 300 => 3 ) ), 15, sprintf( __( '%d words', 'aeo-bob-lite' ), $words ) ); // Answer-focused wording (10) $signals = 0; foreach ( array( '/\b(how to|what is|what are|why does|why do|when should|where can|which)\b/i', '/\b(step|steps|guide|tutorial|explanation|overview|introduction)\b/i', '/\b(benefits?|advantages?|disadvantages?|pros|cons|comparison)\b/i', '/\b(definition|meaning|example|examples|tips?|advice)\b/i', '/\b(best practices?|recommendations?|solutions?)\b/i', ) as $pattern ) { if ( preg_match( $pattern, $content ) ) { $signals++; } } /* translators: %d: number of answer-style word groups found (of 5) */ $factors[] = $this->factor( 'answers', __( 'Answer-focused wording', 'aeo-bob-lite' ), $this->tier( $signals, array( 4 => 10, 3 => 8, 2 => 6, 1 => 3 ) ), 10, sprintf( __( '%d of 5 answer word groups', 'aeo-bob-lite' ), $signals ) ); return $factors; } /** * AJAX: Analyze author voice */ public function ajax_analyze_voice() { // Security checks check_ajax_referer('aeo_bob_lite_nonce', 'nonce', true); if (!isset($_POST['post_id']) || !current_user_can('edit_post', absint(wp_unslash($_POST['post_id'])))) { wp_send_json_error(__('Permission denied', 'aeo-bob-lite')); } $post_id = absint(wp_unslash($_POST['post_id'])); $post = get_post($post_id); if (!$post) { wp_send_json_error(__('Post not found', 'aeo-bob-lite')); } // Check if Author Voice class exists if (!class_exists('AEO_Bob_Author_Voice')) { require_once AEO_BOB_LITE_PATH . 'includes/core/class-aeo-bob-author-voice.php'; } try { $author_voice = AEO_Bob_Author_Voice::get_instance(); $profile = $author_voice->update_author_voice_profile($post_id); if (!$profile || empty($profile)) { wp_send_json_error(__('Failed to analyze author voice', 'aeo-bob-lite')); } // Save profile update_post_meta($post_id, '_aeo_bob_author_voice_profile', $profile); wp_send_json_success(array( 'profile' => $profile, 'message' => __('Author voice analyzed successfully', 'aeo-bob-lite') )); } catch (Exception $e) { wp_send_json_error(__('Analysis failed:', 'aeo-bob-lite') . ' ' . $e->getMessage()); } } /** * AJAX: Generate AI suggestions (limited to 3 content + 3 FAQ) */ public function ajax_generate_suggestions() { // Security checks check_ajax_referer('aeo_bob_lite_nonce', 'nonce', true); // v1.5.86: AI suggestions spend the site's OpenAI credit - editors and administrators only // (filter aeo_bob_lite_ai_capability), and only on posts the user can edit. if (!isset($_POST['post_id']) || !self::can_use_ai(absint(wp_unslash($_POST['post_id'])))) { wp_send_json_error(array('message' => esc_html__('Only editors and administrators can use AI suggestions.', 'aeo-bob-lite'))); } $post_id = absint(wp_unslash($_POST['post_id'])); $post = get_post($post_id); if (!$post) { wp_send_json_error(__('Post not found', 'aeo-bob-lite')); } // Check API key $api_key = get_option('aeo_bob_lite_openai_key', ''); if (empty($api_key)) { wp_send_json_error(__('OpenAI API key not configured. Please add your API key in settings.', 'aeo-bob-lite')); } try { // Generate suggestions using optimizer class if (!class_exists('AEO_Bob_Lite_Optimizer')) { wp_send_json_error(__('Optimizer class not found', 'aeo-bob-lite')); } $optimizer = AEO_Bob_Lite_Optimizer::get_instance(); $suggestions = $optimizer->generate_suggestions($post_id); // v1.5.79 (P4): show the actual reason (bad key, quota, truncated reply, ...) if (is_wp_error($suggestions)) { // Plain text; the panels escape it when they render it wp_send_json_error($suggestions->get_error_message()); } if (!$suggestions || !is_array($suggestions)) { wp_send_json_error(__('Failed to generate suggestions. Please try again.', 'aeo-bob-lite')); } // Store suggestions for later application update_post_meta($post_id, '_aeo_bob_lite_suggestions_cache', $suggestions); // v1.5.79 (P5): "nothing to change" comes back as a normal result with its message wp_send_json_success(array( 'suggestions' => $suggestions, 'message' => !empty($suggestions['message']) ? $suggestions['message'] : __('Suggestions generated successfully', 'aeo-bob-lite') )); } catch (Exception $e) { wp_send_json_error(__('Failed to generate suggestions:', 'aeo-bob-lite') . ' ' . $e->getMessage()); } } /** * AJAX: Apply selected suggestions to content */ public function ajax_apply_suggestions() { // Security checks check_ajax_referer('aeo_bob_lite_nonce', 'nonce', true); // v1.5.86: AI suggestions spend the site's OpenAI credit - editors and administrators only // (filter aeo_bob_lite_ai_capability), and only on posts the user can edit. if (!isset($_POST['post_id']) || !self::can_use_ai(absint(wp_unslash($_POST['post_id'])))) { wp_send_json_error(array('message' => esc_html__('Only editors and administrators can use AI suggestions.', 'aeo-bob-lite'))); } $post_id = absint(wp_unslash($_POST['post_id'])); /* * JSON INPUT HANDLING - Why we decode BEFORE sanitizing: * * 1. JSON is a structured data format - sanitizing the raw string would corrupt it * 2. WordPress sanitize_* functions are for individual values, not JSON structures * 3. Proper approach: decode first, then sanitize each field individually * 4. This follows WordPress.org coding standards for JSON input handling */ // Step 1: Unslash the raw JSON string (WordPress adds slashes to $_POST) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON must be decoded before sanitization; individual fields sanitized below $selected_raw = isset( $_POST['selected'] ) ? wp_unslash( $_POST['selected'] ) : ''; // Step 2: Validate we received input if ( empty( $selected_raw ) || ! is_string( $selected_raw ) ) { wp_send_json_error( array( 'message' => esc_html__( 'No suggestions data provided', 'aeo-bob-lite' ) ) ); return; } // Step 3: Decode JSON string to PHP array $selected_decoded = json_decode( $selected_raw, true ); // Step 4: Validate JSON decode succeeded if ( json_last_error() !== JSON_ERROR_NONE ) { wp_send_json_error( array( 'message' => esc_html__( 'Invalid JSON format', 'aeo-bob-lite' ), 'error' => json_last_error_msg(), ) ); return; } // Step 5: Validate decoded result is an array if ( ! is_array( $selected_decoded ) ) { wp_send_json_error( array( 'message' => esc_html__( 'Invalid suggestions format: expected array', 'aeo-bob-lite' ) ) ); return; } // Step 6: Sanitize each item individually (proper WordPress approach) $selected = array(); foreach ( $selected_decoded as $item ) { // Skip non-array items if ( ! is_array( $item ) ) { continue; } $sanitized_item = array(); // Sanitize 'type' field - string, sanitized with sanitize_text_field() if ( isset( $item['type'] ) && is_string( $item['type'] ) ) { $sanitized_item['type'] = sanitize_text_field( $item['type'] ); } // Sanitize 'index' field - integer, sanitized with absint() if ( isset( $item['index'] ) && is_numeric( $item['index'] ) ) { $sanitized_item['index'] = absint( $item['index'] ); } // Only include items that have all required fields after sanitization if ( ! empty( $sanitized_item['type'] ) && isset( $sanitized_item['index'] ) ) { $selected[] = $sanitized_item; } } // Step 7: Validate we have at least one valid sanitized item if ( empty( $selected ) ) { wp_send_json_error( array( 'message' => esc_html__( 'No valid suggestions selected', 'aeo-bob-lite' ) ) ); return; } $post = get_post($post_id); if (!$post) { wp_send_json_error(__('Post not found', 'aeo-bob-lite')); } // Get cached suggestions $suggestions = get_post_meta($post_id, '_aeo_bob_lite_suggestions_cache', true); if (!$suggestions) { wp_send_json_error(__('No cached suggestions found. Please generate suggestions again.', 'aeo-bob-lite')); } try { // Apply suggestions using optimizer $optimizer = AEO_Bob_Lite_Optimizer::get_instance(); $result = $optimizer->apply_suggestions($post_id, $selected, $suggestions); if (!$result || (is_array($result) && !$result['success'])) { // v1.5.79 (A11): nothing landed - say why; keep the suggestions for another try if (is_array($result) && !empty($result['message'])) { wp_send_json_error(array( 'message' => $result['message'], 'stats' => isset($result['stats']) ? $result['stats'] : array(), )); } wp_send_json_error(__('Failed to apply suggestions', 'aeo-bob-lite')); } // Clear cache delete_post_meta($post_id, '_aeo_bob_lite_suggestions_cache'); // Recompute scores against the freshly saved post (v1.5.84: rescore(), the one scoring // function; also after the FAQ list meta the AEO score reads has been saved) $scores = $this->rescore($post_id, 'apply'); $seo_score = $scores['seo']; $aeo_score = $scores['aeo']; // Return success with updated content $response = array( 'message' => !empty($result['message']) ? $result['message'] : __('Suggestions applied successfully', 'aeo-bob-lite'), 'stats' => isset($result['stats']) ? $result['stats'] : array(), 'scores' => array( 'aeo' => (int)$aeo_score, 'seo' => (int)$seo_score, ), ); // Include updated content if available if (is_array($result)) { // ✅ v1.5.37: CRITICAL - Pass through elementor_updated flag! if (isset($result['elementor_updated'])) { $response['elementor_updated'] = $result['elementor_updated']; } if (isset($result['content'])) { $response['content'] = $result['content']; } if (isset($result['title_changed']) && $result['title_changed']) { $response['title'] = $result['title']; } } wp_send_json_success($response); } catch (Exception $e) { wp_send_json_error(__('Failed to apply suggestions:', 'aeo-bob-lite') . ' ' . $e->getMessage()); } } /** * Enqueue optimization panel script (Block Editor and Classic Editor compatible) * v1.5.47.44: Moved from admin_print_footer_scripts to admin_enqueue_scripts for reliable context */ public function enqueue_optimization_panel_script($hook) { // Only load on post.php and post-new.php (edit screens) if (!in_array($hook, array('post.php', 'post-new.php'))) { return; } // Don't load in Elementor editor - it has its own panel // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Context check only if (isset($_GET['action']) && sanitize_text_field(wp_unslash($_GET['action'])) === 'elementor') { return; } // Get current screen and validate post type $screen = get_current_screen(); if (!$screen || !isset($screen->post_type) || !in_array($screen->post_type, array('post', 'page'), true)) { return; } // Get post ID from global $post (safe - no direct input usage) $post_id = 0; global $post; if ( $post && isset( $post->ID ) ) { $post_id = absint( $post->ID ); } elseif ( function_exists( 'get_the_ID' ) ) { $post_id = absint( get_the_ID() ); } // For new posts, ID might be 0 - that's OK, the panel will still load if (!$post_id) { return; // Skip for brand new posts that haven't been saved yet } // v1.5.81: FAQ answer preview uses the insert's allowlist. wp_enqueue_script( 'aeo-bob-lite-faq-html', AEO_BOB_LITE_URL . 'assets/js/faq-html.js', array(), aeo_bob_lite_asset_ver( 'assets/js/faq-html.js' ), true ); // Enqueue the external optimizer panel script. wp_enqueue_script( 'aeo-bob-lite-optimizer-panel', AEO_BOB_LITE_URL . 'assets/js/optimizer-panel.js', array( 'jquery', 'aeo-bob-lite-faq-html' ), aeo_bob_lite_asset_ver( 'assets/js/optimizer-panel.js' ), true ); // Build data object with localized strings. $data = array( 'postId' => intval( $post_id ), 'nonce' => wp_create_nonce( 'aeo_bob_lite_nonce' ), 'upgradeUrl' => admin_url( 'admin.php?page=aeo-bob-lite-upgrade' ), 'i18n' => array( 'analyzing' => __( 'Analyzing...', 'aeo-bob-lite' ), 'voiceProfileDetected' => __( 'Voice Profile Detected', 'aeo-bob-lite' ), 'style' => __( 'Style', 'aeo-bob-lite' ), 'tone' => __( 'Tone', 'aeo-bob-lite' ), 'readingLevel' => __( 'Reading Level', 'aeo-bob-lite' ), 'voiceAnalyzedSuccess' => __( 'Author voice analyzed successfully!', 'aeo-bob-lite' ), 'reanalyzeVoice' => __( 'Re-analyze Author Voice', 'aeo-bob-lite' ), 'analysisFailed' => __( 'Analysis failed', 'aeo-bob-lite' ), 'networkError' => __( 'Network error. Please try again.', 'aeo-bob-lite' ), 'timeout' => __( 'Request timed out. Please try again.', 'aeo-bob-lite' ), 'generating' => __( 'Generating...', 'aeo-bob-lite' ), 'unableToGenerate' => __( 'Unable to generate suggestions. Please check your settings.', 'aeo-bob-lite' ), 'titleSuggestions' => __( 'Title Suggestions', 'aeo-bob-lite' ), 'chooseOne' => __( 'Choose ONE', 'aeo-bob-lite' ), 'originalTitle' => __( 'Original Title', 'aeo-bob-lite' ), 'contentImprovements' => __( 'Content Improvements', 'aeo-bob-lite' ), 'selectImprovements' => __( 'Select improvements to apply.', 'aeo-bob-lite' ), 'original' => __( 'ORIGINAL', 'aeo-bob-lite' ), 'improved' => __( 'IMPROVED', 'aeo-bob-lite' ), 'newParagraph' => __( 'NEW PARAGRAPH', 'aeo-bob-lite' ), 'faqSuggestions' => __( 'FAQ Suggestions', 'aeo-bob-lite' ), 'faqHint' => __( 'These are NEW Q&A pairs that will be added to your post.', 'aeo-bob-lite' ), 'suggestionsGenerated' => __( 'Suggestions generated! Select the ones you want to apply.', 'aeo-bob-lite' ), 'getNewSuggestions' => __( 'Get New Suggestions', 'aeo-bob-lite' ), 'failedGenerate' => __( 'Failed to generate suggestions', 'aeo-bob-lite' ), 'getSuggestions' => __( 'Get AI Suggestions', 'aeo-bob-lite' ), 'timeoutLong' => __( 'Request timed out (60s). The API might be slow.', 'aeo-bob-lite' ), 'selectAtLeastOne' => __( 'Please select at least one suggestion to apply.', 'aeo-bob-lite' ), 'applyConfirm' => __( 'Apply {count} suggestion(s) to your content?', 'aeo-bob-lite' ), 'applying' => __( 'Applying...', 'aeo-bob-lite' ), 'suggestionsApplied' => __( 'Suggestions Applied Successfully!', 'aeo-bob-lite' ), 'suggestionsAddedCount' => __( '{count} suggestion(s) have been added.', 'aeo-bob-lite' ), 'reloadHint' => __( 'The page will reload to show updated content.', 'aeo-bob-lite' ), 'applySelected' => __( 'Apply Selected Suggestions', 'aeo-bob-lite' ), 'reloading' => __( 'Reloading page...', 'aeo-bob-lite' ), 'failedApply' => __( 'Failed to apply suggestions', 'aeo-bob-lite' ), 'analyzingContent' => __( 'Analyzing content...', 'aeo-bob-lite' ), 'scoreImprovement' => __( 'Score Improvement', 'aeo-bob-lite' ), 'seoScore' => __( 'SEO Score', 'aeo-bob-lite' ), 'aeoScore' => __( 'AEO Score', 'aeo-bob-lite' ), 'points' => __( 'points', 'aeo-bob-lite' ), 'noChange' => __( 'No change', 'aeo-bob-lite' ), 'rescored' => __( 'Re-scored!', 'aeo-bob-lite' ), 'rescoreFailed' => __( 'Failed to re-score.', 'aeo-bob-lite' ), 'rescoreContent' => __( 'Re-Score Content', 'aeo-bob-lite' ), 'headingSuggestions' => __( 'Heading Suggestions', 'aeo-bob-lite' ), 'currentHeading' => __( 'CURRENT HEADING', 'aeo-bob-lite' ), 'headingAdvice' => __( 'Recommended new headings. Add them yourself where they fit; they are never inserted automatically.', 'aeo-bob-lite' ), 'noSuggestions' => __( 'The AI reviewed this post and found nothing that needs changing.', 'aeo-bob-lite' ), 'changesSaved' => __( 'Changes saved to the post', 'aeo-bob-lite' ), 'nothingChanged' => __( 'Nothing was changed', 'aeo-bob-lite' ), 'reloadingEditor' => __( 'Loading the updated post into the editor...', 'aeo-bob-lite' ), ), ); // Localize data to window object. wp_localize_script( 'aeo-bob-lite-optimizer-panel', 'aeoBobLiteOptimizerData', $data ); } }