get_api_key();
if (empty($api_key)) {
return new WP_Error('no_api_key', __('OpenAI API key not configured. Please add it in Settings.', 'aeo-bob-lite'));
}
$response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => json_encode(array(
'model' => 'gpt-4o-mini',
'messages' => array(
array('role' => 'system', 'content' => 'You are an SEO and AEO expert helping optimize content for both search engines and AI systems like ChatGPT, Claude, and Perplexity.'),
array('role' => 'user', 'content' => $prompt)
),
'max_tokens' => $max_tokens,
'temperature' => 0.7,
)),
'timeout' => 30,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
// Validate JSON parsing succeeded
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_Error('json_error', __('Invalid JSON response from API', 'aeo-bob-lite'));
}
if ($status_code !== 200) {
$error_msg = isset($data['error']['message']) ? $data['error']['message'] : __('API request failed', 'aeo-bob-lite');
return new WP_Error('api_error', $error_msg);
}
if (!isset($data['choices'][0]['message']['content'])) {
return new WP_Error('invalid_response', __('Invalid API response', 'aeo-bob-lite'));
}
return $data['choices'][0]['message']['content'];
}
/**
* Generate optimization suggestions (3 content + 3 FAQ)
*/
public function generate_suggestions($post_id) {
$post = get_post($post_id);
if (!$post) {
return false;
}
$api_key = $this->get_api_key();
if (empty($api_key)) {
return false;
}
// Get author voice profile if available
$author_profile = get_post_meta($post_id, '_aeo_bob_author_voice_profile', true);
// Prepare content for analysis - check if Elementor post
$is_elementor = $this->is_elementor_post($post_id);
if ($is_elementor) {
// Extract text from Elementor data for analysis
$content = $this->extract_text_from_elementor($post_id);
if (empty($content)) {
// Fallback to post_content if Elementor extraction fails
$content = $post->post_content;
}
} else {
$content = $post->post_content;
}
$title = $post->post_title;
// 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);
// Extract existing FAQs to avoid duplicates (IMPROVED: more comprehensive extraction)
$existing_faqs = array();
// Pattern 1: FAQ section headers - extract questions from various tags
if (preg_match_all('/]*>(.*?faq.*?)<\/h[23]>/is', $content)) {
// Extract questions from headings, strong, list items, paragraphs
if (preg_match_all('/<(?:h[3-6]|strong|b|li|p)[^>]*>([^<]*\?[^<]*)<\/(?:h[3-6]|strong|b|li|p)>/i', $content, $questions)) {
$existing_faqs = array_merge($existing_faqs, $questions[1]);
}
}
// Pattern 2: Q: or Question: prefix patterns (common in plain text FAQs)
if (preg_match_all('/(?:^|\n)\s*(?:Q:|Question:)\s*([^\n]+\?)/im', wp_strip_all_tags($content), $q_matches)) {
$existing_faqs = array_merge($existing_faqs, $q_matches[1]);
}
// Normalize and deduplicate
$existing_faqs = array_unique(array_map('trim', $existing_faqs));
$existing_faqs = array_slice($existing_faqs, 0, 15); // Limit to 15
// Build prompt for OpenAI - Request REWRITES with source text + new content
$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. REWRITES over additions - Identify weak/unclear paragraphs and improve them\n";
$prompt .= "2. NEW CONTENT when existing content is already good but missing topics\n";
$prompt .= "3. NO REDUNDANCY - Don't restate what already exists\n";
$prompt .= "4. 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";
}
$prompt .= "WHAT TO PRODUCE:\n\n";
$prompt .= "Return EXACTLY this JSON structure:\n";
$prompt .= "{\n";
$prompt .= " \"headers\": [ // 0-3 headers (ONLY if content needs structure)\n";
$prompt .= " {\n";
$prompt .= " \"text\": \"H2 Header (3-8 words, clear section heading)\",\n";
$prompt .= " \"placement\": \"Brief description of where this header will go (e.g., 'above the section about event details')\"\n";
$prompt .= " }\n";
$prompt .= " ],\n";
$prompt .= " \"content_suggestions\": [\n";
$prompt .= " {\n";
$prompt .= " \"source\": \"Original paragraph text to rewrite (or empty string if this is new content)\",\n";
$prompt .= " \"rewrite\": \"Improved version (max 120 words)\",\n";
$prompt .= " \"type\": \"rewrite\" or \"addition\"\n";
$prompt .= " },\n";
$prompt .= " { /* 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";
$prompt .= "1) HEADERS (0-3 H2 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 .= " - SUGGEST when: long content lacks structure, topics aren't clearly separated\n";
$prompt .= " - Each header must have TWO fields:\n";
$prompt .= " * \"text\": Clear, descriptive section heading (3-8 words)\n";
$prompt .= " * \"placement\": Where it will go (e.g., \"above the section about food philosophy\", \"above the event details\")\n";
$prompt .= " - Help structure content for answer engines\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\n";
$prompt .= "2) CONTENT SUGGESTIONS (3 items - mix of rewrites & additions):\n";
$prompt .= " \n";
$prompt .= " **For REWRITES** (improve existing weak paragraphs):\n";
$prompt .= " - \"source\": MUST copy 4-15 words EXACTLY from the article below (NOT placeholder text!)\n";
$prompt .= " - \"rewrite\": Your improved version (max 120 words)\n";
$prompt .= " - \"type\": \"rewrite\"\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 rewrite and consolidate\n";
$prompt .= " - Target paragraphs that are: unclear, too vague, grammatically weak, or missing key info\n";
$prompt .= " - DO NOT rewrite paragraphs 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 .= " \"type\": \"rewrite\"\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\",\n";
$prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world. It aims to enhance appreciation for intelligent design.\",\n";
$prompt .= " \"type\": \"rewrite\"\n";
$prompt .= " }\n";
$prompt .= " ❌ Problem: Next sentence 'I also aim to heighten appreciation...' is now redundant!\n";
$prompt .= " \n";
$prompt .= " GOOD REWRITE (consolidates to avoid redundancy):\n";
$prompt .= " {\n";
$prompt .= " \"source\": \"This chapter provides some practical guidance on reconnecting to the natural world. I also aim to heighten\",\n";
$prompt .= " \"rewrite\": \"This chapter offers practical advice on reconnecting with the natural world while enhancing your appreciation for intelligent design through the sacred geometry found in whole foods.\",\n";
$prompt .= " \"type\": \"rewrite\"\n";
$prompt .= " }\n";
$prompt .= " ✅ Solution: Included BOTH sentences in source, consolidated into ONE improved sentence!\n\n";
$prompt .= " **For NEW CONTENT** (when existing content is good but incomplete):\n";
$prompt .= " - \"source\": \"\" (empty string)\n";
$prompt .= " - \"rewrite\": New paragraph to add (max 120 words)\n";
$prompt .= " - \"type\": \"addition\"\n";
$prompt .= " - Add NEW value: practical example, missing definition, simple practice, modern application\n";
$prompt .= " - DO NOT duplicate existing concepts\n";
$prompt .= " - ✅ ENSURE all new content has perfect grammar and spelling\n\n";
$prompt .= " EXAMPLE ADDITION:\n";
$prompt .= " {\n";
$prompt .= " \"source\": \"\",\n";
$prompt .= " \"rewrite\": \"Food Yoga workshops typically begin with a meditation to set intention, followed by hands-on preparation of plant-based dishes using mindful techniques.\",\n";
$prompt .= " \"type\": \"addition\"\n";
$prompt .= " }\n\n";
$prompt .= " **Strategy**: Prioritize rewrites first. Consolidate adjacent sentences if your rewrite makes them redundant. Only suggest additions if all existing content is already good.\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)\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 .= "- For rewrites: \"source\" MUST be ACTUAL TEXT copied from the article below (4-15 words minimum)\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 .= "- For consolidation: Include MORE of the source text (2+ sentences) and merge into one improved paragraph\n";
$prompt .= "- For additions: Leave source empty (\"\") and set type to \"addition\"\n";
$prompt .= "- Match author's voice 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:\n";
$prompt .= wp_strip_all_tags($content) . "\n";
// Call OpenAI API
$response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
'timeout' => 60,
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => json_encode(array(
'model' => 'gpt-4o-mini',
'messages' => array(
array('role' => 'system', 'content' => 'You are AEO Bob Lite. CRITICAL: For rewrites, read surrounding context (2-3 sentences). If your rewrite makes adjacent sentences redundant, CONSOLIDATE them by including MORE source text (2+ sentences) and merging into one improved paragraph. The "source" field MUST contain ACTUAL TEXT (4-15 words minimum) copied EXACTLY from the user\'s article. NEVER use placeholder text. For additions, use empty source "". Respond ONLY with valid JSON. Format: {source: "actual text from article", rewrite: "improved", type: "rewrite" or "addition"}. Headers: 0-3 suggestions (ONLY if content needs structure), 3-8 words each, avoid FAQ headers, return [] if not needed. Content: max 120 words. 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)
),
'temperature' => 0.7,
'max_tokens' => 1500
))
));
if (is_wp_error($response)) {
return false;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
// Validate JSON parsing succeeded
if (json_last_error() !== JSON_ERROR_NONE) {
return false;
}
if (!isset($body['choices'][0]['message']['content'])) {
return false;
}
$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 || !$suggestions) {
return false;
}
// Normalize headers (handle both old string format and new object format)
if (isset($suggestions['headers']) && is_array($suggestions['headers'])) {
$normalized_headers = array();
foreach ($suggestions['headers'] as $header) {
if (is_string($header)) {
// Old format: just a string
$normalized_headers[] = array(
'text' => $header,
'placement' => 'throughout the content'
);
} elseif (is_array($header) && isset($header['text'])) {
// New format: object with text + placement
$normalized_headers[] = array(
'text' => $header['text'],
'placement' => isset($header['placement']) ? $header['placement'] : 'throughout the content'
);
}
}
$suggestions['headers'] = $normalized_headers;
}
// Normalize content_suggestions key (handle both old and new format)
if (isset($suggestions['content_suggestions'])) {
$suggestions['content'] = $suggestions['content_suggestions'];
} elseif (!isset($suggestions['content'])) {
return false;
}
// Check for FAQs (handle both "faqs" and potential "faq" key)
if (!isset($suggestions['faqs'])) {
return false;
}
// Normalize FAQ structure (handle both "q"/"a" and "question"/"answer" formats)
foreach ($suggestions['faqs'] as $index => $faq) {
if (isset($faq['q']) && isset($faq['a'])) {
$suggestions['faqs'][$index] = array(
'question' => $faq['q'],
'answer' => $faq['a']
);
}
}
// Ensure limits and deduplicate FAQs
$suggestions['titles'] = isset($suggestions['titles']) ? array_slice($suggestions['titles'], 0, 3) : array();
$suggestions['content'] = array_slice($suggestions['content'], 0, 3);
// Deduplicate FAQs against existing content (filter 6 down to best 3)
$suggestions['faqs'] = $this->deduplicate_faqs($suggestions['faqs'], $existing_faqs);
// Add original title as first option
if (!empty($suggestions['titles'])) {
array_unshift($suggestions['titles'], $title);
}
return $suggestions;
}
/**
* Deduplicate FAQ suggestions against existing FAQs
* Uses normalized string comparison with similarity threshold
*/
private function deduplicate_faqs($suggested_faqs, $existing_faqs) {
if (empty($existing_faqs)) {
return array_slice($suggested_faqs, 0, 3); // No existing, return first 3
}
// Normalize existing questions
$normalized_existing = array();
foreach ($existing_faqs as $q) {
$normalized_existing[] = $this->normalize_faq_question($q);
}
// Filter suggested FAQs
$filtered = array();
foreach ($suggested_faqs as $faq) {
$question = isset($faq['question']) ? $faq['question'] : (isset($faq['q']) ? $faq['q'] : '');
if (empty($question)) continue;
$normalized_q = $this->normalize_faq_question($question);
// Check similarity against existing
$is_duplicate = false;
foreach ($normalized_existing as $existing_normalized) {
if ($this->faq_similarity($normalized_q, $existing_normalized) > 0.75) {
$is_duplicate = true;
break;
}
}
if (!$is_duplicate) {
$filtered[] = $faq;
}
}
return array_slice($filtered, 0, 3); // Return best 3
}
/**
* Normalize FAQ question for comparison
*/
private function normalize_faq_question($question) {
$q = strtolower(trim($question));
// Remove question marks
$q = str_replace('?', '', $q);
// Remove punctuation
$q = preg_replace('/[^\w\s]/', '', $q);
// Collapse whitespace
$q = preg_replace('/\s+/', ' ', $q);
return trim($q);
}
/**
* Simple similarity score (0-1) using Levenshtein distance
*/
private function faq_similarity($str1, $str2) {
$max_len = max(strlen($str1), strlen($str2));
if ($max_len === 0) return 1.0;
$distance = levenshtein(substr($str1, 0, 255), substr($str2, 0, 255)); // Levenshtein limit
return 1 - ($distance / $max_len);
}
/**
* Extract existing FAQ questions from content
* Detects questions under FAQ headings and Q:/Question: patterns
*
* @param string $content Post content HTML
* @return array Normalized FAQ questions
*/
private function extract_existing_faq_questions_from_content($content) {
$existing_questions = array();
// Pattern 1: Under "Frequently Asked Questions" / "FAQ" / "FAQs" headings
if (preg_match('/]*>\s*(?:frequently asked questions|faqs?)\s*<\/h[23]>/i', $content, $faq_header_match, PREG_OFFSET_CAPTURE)) {
$faq_section_start = $faq_header_match[0][1];
$faq_section = substr($content, $faq_section_start);
// Extract questions from H3/H4 tags or strong tags containing "Q:"
if (preg_match_all('/]*>(?:\s*)?\s*Q:\s*([^<]+?)(?:<\/strong>)?\s*<\/h[34]>/i', $faq_section, $h_matches)) {
$existing_questions = array_merge($existing_questions, $h_matches[1]);
}
// Extract from Q: ... patterns
if (preg_match_all('/\s*Q:\s*([^<]+?)<\/strong>/i', $faq_section, $strong_matches)) {
$existing_questions = array_merge($existing_questions, $strong_matches[1]);
}
// Extract from headings ending with ? (common FAQ pattern)
if (preg_match_all('/]*>([^<]+\?)\s*<\/h[3-6]>/i', $faq_section, $heading_q_matches)) {
$existing_questions = array_merge($existing_questions, $heading_q_matches[1]);
}
}
// Pattern 2: Q: or Question: prefix patterns anywhere in content
if (preg_match_all('/(?:^|\n||
)\s*(?:Q:|Question:)\s*([^\n<]+\?)/im', $content, $q_matches)) {
$existing_questions = array_merge($existing_questions, $q_matches[1]);
}
// Normalize all questions for comparison
$normalized = array();
foreach ($existing_questions as $q) {
$normalized[] = $this->normalize_faq_question($q);
}
return array_unique($normalized);
}
/**
* Apply selected suggestions to post content
*/
public function apply_suggestions($post_id, $selected, $suggestions) {
$post = get_post($post_id);
if (!$post) {
return false;
}
// ✅ 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, $selected, $suggestions);
}
$content = $post->post_content;
$title = $post->post_title;
$title_changed = false;
// Separate title, header, content and FAQ selections
$selected_title = null;
$header_suggestions = array();
$content_suggestions = array();
$faq_suggestions = array();
foreach ($selected as $item) {
$type = $item['type'];
$index = intval($item['index']);
if ($type === 'title' && isset($suggestions['titles'][$index])) {
$selected_title = $suggestions['titles'][$index];
} elseif ($type === 'header' && isset($suggestions['headers'][$index])) {
$header_suggestions[] = $suggestions['headers'][$index];
} elseif ($type === 'content' && isset($suggestions['content'][$index])) {
// Handle both old string format and new object format
$content_item = $suggestions['content'][$index];
if (is_array($content_item)) {
// New format: {source, rewrite, type}
$content_suggestions[] = $content_item;
$preview = is_string($content_item['rewrite']) ? substr($content_item['rewrite'], 0, 100) : '[object]';
} else {
// Old format: just a string - convert to new format
$content_suggestions[] = array(
'source' => '',
'rewrite' => $content_item,
'type' => 'addition'
);
}
} elseif ($type === 'faq' && isset($suggestions['faqs'][$index])) {
$faq_suggestions[] = $suggestions['faqs'][$index];
}
}
// Find if FAQ section already exists
$faq_position = false;
if (preg_match('/]*>\s*frequently asked questions\s*<\/h2>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
$faq_position = $matches[0][1];
}
// Add content suggestions FIRST - handle both rewrites and additions
if (!empty($content_suggestions)) {
foreach ($content_suggestions as $index => $item) {
$suggestion_type = isset($item['type']) ? $item['type'] : 'addition';
$source_text = isset($item['source']) ? trim($item['source']) : '';
$rewrite_text = isset($item['rewrite']) ? $item['rewrite'] : (is_string($item) ? $item : '');
if (!empty($source_text)) {
}
if (empty($rewrite_text)) {
continue;
}
// Clean the rewrite text - strip any malformed HTML tags/entities
// 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
$clean_rewrite = wp_kses_post($rewrite_text);
if ($suggestion_type === 'rewrite' && !empty($source_text)) {
// REWRITE: Smart sentence-level replacement
// Strategy: Use simple string replacement in the HTML
// Works regardless of whether content is wrapped in
tags or not
// Strip HTML from source for matching
$source_plain = wp_strip_all_tags($source_text);
$source_plain_clean = trim($source_plain);
// Try to find the source text in the content
$source_pos = stripos($content, $source_plain_clean);
if ($source_pos !== false) {
// Find the sentence boundaries around the source text
// SAFETY: Limit search to +/- 300 chars from source position to prevent over-expansion
$search_radius = 300;
// Look backwards for sentence start (., !, ?, or paragraph start)
$sentence_start = max(0, $source_pos - $search_radius);
$found_start = false;
for ($i = $source_pos - 1; $i >= $sentence_start; $i--) {
$char = substr($content, $i, 1);
// Check for sentence terminators
if (in_array($char, ['.', '!', '?'])) {
// Found terminator, skip any whitespace after it
$sentence_start = $i + 1;
while ($sentence_start < $source_pos && ctype_space(substr($content, $sentence_start, 1))) {
$sentence_start++;
}
$found_start = true;
break;
}
// Check for HTML tags that indicate paragraph/block boundaries
if ($i > 4 && (substr($content, $i-4, 4) === '
' ||
substr($content, $i-5, 5) === '' ||
substr($content, $i-5, 5) === '' ||
substr($content, $i-5, 5) === '')) {
$sentence_start = $i;
$found_start = true;
break;
}
}
// If no sentence start found within radius, just replace the source text itself
if (!$found_start) {
$sentence_start = $source_pos;
}
// Look forward for sentence end
$source_end = $source_pos + strlen($source_plain_clean);
$sentence_end = min(strlen($content), $source_end + $search_radius);
$found_end = false;
for ($i = $source_end; $i < $sentence_end; $i++) {
$char = substr($content, $i, 1);
if (in_array($char, ['.', '!', '?'])) {
$sentence_end = $i + 1;
$found_end = true;
break;
}
// Check for block-ending tags
if ($i < strlen($content) - 4 && (substr($content, $i, 4) === '' ||
substr($content, $i, 4) === '
' ||
substr($content, $i, 4) === '' ||
substr($content, $i, 4) === '')) {
$sentence_end = $i;
$found_end = true;
break;
}
}
// If no sentence end found within radius, just go to end of source text
if (!$found_end) {
$sentence_end = $source_end;
}
// Extract the text to replace
$sentence_to_replace = substr($content, $sentence_start, $sentence_end - $sentence_start);
$sentence_to_replace_trimmed = trim($sentence_to_replace);
// STRICT SAFETY CHECK: Don't replace if too long (likely over-expansion)
// Max 200 chars = about 2-3 sentences max
if (strlen($sentence_to_replace_trimmed) > 200) {
// Fall back to just replacing the source text itself
$sentence_to_replace_trimmed = $source_plain_clean;
}
// Wrap rewrite appropriately
$clean_rewrite_final = $clean_rewrite;
// Don't add
tags if we're replacing inside existing structure
if (stripos($sentence_to_replace, '
') === false &&
stripos($sentence_to_replace, '
') === false &&
stripos($clean_rewrite, '') === false) {
$clean_rewrite_final = '
' . $clean_rewrite . '
';
}
// Do the replacement - find first occurrence and replace it
$content_before_length = strlen($content);
$content = preg_replace(
'/' . preg_quote($sentence_to_replace_trimmed, '/') . '/i',
$clean_rewrite_final,
$content,
1 // Only replace first occurrence
);
$content_after_length = strlen($content);
if ($content_after_length !== $content_before_length) {
} else {
$suggestion_type = 'addition';
}
} else {
$suggestion_type = 'addition';
}
}
if ($suggestion_type === 'addition') {
// ADDITION: Insert before FAQ section if it exists, otherwise append
// Wrap in tags if not already wrapped
if (stripos($clean_rewrite, '
') === false) {
$clean_rewrite = "\n\n
" . $clean_rewrite . "
";
} else {
$clean_rewrite = "\n\n" . $clean_rewrite;
}
if ($faq_position !== false) {
// Re-find FAQ position in case content changed
if (preg_match('/]*>\s*frequently asked questions\s*<\/h2>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
$current_faq_pos = $matches[0][1];
$content = substr($content, 0, $current_faq_pos) . $clean_rewrite . "\n\n" . substr($content, $current_faq_pos);
} else {
$content .= $clean_rewrite;
}
} else {
$content .= $clean_rewrite;
}
}
}
}
// Smart header placement - distribute throughout content, avoid FAQ sections
// IMPORTANT: Do this AFTER content suggestions so there's more content to distribute across
if (!empty($header_suggestions)) {
// Find FAQ section position to avoid placing headers there
$faq_start_pos = false;
if (preg_match('/]*>.*?faq.*?<\/h[23]>/is', $content, $faq_match, PREG_OFFSET_CAPTURE)) {
$faq_start_pos = $faq_match[0][1];
}
// Split content into paragraphs
$paragraphs = preg_split('/(<\/p>)/i', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
$total_paragraphs = count($paragraphs);
// Safety check: Need at least 2 paragraphs to insert headers
if ($total_paragraphs < 4) { // 4 = at least 2 paragraphs (paragraph +
closing tag each)
// Fallback: Insert all headers at the beginning
$header_html = '';
foreach ($header_suggestions as $i => $header) {
// Handle both old format (string) and new format (object with text + placement)
$header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : '');
if (empty($header_text)) continue;
$clean_header = wp_kses_post($header_text);
if (stripos($header_text, '') === false && stripos($header_text, '') === false) {
$header_html .= "" . $clean_header . "
\n\n";
} else {
$header_html .= $clean_header . "\n\n";
}
}
$content = $header_html . $content;
} else {
// Calculate insertion points (distribute headers evenly)
$header_count = count($header_suggestions);
$interval = max(2, floor($total_paragraphs / ($header_count + 1)) * 2); // Multiply by 2 because we have paragraph + closing tag
// Reconstruct content to calculate byte positions
$running_position = 0;
$paragraph_positions = array();
foreach ($paragraphs as $i => $para) {
$paragraph_positions[$i] = $running_position;
$running_position += strlen($para);
}
// Insert headers at calculated intervals
$inserted = 0;
$skipped_count = 0;
for ($i = 0; $i < $header_count && $inserted < $header_count; $i++) {
$insert_position = ($i + 1) * $interval;
// Make sure we don't go past the end
if ($insert_position >= $total_paragraphs) {
$skipped_count++;
continue;
}
// Check if this position is after FAQ section start
$byte_position = $paragraph_positions[$insert_position] ?? 0;
if ($faq_start_pos !== false && $byte_position >= $faq_start_pos) {
$skipped_count++;
continue; // Skip this position, it's in or after FAQ section
}
$header = $header_suggestions[$i];
// Handle both old format (string) and new format (object with text + placement)
$header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : '');
if (empty($header_text)) {
continue;
}
$clean_header = wp_kses_post($header_text);
// Wrap in H2 tags if not already wrapped
if (stripos($header_text, '') === false && stripos($header_text, '') === false) {
$header_html = "\n\n" . $clean_header . "
\n\n";
} else {
$header_html = "\n\n" . $clean_header . "\n\n";
}
// Insert header after the paragraph at this position
$paragraphs[$insert_position] .= $header_html;
$inserted++;
}
// Reconstruct content
$content = implode('', $paragraphs);
// If no headers were inserted due to FAQ collision, insert them before FAQ
if ($inserted === 0 && $header_count > 0) {
$header_html = '';
foreach ($header_suggestions as $i => $header) {
// Handle both old format (string) and new format (object with text + placement)
$header_text = is_string($header) ? $header : (isset($header['text']) ? $header['text'] : '');
if (empty($header_text)) continue;
$clean_header = wp_kses_post($header_text);
if (stripos($header_text, '') === false && stripos($header_text, '') === false) {
$header_html .= "" . $clean_header . "
\n\n";
} else {
$header_html .= $clean_header . "\n\n";
}
}
if ($faq_start_pos !== false) {
// Insert before FAQ
$content = substr($content, 0, $faq_start_pos) . $header_html . substr($content, $faq_start_pos);
} else {
// No FAQ, append to end
$content .= "\n\n" . $header_html;
}
}
}
}
// Add FAQ section if there are FAQ suggestions
if (!empty($faq_suggestions)) {
// Extract existing FAQ questions from content to avoid duplicates
$existing_faq_questions = $this->extract_existing_faq_questions_from_content($content);
// Deduplicate incoming FAQ suggestions against existing FAQs
$unique_faq_suggestions = array();
$deduped_within_request = array(); // Track within this request
foreach ($faq_suggestions as $faq) {
$question = isset($faq['question']) ? $faq['question'] : '';
if (empty($question)) {
continue;
}
$normalized_q = $this->normalize_faq_question($question);
// Check if already exists in content
$exists_in_content = false;
foreach ($existing_faq_questions as $existing_normalized) {
if ($this->faq_similarity($normalized_q, $existing_normalized) > 0.75) {
$exists_in_content = true;
break;
}
}
// Check if already added in this batch (in case Apply is clicked multiple times)
$duplicate_in_batch = false;
foreach ($deduped_within_request as $batch_normalized) {
if ($this->faq_similarity($normalized_q, $batch_normalized) > 0.75) {
$duplicate_in_batch = true;
break;
}
}
// Only add if not duplicate
if (!$exists_in_content && !$duplicate_in_batch) {
$unique_faq_suggestions[] = $faq;
$deduped_within_request[] = $normalized_q;
}
}
// Only add FAQ section if we have at least one new unique FAQ
if (!empty($unique_faq_suggestions)) {
// Check if FAQ header exists after potential content insertion
$has_faq_header = (stripos($content, 'frequently asked questions') !== false ||
stripos($content, 'FAQ
') !== false);
// Add FAQ header if not already present
if (!$has_faq_header) {
$content .= "\n\nFrequently Asked Questions
";
}
// Add each unique FAQ at the end
foreach ($unique_faq_suggestions as $faq) {
$clean_question = esc_html($faq['question']);
// Strip underline tags from FAQ answers
$answer_text = $faq['answer'];
$answer_text = preg_replace('/(.*?)<\/u>/i', '$1', $answer_text);
$answer_text = preg_replace('/]*>(.*?)<\/u>/i', '$1', $answer_text);
$answer_text = preg_replace('/style\s*=\s*["\'][^"\']*text-decoration:\s*underline[^"\']*["\']/i', '', $answer_text);
$clean_answer = wp_kses_post($answer_text);
$content .= "\n\nQ: " . $clean_question . "
\n
A: " . $clean_answer . "
";
}
}
}
// Remove kses filters temporarily to allow our content
kses_remove_filters();
// Build update array
$update_data = array('ID' => $post_id);
// Update title if selected and different from original
if ($selected_title && $selected_title !== $title) {
$update_data['post_title'] = $selected_title;
$title_changed = true;
}
// Update content
$update_data['post_content'] = $content;
// 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;
}
// Return array with success and updated content
return array(
'success' => true,
'content' => $content,
'title' => $selected_title ? $selected_title : $title,
'title_changed' => $title_changed
);
}
/**
* Check if post is built with Elementor
*
* @param int $post_id Post ID
* @return bool True if post uses Elementor
*/
private 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
$elementor_data = get_post_meta($post_id, '_elementor_data', true);
return !empty($elementor_data);
}
/**
* Extract plain text from Elementor data for analysis
*
* @param int $post_id Post ID
* @return string Extracted text content
*/
private function extract_text_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 '';
}
$text = '';
$this->extract_text_recursive($data, $text);
return trim($text);
}
/**
* Recursively extract text from Elementor widget data
*
* @param array $data Elementor widget data
* @param string $text Reference to text accumulator
*/
private function extract_text_recursive($data, &$text) {
if (is_array($data)) {
foreach ($data as $key => $value) {
// Common Elementor text fields
if (in_array($key, array('editor', 'text', 'title', 'description', 'content', 'caption', 'heading', 'subheading'))) {
if (is_string($value)) {
$text .= ' ' . wp_strip_all_tags($value);
}
} elseif ($key === 'settings' && is_array($value)) {
// Extract from settings object
foreach ($value as $setting_key => $setting_value) {
if (in_array($setting_key, array('editor', 'text', 'title', 'description', 'content', 'caption', 'heading', 'subheading'))) {
if (is_string($setting_value)) {
$text .= ' ' . wp_strip_all_tags($setting_value);
}
}
}
} elseif (is_array($value)) {
$this->extract_text_recursive($value, $text);
}
}
}
}
/**
* Apply suggestions to Elementor document structure
* ✅ v1.5.36: Use Elementor Document API to actually update visible content
*
* @param int $post_id Post ID
* @param array $selected Selected suggestions
* @param array $suggestions All suggestions
* @return array|bool Result array or false on failure
*/
private function apply_suggestions_to_elementor($post_id, $selected, $suggestions) {
// 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();
}
// Build content to append
$new_sections = array();
// Process selected suggestions
foreach ($selected as $item) {
$type = $item['type'];
$index = intval($item['index']);
if ($type === 'faq' && isset($suggestions['faqs'][$index])) {
$faq = $suggestions['faqs'][$index];
// Create a new section with FAQ
$new_sections[] = 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' => array(
array(
'id' => \Elementor\Utils::generate_random_string(),
'elType' => 'widget',
'widgetType' => 'heading',
'settings' => array(
'title' => esc_html($faq['question']),
'header_size' => 'h3'
)
),
array(
'id' => \Elementor\Utils::generate_random_string(),
'elType' => 'widget',
'widgetType' => 'text-editor',
'settings' => array(
'editor' => wp_kses_post($faq['answer'])
)
)
)
)
)
);
} elseif ($type === 'content' && isset($suggestions['content'][$index])) {
$content_item = $suggestions['content'][$index];
$content_text = is_array($content_item) && isset($content_item['rewrite'])
? $content_item['rewrite']
: $content_item;
// Create a new section with content
$new_sections[] = 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' => array(
array(
'id' => \Elementor\Utils::generate_random_string(),
'elType' => 'widget',
'widgetType' => 'text-editor',
'settings' => array(
'editor' => wp_kses_post($content_text)
)
)
)
)
)
);
}
}
if (empty($new_sections)) {
return array(
'success' => false,
'elementor_updated' => false,
'error' => 'No sections were created from selected suggestions'
);
}
// Append new sections to existing elements
$elements = array_merge($elements, $new_sections);
// 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()
);
}
return array(
'success' => true,
'elementor_updated' => true,
'sections_added' => count($new_sections)
);
}
}