get_api_key(); if (empty($api_key)) { return new WP_Error('aeo_bob_lite_no_api_key', __('OpenAI API key not configured. Please add it in Settings.', 'aeo-bob-lite')); } $text = AEO_Bob_Lite_Text::get_instance(); // Get author voice profile if available $author_profile = get_post_meta($post_id, '_aeo_bob_author_voice_profile', true); // Prepare content for analysis. Elementor keeps its content in _elementor_data; the HTML // version keeps headings, so the FAQ block can be found and left out (v1.5.79). $content = ''; if ($this->is_elementor_post($post_id)) { $content = $this->extract_html_from_elementor($post_id); } if (empty($content)) { $content = $post->post_content; } $title = $post->post_title; // v1.5.79 (P1): content rewrites never see the FAQ block; the FAQ step owns it. $content_sans_faq = $text->strip_faq_section($content); // v1.5.79 (V3): point of view of THIS post, FAQ excluded. $person = $text->detect_point_of_view($content_sans_faq); // Count words and existing headers for AI context $content_plain = wp_strip_all_tags($content); $word_count = str_word_count($content_plain); $existing_headers = preg_match_all('/]*>/i', $content); $existing_headings = $this->existing_heading_texts($content_sans_faq); // Existing FAQ questions, listed in the prompt so the AI doesn't repeat them $existing_faqs = $this->existing_faq_questions($content); // Build prompt for OpenAI - rewrites of existing text, heading ideas and new FAQs $prompt = "You are \"AEO Bob Lite\", an Answer Engine Optimization assistant.\n\n"; $prompt .= "Your job is to improve this article for:\n"; $prompt .= "- Human readers, and\n"; $prompt .= "- Answer engines / AI overviews\n\n"; $prompt .= "CONTENT ANALYSIS:\n"; $prompt .= "- Word count: " . $word_count . " words\n"; $prompt .= "- Existing headers: " . $existing_headers . "\n"; $prompt .= "- Needs structure: " . ($word_count > 1500 && $existing_headers < 3 ? "YES - HEADERS REQUIRED" : "No") . "\n\n"; $prompt .= "CORE PRINCIPLES:\n"; $prompt .= "1. IMPROVE EXISTING TEXT IN PLACE - every content item replaces sentences that are already in the article\n"; $prompt .= "2. NO REDUNDANCY - Don't restate what already exists\n"; $prompt .= "3. MATCH THE AUTHOR'S VOICE exactly\n\n"; if ($author_profile) { $prompt .= "AUTHOR'S VOICE (MUST MATCH EXACTLY):\n"; $prompt .= "- Style: " . ($author_profile['style'] ?? 'professional') . "\n"; $prompt .= "- Tone: " . ($author_profile['tone'] ?? 'informative') . "\n"; $prompt .= "- Reading Level: " . ($author_profile['reading_level'] ?? 'intermediate') . "\n\n"; } // v1.5.79 (V1): point-of-view hard constraint (Pro 2.5.7) $prompt .= $text->build_voice_constraints(array( 'person' => $person, 'tone' => is_array($author_profile) && !empty($author_profile['tone']) ? $author_profile['tone'] : 'informative', 'formality' => is_array($author_profile) && !empty($author_profile['style']) ? $author_profile['style'] : 'moderate', )) . "\n"; $prompt .= "WHAT TO PRODUCE:\n\n"; $prompt .= "Return EXACTLY this JSON structure:\n"; $prompt .= "{\n"; $prompt .= " \"headers\": [ // 0-3 heading suggestions (ONLY if content needs structure)\n"; $prompt .= " {\n"; $prompt .= " \"current\": \"Exact text of the existing heading this replaces, or empty string for a new heading\",\n"; $prompt .= " \"text\": \"Heading (3-8 words, clear section heading)\",\n"; $prompt .= " \"placement\": \"Brief description of where a new heading belongs (e.g., 'above the section about event details')\"\n"; $prompt .= " }\n"; $prompt .= " ],\n"; $prompt .= " \"content_suggestions\": [\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"One or more COMPLETE sentences copied VERBATIM from the article\",\n"; $prompt .= " \"rewrite\": \"Improved version (max 120 words)\"\n"; $prompt .= " },\n"; $prompt .= " { /* up to 2 more suggestions */ }\n"; $prompt .= " ],\n"; $prompt .= " \"faqs\": [\n"; $prompt .= " {\"q\": \"Question 1?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 2?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 3?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 4?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 5?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"},\n"; $prompt .= " {\"q\": \"Question 6?\", \"a\": \"Direct answer first, then clarification (max 90 words)\"}\n"; $prompt .= " ]\n"; $prompt .= "}\n\n"; $prompt .= "DETAILED GUIDANCE:\n\n"; // v1.5.79 (A7): headings either replace an existing heading or are advice only (Pro 2.3.63) $prompt .= "1) HEADINGS (0-3 suggestions - CRITICAL for long content):\n"; $prompt .= " - REQUIRED if content is >1500 words and has fewer than 3 headers\n"; $prompt .= " - SKIP if content already has 3+ good headers\n"; $prompt .= " - SKIP if content is <800 words\n"; $prompt .= " - To IMPROVE an existing heading: set \"current\" to its exact text (from EXISTING HEADINGS below) and \"text\" to the better heading. It replaces that heading in place.\n"; $prompt .= " - To RECOMMEND a new heading: leave \"current\" empty and say in \"placement\" where it belongs. New headings are shown to the author as recommendations; they are never inserted automatically.\n"; $prompt .= " - Natural language (not clickbaity)\n"; $prompt .= " - DO NOT suggest FAQ headers (those are separate)\n"; $prompt .= " - Return empty array [] ONLY if headers aren't needed\n"; if (!empty($existing_headings)) { $prompt .= " EXISTING HEADINGS:\n"; foreach (array_slice($existing_headings, 0, 20) as $heading) { $prompt .= " - " . $heading . "\n"; } } $prompt .= "\n"; // v1.5.79 (P2, A2): complete verbatim sentences only, no sourceless additions $prompt .= "2) CONTENT REWRITES (up to 3 items):\n"; $prompt .= " - \"source\": one or more COMPLETE sentences copied VERBATIM from the article below. It MUST start at the beginning of a sentence and end at the end of a sentence (with its final punctuation). NEVER a mid-sentence fragment. Stay within one paragraph.\n"; $prompt .= " - NEVER quote headings, FAQ questions or answers (lines starting with Q: or A:), or quoted passages.\n"; $prompt .= " - \"rewrite\": Your improved version (max 120 words), same paragraph shape as the source\n"; $prompt .= " - Every item REPLACES existing text. Do not suggest new paragraphs.\n"; // v1.5.80: Pro 2.6.9 final-text-only contract (reviewer notes leaked into posts as page text) $prompt .= " - \"rewrite\" MUST contain ONLY the final text exactly as it will appear on the page. Never describe the change in it (no \"Corrected...\", \"Capitalized...\", \"Fixed...\", \"This version...\", \"This paragraph...\", \"Consider adding...\", \"Note:\"). If you have no replacement text, leave the item out.\n"; $prompt .= " - ⚠️ CRITICAL: Read 2-3 sentences AROUND your target to check for redundancy\n"; $prompt .= " - If your rewrite makes the NEXT sentence redundant, INCLUDE IT in your source and consolidate\n"; $prompt .= " - Target sentences that are: unclear, too vague, grammatically weak, or missing key info\n"; $prompt .= " - DO NOT rewrite sentences that are already clear and well-written\n"; $prompt .= " - ✅ ENSURE all rewrites have perfect grammar and spelling\n\n"; $prompt .= " EXAMPLE REWRITE (simple):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"Paul Rodney Turner was a celibate monk for 14 years.\",\n"; $prompt .= " \"rewrite\": \"Paul Rodney Turner dedicated 14 years of his life as a celibate monk, deeply immersing himself in spiritual practice and vegetarian cuisine.\"\n"; $prompt .= " }\n\n"; $prompt .= " EXAMPLE REWRITE (with consolidation to avoid redundancy):\n"; $prompt .= " ORIGINAL TEXT:\n"; $prompt .= " \"This chapter provides some practical guidance on reconnecting to the natural world. I also aim to heighten the reader's appreciation for Intelligent design.\"\n"; $prompt .= " \n"; $prompt .= " BAD REWRITE (creates redundancy):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"This chapter provides some practical guidance on reconnecting to the natural world.\",\n"; $prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world and heightening the reader's appreciation for intelligent design.\"\n"; $prompt .= " }\n"; $prompt .= " ❌ Problem: The next sentence 'I also aim to heighten the reader's appreciation...' is now redundant!\n"; $prompt .= " \n"; $prompt .= " GOOD REWRITE (consolidates both complete sentences and keeps the author's \"I\"):\n"; $prompt .= " {\n"; $prompt .= " \"source\": \"This chapter provides some practical guidance on reconnecting to the natural world. I also aim to heighten the reader's appreciation for Intelligent design.\",\n"; $prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world, and in it I aim to heighten the reader's appreciation for intelligent design.\"\n"; $prompt .= " }\n"; $prompt .= " ✅ Solution: Included BOTH complete sentences in source, consolidated into ONE improved sentence in the author's own voice!\n\n"; $prompt .= "3) FAQS (6 Q&A pairs - we'll filter to best 3):\n"; $prompt .= " - Questions real users might ask about this topic\n"; $prompt .= " - Start answer with 1-2 sentence direct response\n"; $prompt .= " - Add 1-3 clarifying sentences (max 90 words total), so every answer has at least 2 sentences\n"; $prompt .= " - Answers contain ONLY the answer text as it will appear on the page - no notes about the answer, no repeat of the post title or of a paragraph already in the post\n"; $prompt .= " - Generate 6 diverse questions (more than needed for better variety)\n"; if (!empty($existing_faqs)) { $prompt .= " \n"; $prompt .= " ⚠️ EXISTING FAQ QUESTIONS (DO NOT DUPLICATE OR PARAPHRASE):\n"; foreach ($existing_faqs as $existing_q) { $prompt .= " - " . trim($existing_q) . "\n"; } $prompt .= " \n"; $prompt .= " Your questions MUST be meaningfully different from the above.\n"; $prompt .= " Do NOT rephrase these questions.\n"; $prompt .= " Focus on NEW angles, topics, or user needs not covered above.\n"; } $prompt .= "\n"; $prompt .= "CRITICAL RULES:\n"; $prompt .= "- Respond ONLY with valid JSON (no explanation outside JSON)\n"; $prompt .= "- \"source\" MUST be one or more COMPLETE sentences copied EXACTLY from the article below\n"; $prompt .= "- ❌ NEVER use placeholder text like \"original paragraph text to rewrite\"\n"; $prompt .= "- ✅ ALWAYS copy exact words from the article\n"; $prompt .= "- ⚠️ READ surrounding sentences - if your rewrite makes adjacent text redundant, CONSOLIDATE them\n"; $prompt .= "- Match author's voice and point of view exactly\n"; $prompt .= "- No redundancy with existing content\n"; $prompt .= "- No duplicate FAQ questions\n\n"; $prompt .= "Current Title:\n"; $prompt .= $title . "\n\n"; $prompt .= "Content to analyze (FAQ section and quoted passages left out):\n"; $prompt .= $this->prompt_text($content_sans_faq) . "\n"; // Call OpenAI API (v1.5.80: model from Settings / Recommended, Pro's request-parameter policy) $model = AEO_Bob_Lite_OpenAI::model(); $messages = array( array('role' => 'system', 'content' => 'You are AEO Bob Lite. Respond ONLY with valid JSON. Content items replace existing text: "source" MUST be one or more COMPLETE sentences copied VERBATIM from the user\'s article (never a fragment, never placeholder text, never from a quoted passage, a heading or the FAQ). If a rewrite makes an adjacent sentence redundant, include that sentence in "source" and consolidate. Never change the author\'s point of view. Headings: either improve an existing heading ("current" = its exact text) or recommend a new one ("current" empty); 3-8 words; never FAQ headings; return [] if not needed. Content: max 120 words. "rewrite" and FAQ answers hold only final page text, never notes about the change. FAQs: Generate 6 diverse questions (we filter to best 3), max 90 words each, must be MEANINGFULLY DIFFERENT from existing questions (not just rephrased). ✅ All content MUST have perfect grammar and spelling.'), array('role' => 'user', 'content' => $prompt), ); $response = AEO_Bob_Lite_OpenAI::post($api_key, AEO_Bob_Lite_OpenAI::chat_body($model, $messages, 2500, 0.7), array('timeout' => 60)); // v1.5.79 (P4): say what went wrong instead of a generic failure if (is_wp_error($response)) { /* translators: %s: error message from the HTTP request */ return new WP_Error('aeo_bob_lite_http', sprintf(__('Could not reach OpenAI: %s', 'aeo-bob-lite'), $response->get_error_message())); } $status_code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($status_code !== 200) { $provider_message = (is_array($body) && isset($body['error']['message'])) ? $body['error']['message'] : 'HTTP ' . $status_code; /* translators: 1: model id, 2: error message returned by OpenAI */ return new WP_Error('aeo_bob_lite_api', sprintf(__('OpenAI returned an error for model %1$s: %2$s', 'aeo-bob-lite'), $model, $provider_message)); } if (!is_array($body) || !isset($body['choices'][0]['message']['content'])) { return new WP_Error('aeo_bob_lite_invalid_response', __('OpenAI sent an unexpected response. Please try again.', 'aeo-bob-lite')); } // v1.5.79 (P3): a reply cut off at max_tokens is not valid JSON - say so if (isset($body['choices'][0]['finish_reason']) && $body['choices'][0]['finish_reason'] === 'length') { return new WP_Error('aeo_bob_lite_truncated', __('The AI response was cut off before it finished. Please try again.', 'aeo-bob-lite')); } $content_response = $body['choices'][0]['message']['content']; // Extract JSON from response (handle markdown code blocks) if (preg_match('/```json\s*(.*?)\s*```/s', $content_response, $matches)) { $content_response = $matches[1]; } elseif (preg_match('/```\s*(.*?)\s*```/s', $content_response, $matches)) { $content_response = $matches[1]; } $suggestions = json_decode($content_response, true); // Validate JSON parsing succeeded if (json_last_error() !== JSON_ERROR_NONE || !is_array($suggestions)) { return new WP_Error('aeo_bob_lite_bad_json', __('The AI response could not be read. Please try again.', 'aeo-bob-lite')); } return $this->parse_suggestions($suggestions, $content, $existing_headings, $title); } /** * v1.5.79: normalize and filter the AI's JSON before it reaches the user. * Drops sourceless items (A2), FAQ-targeting sources (P1), rewrites that drop the * author's first person (V2), FAQ near-duplicates (F1/F2), and turns heading * suggestions that don't name a real heading into advice (A7). */ private function parse_suggestions($suggestions, $content, $existing_headings, $title) { $text = AEO_Bob_Lite_Text::get_instance(); $result = array('titles' => array(), 'headers' => array(), 'content' => array(), 'faqs' => array()); // Headings: "current" must name a heading that exists, otherwise it is advice only $headings_by_key = array(); foreach ($existing_headings as $heading) { $headings_by_key[$this->heading_key($heading)] = $heading; } if (isset($suggestions['headers']) && is_array($suggestions['headers'])) { foreach ($suggestions['headers'] as $header) { if (is_string($header)) { $header = array('text' => $header); } if (!is_array($header) || empty($header['text']) || !is_string($header['text'])) { continue; } $heading_text = trim(wp_strip_all_tags($header['text'])); if ($heading_text === '' || preg_match('/\b(faq|frequently asked questions)\b/i', $heading_text)) { continue; } $current = isset($header['current']) && is_string($header['current']) ? $this->heading_key($header['current']) : ''; $current = ($current !== '' && isset($headings_by_key[$current])) ? $headings_by_key[$current] : ''; // v1.5.80 (Pro 2.6.9): no junk headings (under 3 words, already on the page, a copied body sentence) if ($text->heading_problem($heading_text, $current, array('html' => $content)) !== '') { continue; } $result['headers'][] = array( 'text' => $heading_text, 'placement' => isset($header['placement']) && is_string($header['placement']) ? wp_strip_all_tags($header['placement']) : '', 'current' => $current, ); } $result['headers'] = array_slice($result['headers'], 0, 3); } // Content rewrites $items = isset($suggestions['content_suggestions']) ? $suggestions['content_suggestions'] : (isset($suggestions['content']) ? $suggestions['content'] : array()); if (is_array($items)) { foreach ($items as $item) { if (!is_array($item) || !isset($item['source'], $item['rewrite']) || !is_string($item['source']) || !is_string($item['rewrite'])) { continue; } // v1.5.79: AI text is never trusted as markup - source is plain text, rewrite is kses'd $source = wp_strip_all_tags($text->strip_wrapping_quotes($item['source'])); $rewrite = wp_kses_post($text->strip_wrapping_quotes($item['rewrite'])); if ($this->content_item_problem($source, $rewrite) !== '') { continue; } $result['content'][] = array('source' => $source, 'rewrite' => $rewrite, 'type' => 'rewrite'); } $result['content'] = array_slice($result['content'], 0, 3); } // FAQs: word-set dedup against the post and against each other (Pro 2.5.5/2.6.1) $existing_sets = $text->extract_existing_faq_question_sets_all($content); if (isset($suggestions['faqs']) && is_array($suggestions['faqs'])) { foreach ($suggestions['faqs'] as $faq) { if (!is_array($faq)) { continue; } $question = isset($faq['q']) ? $faq['q'] : (isset($faq['question']) ? $faq['question'] : ''); $answer = isset($faq['a']) ? $faq['a'] : (isset($faq['answer']) ? $faq['answer'] : ''); if (!is_string($question) || !is_string($answer) || trim($question) === '' || trim($answer) === '') { continue; } if ($text->faq_question_is_duplicate($question, $existing_sets)) { continue; } // v1.5.80 (Pro 2.6.9): no notes, one-sentence answers, title copies or repeated paragraphs if ($text->faq_answer_problem($answer, array('html' => $content, 'title' => $title)) !== '') { continue; } $existing_sets[] = $text->faq_question_word_set($question); // v1.5.80 (Pro 2.6.8): the same allowlist the insert uses $result['faqs'][] = array('question' => wp_strip_all_tags($question), 'answer' => $text->faq_answer_kses($answer)); } $result['faqs'] = array_slice($result['faqs'], 0, 3); } if (isset($suggestions['titles']) && is_array($suggestions['titles'])) { $result['titles'] = array_slice(array_values(array_filter(array_map(array($this, 'clean_title'), $suggestions['titles']))), 0, 3); } // Add original title as first option if (!empty($result['titles'])) { array_unshift($result['titles'], $title); } // v1.5.79 (P5): nothing to change is a normal answer, not an error if (empty($result['headers']) && empty($result['content']) && empty($result['faqs']) && empty($result['titles'])) { $result['message'] = __('The AI reviewed this post and found nothing that needs changing.', 'aeo-bob-lite'); } return $result; } /** * v1.5.79: why a content item must not be applied ('' = fine). Used when parsing the * AI reply and again at apply time, so suggestions cached before an update are safe too. */ private function content_item_problem($source, $rewrite) { if (trim((string) $source) === '' || trim((string) $rewrite) === '') { return __('no original text to replace (new paragraphs are never inserted)', 'aeo-bob-lite'); } if (preg_match('/^[QA]:\s/i', trim((string) $source))) { return __('it targets FAQ text', 'aeo-bob-lite'); } $text = AEO_Bob_Lite_Text::get_instance(); // v1.5.80 (Pro 2.6.9): a reviewer note or commentary is never page text $meta = $text->rewrite_meta_problem($rewrite, $source); if ($meta !== '') { return $this->skip_reason_text($meta); } $words = $text->text_word_count($rewrite); if ($words < 6 && $text->text_word_count($source) > $words + 3) { return $this->skip_reason_text('too_short'); } if ($text->rewrite_drops_first_person($source, $rewrite)) { return __("the rewrite drops the author's first-person voice", 'aeo-bob-lite'); } return ''; } /** * v1.5.80: plain-words reason for a skip code from the Pro 2.6.9 matcher and guards * (same meaning as Pro's skip_reason_text()). */ private function skip_reason_text($reason) { $texts = array( 'not_found' => __('sentence not found in the post', 'aeo-bob-lite'), 'link' => __('the sentence contains a link the rewrite would remove', 'aeo-bob-lite'), 'ambiguous' => __('the original text appears in more than one place, so nothing was changed', 'aeo-bob-lite'), 'no_source' => __('no original text to replace (new paragraphs are never inserted)', 'aeo-bob-lite'), 'no_rewrite' => __('the suggestion has no replacement text', 'aeo-bob-lite'), 'meta_text' => __('the suggested text is a reviewer note, not page text', 'aeo-bob-lite'), 'commentary' => __('the rewrite contains reviewer commentary', 'aeo-bob-lite'), 'too_short' => __('the suggested text is too short to replace the original', 'aeo-bob-lite'), 'heading_short' => __('a heading needs at least 3 words', 'aeo-bob-lite'), 'heading_duplicate' => __('the page already has this heading', 'aeo-bob-lite'), 'heading_copies_body' => __('the heading copies a sentence from the body text', 'aeo-bob-lite'), 'incomplete' => __('the FAQ answer is empty', 'aeo-bob-lite'), 'faq_short' => __('an FAQ answer needs at least 2 sentences', 'aeo-bob-lite'), 'faq_title' => __('the FAQ answer is just the page title or a heading', 'aeo-bob-lite'), 'faq_repeats' => __('the FAQ answer repeats a paragraph that is already on the page', 'aeo-bob-lite'), 'verify_failed' => __('after the change the post did not hold the new text exactly once, so it was not saved', 'aeo-bob-lite'), 'duplicate_paragraph' => __('it would put the same paragraph on the page twice', 'aeo-bob-lite'), 'formatting' => __('formatting inside the text (a link or bold text) prevents a safe replacement', 'aeo-bob-lite'), 'blockquote' => __('it is inside a quotation, and quotations are never rewritten', 'aeo-bob-lite'), 'mid_sentence_start' => __('it starts in the middle of a sentence', 'aeo-bob-lite'), 'mid_sentence_end' => __('it ends in the middle of a sentence', 'aeo-bob-lite'), ); return isset($texts[$reason]) ? $texts[$reason] : $texts['not_found']; } /** * v1.5.79: plain text for the prompt - quoted passages left out (they must never be * rewritten), one block per line so sources stay inside a paragraph. */ private function prompt_text($html) { $html = preg_replace('#]*>.*?#is', "\n", (string) $html); $html = preg_replace('##i', "$0\n", $html); $html = preg_replace('##i', "\n", $html); $plain = wp_strip_all_tags($html); $plain = preg_replace("/[ \t]+\n/", "\n", $plain); return trim(preg_replace("/\n{3,}/", "\n\n", $plain)); } /** * v1.5.79: text of every h1-h6 heading in the content. */ private function existing_heading_texts($content) { $headings = array(); if (preg_match_all('/]*>(.*?)<\/h\1>/is', (string) $content, $m)) { foreach ($m[2] as $inner) { $heading = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($inner))); if ($heading !== '') { $headings[] = $heading; } } } return array_values(array_unique($headings)); } /** * v1.5.79: comparison key for heading text (case, whitespace and entities ignored). */ private function heading_key($heading) { $heading = html_entity_decode(wp_strip_all_tags((string) $heading), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return strtolower(trim(preg_replace('/\s+/', ' ', $heading))); } /** * v1.5.79 (F2): existing FAQ-style questions to list in the prompt - question headings * (h3/h4), bolded question lines (same patterns as Pro's extract_existing_faq_question_sets) * and Q:/Question: lines. */ private function existing_faq_questions($content) { $questions = array(); if (preg_match_all('/]*>(.*?)<\/h[34]>/is', (string) $content, $m)) { foreach ($m[1] as $q) { $q = trim(wp_strip_all_tags($q)); if (substr($q, -1) === '?') { $questions[] = $q; } } } if (preg_match_all('/<(?:strong|b)>\s*(?:Q:)?\s*([^<]{10,200}\?)\s*<\/(?:strong|b)>/i', (string) $content, $m)) { $questions = array_merge($questions, $m[1]); } if (preg_match_all('/(?:^|\n)\s*(?:Q:|Question:)\s*([^\n]+\?)/im', wp_strip_all_tags((string) $content), $m)) { $questions = array_merge($questions, $m[1]); } $questions = array_values(array_unique(array_map('trim', $questions))); return array_slice($questions, 0, 15); } /** * Apply selected suggestions to post content * * v1.5.79: the Pro 2.5.5 invariant - after apply the source text is gone and the rewrite is * present; an item that can't be matched safely is SKIPPED and reported, never appended. * New headings are advice only; heading suggestions only replace an existing heading. * FAQs are deduped against the post and added inside its FAQ section. * * @return array|false {success, message, stats, ...} - success is false when nothing changed */ public function apply_suggestions($post_id, $selected, $suggestions) { $post = get_post($post_id); if (!$post) { return false; } $picked = $this->pick_selected($selected, $suggestions); // ✅ LITE v1.5.36: Elementor one-click apply uses Elementor Document API // Elementor posts now update the actual document structure, not just post_content if ($this->is_elementor_post($post_id)) { // Use Elementor-specific apply method that updates the document structure return $this->apply_suggestions_to_elementor($post_id, $picked); } $text = AEO_Bob_Lite_Text::get_instance(); $content = $post->post_content; $title = $post->post_title; $stats = $this->new_apply_stats(); // v1.5.83: block-editor posts get block markup for anything inserted (FAQs) $blocks = $text->has_block_markup($content); // v1.5.86 repair: FAQ sections that Lite added more than once become one (live: post 681) $merge = $text->merge_faq_sections($content); if ($merge['merged'] > 0) { $content = $merge['content']; $stats['faq_merged'] = $merge['merged']; /* translators: %d: number of duplicate FAQ sections merged into the first one */ $stats['items'][] = sprintf(_n('FAQ sections: %d duplicate section merged into the first one', 'FAQ sections: %d duplicate sections merged into the first one', $merge['merged'], 'aeo-bob-lite'), $merge['merged']); } // Content rewrites: replace in place or skip foreach ($picked['content'] as $n => $item) { /* translators: %d: number of the rewrite in the list the user applied */ $label = sprintf(__('Rewrite %d', 'aeo-bob-lite'), $n + 1); $result = $this->apply_content_item($content, $item); if ($result['applied']) { $content = $result['content']; $stats['content_applied']++; $this->note_item($stats, $label, ''); } else { $this->note_skip($stats, 'content_skipped', $item['source'], $result['method']); $this->note_item($stats, $label, $result['method']); } } // Headings: only replace an existing heading foreach ($picked['headers'] as $n => $header) { /* translators: %d: number of the heading suggestion in the list the user applied */ $label = sprintf(__('Heading %d', 'aeo-bob-lite'), $n + 1); $result = $this->replace_heading($content, $header); if ($result['applied']) { $content = $result['content']; $stats['headings_replaced']++; $this->note_item($stats, $label, ''); } else { $this->note_skip($stats, 'headings_skipped', $header['text'], $result['method']); $this->note_item($stats, $label, $result['method']); } } // FAQs: dedup against the post, then insert inside the FAQ section $faq_pairs = array(); if (!empty($picked['faqs'])) { $faq = $this->build_faq_html($picked['faqs'], $text->extract_existing_faq_question_sets_all($content), $stats, array('html' => $content, 'title' => $title, 'blocks' => $blocks)); if ($faq['html'] !== '') { $content = $this->insert_faq_html($content, $faq, $blocks); $faq_pairs = $faq['pairs']; } } $title_changed = ($picked['title'] !== '' && $picked['title'] !== $title); $stats['title_changed'] = $title_changed; if ($picked['title'] !== '') { $this->note_item($stats, __('Title', 'aeo-bob-lite'), $title_changed ? '' : __('it is the same as the current title', 'aeo-bob-lite')); } // v1.5.83: nothing changed -> no save, no revision, no "success" if (!$this->apply_count($stats) || ($content === $post->post_content && !$title_changed)) { return array( 'success' => false, 'stats' => $stats, 'message' => $this->apply_message($stats), ); } // Remove kses filters temporarily to allow our content kses_remove_filters(); // Build update array $update_data = array('ID' => $post_id, 'post_content' => $content); // Update title if selected and different from original if ($title_changed) { $update_data['post_title'] = $picked['title']; } // Update post $result = wp_update_post($update_data, true); // Re-add kses filters kses_init_filters(); // Clear post cache clean_post_cache($post_id); if (is_wp_error($result)) { return false; } $this->save_faq_pairs($post_id, $faq_pairs); // Return array with success and updated content return array( 'success' => true, 'content' => $content, 'title' => $title_changed ? $picked['title'] : $title, 'title_changed' => $title_changed, 'stats' => $stats, 'message' => $this->apply_message($stats), ); } /** * v1.5.79: the selected items, normalized (shared by the classic and Elementor paths). */ private function pick_selected($selected, $suggestions) { $picked = array('title' => '', 'headers' => array(), 'content' => array(), 'faqs' => array()); foreach ($selected as $item) { $type = $item['type']; $index = intval($item['index']); if ($type === 'title' && isset($suggestions['titles'][$index])) { // v1.5.79: titles are saved with kses filters off - plain text only $picked['title'] = $this->clean_title($suggestions['titles'][$index]); } elseif ($type === 'header' && isset($suggestions['headers'][$index])) { $header = $suggestions['headers'][$index]; $picked['headers'][] = array( 'text' => is_array($header) && isset($header['text']) ? (string) $header['text'] : (is_string($header) ? $header : ''), 'current' => is_array($header) && isset($header['current']) ? (string) $header['current'] : '', ); } elseif ($type === 'content' && isset($suggestions['content'][$index])) { // Old cached format was a bare string with no source: it can only be skipped $content_item = $suggestions['content'][$index]; $picked['content'][] = array( 'source' => is_array($content_item) && isset($content_item['source']) ? (string) $content_item['source'] : '', 'rewrite' => is_array($content_item) && isset($content_item['rewrite']) ? (string) $content_item['rewrite'] : (is_string($content_item) ? $content_item : ''), ); } elseif ($type === 'faq' && isset($suggestions['faqs'][$index])) { $picked['faqs'][] = $suggestions['faqs'][$index]; } } return $picked; } /** * v1.5.79: an AI title as plain text ('' when there is nothing usable). */ private function clean_title($title) { return is_string($title) ? sanitize_text_field(wp_strip_all_tags($title)) : ''; } /** * v1.5.79: apply one content rewrite to an HTML string with Pro's replacement ladder. * * @return array {content, applied, method} */ private function apply_content_item($html, $item) { $problem = $this->content_item_problem($item['source'], $item['rewrite']); if ($problem !== '') { return array('content' => $html, 'applied' => false, 'method' => $problem); } $text = AEO_Bob_Lite_Text::get_instance(); $source = wp_strip_all_tags($item['source']); $rewrite = $this->clean_rewrite($item['rewrite']); $result = $text->apply_source_replacement($html, $source, $rewrite); $inserted = $rewrite; if (!$result['applied'] && in_array($result['reason'], array('not_found', 'formatting'), true)) { // v1.5.83: the sentence runs through inline tags (, , ...) - match the // text a reader sees and replace that span (live: billiardaimtrainer.com post 681). $inline = $text->apply_inline_aware_replacement($html, $source, $rewrite); if ($inline['applied'] || $inline['reason'] !== 'not_found' || $result['reason'] === 'not_found') { $result = $inline; } if ($result['applied']) { $inserted = $result['rewrite']; // with the emphasis it put back } } if (!$result['applied']) { return array('content' => $html, 'applied' => false, 'method' => $this->skip_reason_text($result['reason'])); } // v1.5.80 (Pro 2.6.9): replace-or-nothing - the rewrite lands once, the source is gone, // and no long paragraph ends up on the page twice $problem = $text->verify_replacement($html, $result['content'], $source, $inserted); if ($problem === '' && $text->new_duplicate_paragraphs($html, $result['content'])) { $problem = 'duplicate_paragraph'; } if ($problem !== '') { return array('content' => $html, 'applied' => false, 'method' => $this->skip_reason_text($problem)); } return $result; } /** * Clean AI rewrite text: decode entities, drop HTML comments and underline markup, kses. */ private function clean_rewrite($rewrite_text) { // First decode any HTML entities, then re-encode properly $rewrite_text = html_entity_decode($rewrite_text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Remove any malformed HTML comment tags or similar artifacts $rewrite_text = preg_replace('/]*>/', '', $rewrite_text); // STRIP UNDERLINE TAGS - Remove tags and underline styles $rewrite_text = preg_replace('/(.*?)<\/u>/i', '$1', $rewrite_text); $rewrite_text = preg_replace('/]*>(.*?)<\/u>/i', '$1', $rewrite_text); $rewrite_text = preg_replace('/style\s*=\s*["\'][^"\']*text-decoration:\s*underline[^"\']*["\']/i', '', $rewrite_text); // Now apply WordPress sanitization return wp_kses_post($rewrite_text); } /** * v1.5.79 (A7): replace the text of an existing heading. Never inserts a heading. * * @return array {content, applied, method} */ private function replace_heading($html, $header) { $current = trim((string) $header['current']); $new = trim(wp_strip_all_tags((string) $header['text'])); if ($current === '' || $new === '') { return array('content' => $html, 'applied' => false, 'method' => __('new headings are recommendations only - add them yourself where they fit', 'aeo-bob-lite')); } $problem = AEO_Bob_Lite_Text::get_instance()->heading_problem($new, $current, array('html' => $html)); if ($problem !== '') { return array('content' => $html, 'applied' => false, 'method' => $this->skip_reason_text($problem)); } $words = preg_split('/\s+/', $current, -1, PREG_SPLIT_NO_EMPTY); $inner = implode('\s+', array_map(function ($w) { return preg_quote($w, '/'); }, $words)); $pattern = '/(]*>)\s*' . $inner . '\s*(<\/h\2>)/iu'; if (!preg_match($pattern, $html, $m, PREG_OFFSET_CAPTURE)) { return array('content' => $html, 'applied' => false, 'method' => __('the heading to replace was not found', 'aeo-bob-lite')); } $replacement = $m[1][0] . esc_html($new) . $m[3][0]; return array('content' => substr_replace($html, $replacement, $m[0][1], strlen($m[0][0])), 'applied' => true, 'method' => 'heading'); } /** * v1.5.79 (A9, S1): FAQ items as

Question?

Answer

(v1.5.84: bold), skipping questions * that duplicate one already in the post or earlier in this batch. * * @return array {html, pairs} */ private function build_faq_html($faqs, $existing_sets, &$stats, array $ctx = array()) { $text = AEO_Bob_Lite_Text::get_instance(); $html = ''; $pairs = array(); $items = array(); // v1.5.87: (question, answer) for writing in the post's own FAQ style $blocks = !empty($ctx['blocks']); foreach (array_values($faqs) as $n => $faq) { /* translators: %d: number of the FAQ in the list the user applied */ $label = sprintf(__('FAQ %d', 'aeo-bob-lite'), $n + 1); $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : ''; $answer = isset($faq['answer']) ? (string) $faq['answer'] : ''; if ($question === '' || trim($answer) === '') { $this->note_item($stats, $label, __('the question or answer is empty', 'aeo-bob-lite')); continue; } if ($text->faq_question_is_duplicate($question, $existing_sets)) { // v1.5.83: re-applying the same FAQ never adds it twice $stats['faq_skipped']++; $this->note_item($stats, $label, __('this question is already in the post', 'aeo-bob-lite')); continue; } // v1.5.80 (Pro 2.6.9): also for suggestions cached before this version $problem = $text->faq_answer_problem($answer, $ctx); if ($problem !== '') { $this->note_skip($stats, 'faq_rejected', $question, $this->skip_reason_text($problem)); $this->note_item($stats, $label, $this->skip_reason_text($problem)); continue; } $existing_sets[] = $text->faq_question_word_set($question); // Strip underline tags from FAQ answers $answer = preg_replace('/(.*?)<\/u>/i', '$1', $answer); $answer = preg_replace('/]*>(.*?)<\/u>/i', '$1', $answer); $answer = preg_replace('/style\s*=\s*["\'][^"\']*text-decoration:\s*underline[^"\']*["\']/i', '', $answer); // v1.5.80 (Pro 2.6.8): answers are sanitized, not escaped - safe inline HTML kept (allowlist, noopener) // v1.5.83: block markup in block-editor posts (real Heading/Paragraph blocks, not one Classic block) // v1.5.84: the question text is wrapped in inside the

, so it stays bold on // themes whose heading weight is light (live: post 681). Answers stay normal paragraphs. if ($blocks) { $html .= "\n

" . esc_html($question) . "

\n\n\n" . "\n

" . $text->faq_answer_kses($answer) . "

\n\n\n"; } else { $html .= '

' . esc_html($question) . "

\n

" . $text->faq_answer_kses($answer) . "

\n"; } $items[] = array(esc_html($question), $text->faq_answer_kses($answer)); $pairs[] = array('question' => $question, 'answer' => wp_strip_all_tags($answer)); $stats['faq_added']++; /* translators: past tense for an FAQ that was put into the post */ $this->note_item($stats, $label, '', __('added', 'aeo-bob-lite')); } return array('html' => $html, 'items' => $items, 'pairs' => $pairs); } /** * v1.5.79 (A10): insert FAQ items at the end of the existing FAQ section (before the next *

), or add one "Frequently Asked Questions" section at the end of the post. */ private function insert_faq_html($html, $faq, $blocks = false) { // v1.5.86: the FAQ section is found by its marker's visible text (any case, any inline // markup, classic or block markup). v1.5.87: also a bold "FAQS" paragraph/block, and the // new questions are written in the style that section already uses ("Q:"/"A:" items or // question headings), at its end. A heading is only created when the post has no FAQ section. $faq_html = is_array($faq) ? $faq['html'] : (string) $faq; if (is_array($faq) && !empty($faq['items'])) { $merged = AEO_Bob_Lite_Text::get_instance()->insert_faq_items($html, $faq['items'], $blocks); if ($merged !== null) { return $merged; } } elseif (AEO_Bob_Lite_Text::get_instance()->faq_sections($html)) { $s = AEO_Bob_Lite_Text::get_instance()->faq_sections($html); $at = $s[0]['body_end']; $after = substr($html, $at); return rtrim(substr($html, 0, $at)) . "\n\n" . trim($faq_html) . (trim($after) !== '' ? "\n\n" . ltrim($after) : "\n"); } $heading = esc_html__('Frequently Asked Questions', 'aeo-bob-lite'); if ($blocks) { return rtrim($html) . "\n\n\n

" . $heading . "

\n\n\n" . rtrim($faq_html) . "\n"; } return $html . "\n\n

" . $heading . "

\n" . $faq_html; } /** * v1.5.79 (S1): remember applied FAQ pairs so the AEO score's FAQ Method 3 can count them. */ private function save_faq_pairs($post_id, $pairs) { if (empty($pairs)) { return; } $stored = json_decode((string) get_post_meta($post_id, '_aeo_bob_lite_faq_section', true), true); $stored = is_array($stored) ? $stored : array(); $known = array(); foreach ($stored as $pair) { $known[] = strtolower(trim(isset($pair['question']) ? (string) $pair['question'] : '')); } foreach ($pairs as $pair) { if (!in_array(strtolower(trim($pair['question'])), $known, true)) { $stored[] = $pair; } } update_post_meta($post_id, '_aeo_bob_lite_faq_section', wp_json_encode($stored)); } /** * v1.5.79 (A11): per-item apply counts. */ private function new_apply_stats() { return array( 'title_changed' => false, 'content_applied' => 0, 'content_skipped' => 0, 'headings_replaced' => 0, 'headings_skipped' => 0, 'faq_added' => 0, 'faq_skipped' => 0, 'faq_rejected' => 0, 'faq_merged' => 0, // v1.5.86 'skipped' => array(), 'items' => array(), // v1.5.83: one line per selected suggestion, in order ); } private function note_skip(&$stats, $counter, $label, $reason) { $stats[$counter]++; $label = wp_strip_all_tags((string) $label); if (function_exists('mb_strlen') && mb_strlen($label) > 60) { $label = mb_substr($label, 0, 57) . '...'; } $stats['skipped'][] = '"' . $label . '" - ' . $reason; } /** * v1.5.83: one honest line per selected suggestion: "Rewrite 1: applied", * "Rewrite 2: skipped, sentence not found in the post", "FAQ 1: added". */ private function note_item(&$stats, $label, $skip_reason, $done = '') { if ($skip_reason === '') { /* translators: 1: item label (e.g. "Rewrite 1"), 2: "applied" or "added" */ $stats['items'][] = sprintf(__('%1$s: %2$s', 'aeo-bob-lite'), $label, $done !== '' ? $done : __('applied', 'aeo-bob-lite')); } else { /* translators: 1: item label (e.g. "Rewrite 2"), 2: why it was skipped */ $stats['items'][] = sprintf(__('%1$s: skipped, %2$s', 'aeo-bob-lite'), $label, $skip_reason); } } private function apply_count($stats) { return ($stats['title_changed'] ? 1 : 0) + $stats['content_applied'] + $stats['headings_replaced'] + $stats['faq_added'] + (int) $stats['faq_merged']; } /** * v1.5.79 (A11): one readable sentence per outcome, for the panel. */ private function apply_message($stats) { // v1.5.83: the per-item lines are the message ("Rewrite 1: applied. FAQ 1: added.") $parts = array(); if (!$this->apply_count($stats)) { $parts[] = __('Nothing was changed.', 'aeo-bob-lite'); } foreach ($stats['items'] as $line) { $parts[] = $line . '.'; } return implode(' ', $parts); } /** * Check if post is built with Elementor * * @param int $post_id Post ID * @return bool True if post uses Elementor */ public function is_elementor_post($post_id) { // Check if Elementor plugin is active if (!defined('ELEMENTOR_VERSION') && !class_exists('\Elementor\Plugin')) { return false; } // Check if post was built with Elementor. v1.5.85: _elementor_data stays behind after // "Back to WordPress Editor"; only edit mode "builder" means Elementor shows the page. // Otherwise Lite scored (and applied suggestions to) that hidden old copy. if (get_post_meta($post_id, '_elementor_edit_mode', true) !== 'builder') { return false; } $elementor_data = get_post_meta($post_id, '_elementor_data', true); return !empty($elementor_data); } /** * Build HTML from Elementor data for scoring * * The scorers match HTML patterns (headings, schema, Q/A markup), so the * plain-text extractor above cannot be used for them. * * @param int $post_id Post ID * @return string HTML representation of the Elementor content */ public function extract_html_from_elementor($post_id) { $elementor_data = get_post_meta($post_id, '_elementor_data', true); if (empty($elementor_data)) { return ''; } $data = json_decode($elementor_data, true); if (!$data || !is_array($data)) { return ''; } $html = ''; $this->build_html_recursive($data, $html); return trim($html); } /** * Recursively build HTML from Elementor widget data * * @param array $data Elementor element data * @param string $html Reference to HTML accumulator */ private function build_html_recursive($data, &$html) { if (!is_array($data)) { return; } foreach ($data as $node) { if (!is_array($node)) { continue; } $settings = isset($node['settings']) && is_array($node['settings']) ? $node['settings'] : array(); $widget_type = isset($node['widgetType']) ? $node['widgetType'] : ''; if ($widget_type === 'heading' && !empty($settings['title']) && is_string($settings['title'])) { $tag = isset($settings['header_size']) ? strtolower($settings['header_size']) : 'h2'; if (!in_array($tag, array('h1', 'h2', 'h3', 'h4', 'h5', 'h6'), true)) { $tag = 'h2'; } $html .= "\n<{$tag}>" . $settings['title'] . ""; } elseif (!empty($settings['editor']) && is_string($settings['editor'])) { $html .= "\n" . $settings['editor']; } elseif (!empty($settings['html']) && is_string($settings['html'])) { // HTML widget can carry JSON-LD schema $html .= "\n" . $settings['html']; } elseif (!empty($settings['text']) && is_string($settings['text'])) { $html .= "\n

" . $settings['text'] . '

'; } // Accordion and toggle widgets hold FAQ pairs if (!empty($settings['tabs']) && is_array($settings['tabs'])) { foreach ($settings['tabs'] as $tab) { if (!is_array($tab)) { continue; } if (!empty($tab['tab_title']) && is_string($tab['tab_title'])) { $html .= "\n

" . $tab['tab_title'] . '

'; } if (!empty($tab['tab_content']) && is_string($tab['tab_content'])) { $html .= "\n" . $tab['tab_content']; } } } if (!empty($node['elements']) && is_array($node['elements'])) { $this->build_html_recursive($node['elements'], $html); } } } /** * Apply suggestions to Elementor document structure * ✅ v1.5.36: Use Elementor Document API to actually update visible content * * v1.5.79 (A8-A10): rewrites replace their source inside the text-editor widget that holds * it (Pro's server-side ladder, per widget) and never add sections; unmatched items are * skipped and reported. Heading suggestions only retitle an existing heading. FAQs are * deduped and placed at the end of the existing FAQ section, or in one new FAQ section. * * @param int $post_id Post ID * @param array $picked Selected suggestions (see pick_selected()) * @return array Result array */ private function apply_suggestions_to_elementor($post_id, $picked) { // Check if Elementor is available if (!class_exists('\Elementor\Plugin')) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Elementor Plugin class not available' ); } // Get the Elementor document try { $document = \Elementor\Plugin::$instance->documents->get($post_id); if (!$document) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Could not get Elementor document' ); } if (!$document->is_built_with_elementor()) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Post not built with Elementor' ); } } catch (Exception $e) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Exception: ' . $e->getMessage() ); } // Get current Elementor data $elements = $document->get_elements_data(); if (empty($elements)) { $elements = array(); } $text = AEO_Bob_Lite_Text::get_instance(); $stats = $this->new_apply_stats(); // Content rewrites: replace inside the text-editor widget that holds the source. // v1.5.80 (Pro 2.6.9): replace-or-nothing across the whole document - the source must be // in exactly one place, the rewrite must land once, and no paragraph may end up twice. foreach ($picked['content'] as $n => $item) { /* translators: %d: number of the rewrite in the list the user applied */ $label = sprintf(__('Rewrite %d', 'aeo-bob-lite'), $n + 1); $outcome = null; $problem = $this->content_item_problem($item['source'], $item['rewrite']); if ($problem === '') { $source = wp_strip_all_tags($item['source']); $rewrite = $this->clean_rewrite($item['rewrite']); $needle = $text->plain_canon($text->strip_wrapping_quotes($source)); $copies = 0; $this->walk_elementor_widgets($elements, function (&$widget) use ($text, $needle, &$copies) { if (isset($widget['settings']['editor']) && is_string($widget['settings']['editor'])) { $copies += $text->count_occurrences($text->plain_canon($widget['settings']['editor']), $needle); } return false; }); if ($copies > 1) { $problem = $this->skip_reason_text('ambiguous'); } } if ($problem === '') { $before_elements = $elements; $before_html = ''; $this->build_html_recursive($elements, $before_html); $this->walk_elementor_widgets($elements, function (&$widget) use ($text, $source, $rewrite, &$outcome) { if (!isset($widget['settings']['editor']) || !is_string($widget['settings']['editor'])) { return false; } $result = $text->apply_source_replacement($widget['settings']['editor'], $source, $rewrite); if ($result['applied']) { $result['reason'] = $text->verify_replacement($widget['settings']['editor'], $result['content'], $source, $rewrite); if ($result['reason'] !== '') { $result['applied'] = false; } else { $widget['settings']['editor'] = $result['content']; } $outcome = $result; return true; } // Keep the most telling reason: a blocked match beats "not found" if ($outcome === null || $result['reason'] !== 'not_found') { $outcome = $result; } return false; }); if ($outcome !== null && $outcome['applied']) { $after_html = ''; $this->build_html_recursive($elements, $after_html); if ($text->new_duplicate_paragraphs($before_html, $after_html)) { $elements = $before_elements; $outcome = array('applied' => false, 'reason' => 'duplicate_paragraph'); } } if ($outcome !== null && !$outcome['applied']) { $problem = $this->skip_reason_text($outcome['reason']); } } if ($outcome !== null && $outcome['applied']) { $stats['content_applied']++; $this->note_item($stats, $label, ''); } else { $why = $problem !== '' ? $problem : $this->skip_reason_text('not_found'); $this->note_skip($stats, 'content_skipped', $item['source'], $why); $this->note_item($stats, $label, $why); } } // Headings: retitle an existing heading widget (or a heading inside a text widget) foreach ($picked['headers'] as $n => $header) { /* translators: %d: number of the heading suggestion in the list the user applied */ $label = sprintf(__('Heading %d', 'aeo-bob-lite'), $n + 1); $replaced = false; $reason = ''; if (trim($header['current']) === '') { $reason = $this->replace_heading('', $header)['method']; } else { $key = $this->heading_key($header['current']); $new = trim(wp_strip_all_tags($header['text'])); $document_html = ''; $this->build_html_recursive($elements, $document_html); $junk = $text->heading_problem($new, $header['current'], array('html' => $document_html)); if ($junk !== '') { $this->note_skip($stats, 'headings_skipped', $header['text'], $this->skip_reason_text($junk)); $this->note_item($stats, $label, $this->skip_reason_text($junk)); continue; } $replaced = $this->walk_elementor_widgets($elements, function (&$widget) use ($key, $new, $header) { if (isset($widget['widgetType']) && $widget['widgetType'] === 'heading' && isset($widget['settings']['title']) && is_string($widget['settings']['title']) && $this->heading_key($widget['settings']['title']) === $key) { $widget['settings']['title'] = esc_html($new); return true; } if (isset($widget['settings']['editor']) && is_string($widget['settings']['editor'])) { $result = $this->replace_heading($widget['settings']['editor'], $header); if ($result['applied']) { $widget['settings']['editor'] = $result['content']; return true; } } return false; }); $reason = __('the heading to replace was not found', 'aeo-bob-lite'); } if ($replaced) { $stats['headings_replaced']++; $this->note_item($stats, $label, ''); } else { $this->note_skip($stats, 'headings_skipped', $header['text'], $reason); $this->note_item($stats, $label, $reason); } } // FAQs: dedup against the document, then place them in its FAQ section $faq_pairs = array(); $post = get_post($post_id); $post_title = $post ? $post->post_title : ''; if (!empty($picked['faqs'])) { $document_html = ''; $this->build_html_recursive($elements, $document_html); $faq = $this->build_faq_html($picked['faqs'], $text->extract_existing_faq_question_sets_all($document_html), $stats, array('html' => $document_html, 'title' => $post_title)); if ($faq['html'] !== '') { if (!$this->place_faq_in_elementor($elements, $faq['html'])) { $elements[] = $this->elementor_section(array( $this->elementor_widget('heading', array('title' => esc_html__('Frequently Asked Questions', 'aeo-bob-lite'), 'header_size' => 'h2')), $this->elementor_widget('text-editor', array('editor' => $faq['html'])), )); } $faq_pairs = $faq['pairs']; } } $title_changed = false; if ($picked['title'] !== '' && $post && $picked['title'] !== $post->post_title) { $result = wp_update_post(array('ID' => $post_id, 'post_title' => $picked['title']), true); $title_changed = !is_wp_error($result); } $stats['title_changed'] = $title_changed; if ($picked['title'] !== '') { $this->note_item($stats, __('Title', 'aeo-bob-lite'), $title_changed ? '' : __('it is the same as the current title', 'aeo-bob-lite')); } $document_changed = ($stats['content_applied'] + $stats['headings_replaced'] + $stats['faq_added']) > 0; if ($document_changed) { // Save the updated document try { $document->save(array('elements' => $elements)); } catch (Exception $e) { return array( 'success' => false, 'elementor_updated' => false, 'error' => 'Failed to save: ' . $e->getMessage(), 'message' => esc_html__('Elementor could not save the changes.', 'aeo-bob-lite'), ); } $this->save_faq_pairs($post_id, $faq_pairs); } clean_post_cache($post_id); return array( 'success' => $this->apply_count($stats) > 0, 'elementor_updated' => $document_changed, 'title_changed' => $title_changed, 'title' => $picked['title'], 'stats' => $stats, 'message' => $this->apply_message($stats), ); } /** * v1.5.79: call $callback(&$widget) on every widget until it returns true. * * @return bool true if the callback returned true for some widget */ private function walk_elementor_widgets(array &$elements, $callback) { foreach ($elements as &$element) { if (!is_array($element)) { continue; } if (isset($element['elType']) && $element['elType'] === 'widget' && $callback($element)) { return true; } if (!empty($element['elements']) && is_array($element['elements']) && $this->walk_elementor_widgets($element['elements'], $callback)) { return true; } } unset($element); return false; } /** * v1.5.79 (A10): put FAQ HTML inside the document's FAQ section - into a text widget that * holds an FAQ

, or as a new text widget at the end of the run of widgets that follows * an FAQ heading widget (stopping at the next h1/h2 heading). * * @return bool false when the document has no FAQ section */ private function place_faq_in_elementor(array &$elements, $faq_html) { $count = count($elements); for ($i = 0; $i < $count; $i++) { $element = $elements[$i]; if (!is_array($element)) { continue; } $settings = isset($element['settings']) && is_array($element['settings']) ? $element['settings'] : array(); if (isset($element['elType']) && $element['elType'] === 'widget') { if (isset($settings['editor']) && is_string($settings['editor']) && AEO_Bob_Lite_Text::get_instance()->faq_sections($settings['editor'])) { $elements[$i]['settings']['editor'] = $this->insert_faq_html($settings['editor'], $faq_html); return true; } if ($this->is_elementor_faq_heading($element)) { $at = $count; for ($j = $i + 1; $j < $count; $j++) { if (is_array($elements[$j]) && isset($elements[$j]['widgetType']) && $elements[$j]['widgetType'] === 'heading' && in_array(isset($elements[$j]['settings']['header_size']) ? strtolower($elements[$j]['settings']['header_size']) : 'h2', array('h1', 'h2'), true)) { $at = $j; break; } } array_splice($elements, $at, 0, array($this->elementor_widget('text-editor', array('editor' => $faq_html)))); return true; } } if (!empty($element['elements']) && is_array($element['elements'])) { $children = $element['elements']; if ($this->place_faq_in_elementor($children, $faq_html)) { $elements[$i]['elements'] = $children; return true; } } } return false; } private function is_elementor_faq_heading($element) { return isset($element['widgetType']) && $element['widgetType'] === 'heading' && isset($element['settings']['title']) && is_string($element['settings']['title']) && preg_match('/\b(faq|frequently asked questions)\b/i', wp_strip_all_tags($element['settings']['title'])); } private function elementor_widget($type, $settings) { return array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'widget', 'widgetType' => $type, 'settings' => $settings, 'elements' => array(), ); } private function elementor_section($widgets) { return array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'section', 'settings' => array(), 'elements' => array( array( 'id' => \Elementor\Utils::generate_random_string(), 'elType' => 'column', 'settings' => array('_column_size' => 100), 'elements' => $widgets, ), ), ); } }