]*>(.*?)<\/a>/is', $result_html, $matches)) { $final_anchor = wp_strip_all_tags($matches[1]); } $process_html = function ($html) use (&$global_modified, $donor_chunk, $mode, $original_target, $result_html, $fallback, $target_url, &$final_mode, &$final_anchor, $allow_append_fallback) { $current_mode = $mode; $current_original_target = $original_target; $current_result_html = $result_html; $get_anchor = function ($html_fragment) { if (preg_match('/]*>(.*?)<\/a>/is', $html_fragment, $matches)) { return wp_strip_all_tags($matches[1]); } return ''; }; $current_anchor = $get_anchor($current_result_html); $last_error = null; while (true) { libxml_use_internal_errors(true); $dom = new DOMDocument(); $html_wrapper = '
' . $html . '
'; $dom->loadHTML($html_wrapper, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); $xpath = new DOMXPath($dom); $paragraphs = $xpath->query('//p | //li | //blockquote'); $search_text = $this->normalize_text($donor_chunk); $search_prefix = mb_substr($search_text, 0, 150); $target_node = null; $best_match = null; $highest_percent = 0; foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, $search_prefix) !== false) { $target_node = $p; break; } similar_text(mb_substr($p_text, 0, 200), mb_substr($search_text, 0, 200), $percent); if ($percent > $highest_percent) { $highest_percent = $percent; $best_match = $p; } } if (!$target_node && $highest_percent > 75) { $target_node = $best_match; } if (!$target_node) { // Try suffix matching to locate the natural end block of a split/nested list chunk $search_suffix = mb_substr($search_text, -120); if (mb_strlen($search_suffix) >= 40) { foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, $search_suffix) !== false) { $target_node = $p; break; } } } // Fallback to prefix matching for split chunk beginning if (!$target_node) { foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, mb_substr($search_prefix, 0, 80)) !== false) { $target_node = $p; break; } } } } if (!$target_node) { return new WP_Error('paragraph_missing', 'Could not locate the exact paragraph. Similarity score was only ' . round($highest_percent) . '%.'); } // Check if paragraph already contains a link to this target URL to prevent duplicates $existing_links = $xpath->query('.//a', $target_node); if ($existing_links) { foreach ($existing_links as $link) { $href = $link->getAttribute('href'); if ($this->urls_are_equal($href, $target_url)) { return new WP_Error('link_already_exists', 'A link to this URL already exists in this paragraph.'); } } } // Prepare replacement HTML containing the actual target URL $sentence_with_url = preg_replace( '/href=[\'"]#[\'"]/', 'href="' . esc_url($target_url) . '" data-ailm="ai"', $current_result_html ); // Apply the insertion using the current mode $result = null; if ($current_mode === 'in_text') { $result = $this->apply_in_text($dom, $target_node, $current_original_target, $sentence_with_url); } elseif ($current_mode === 'rewrite') { $result = $this->apply_rewrite($dom, $target_node, $current_original_target, $sentence_with_url); } else { return new WP_Error('invalid_mode', 'Invalid insertion mode: ' . $current_mode); } $is_valid = !is_wp_error($result); if ($is_valid) { // Check for nested links inside the modified paragraph $nested_links = $xpath->query('.//a//a', $target_node); if ($nested_links->length > 0) { $is_valid = false; $last_error = new WP_Error('nested_links', 'Insertion resulted in nested links.'); } } else { $last_error = $result; } if ($is_valid) { // Successfully applied! Save final mode and final anchor $final_mode = $current_mode; $final_anchor = $current_anchor ? $current_anchor : $get_anchor($sentence_with_url); $wrapper = $dom->getElementById('ailm-wrapper'); $new_content = ''; foreach ($wrapper->childNodes as $child) { $new_content .= $dom->saveHTML($child); } $global_modified = true; return $new_content; } // Transition with fallback priority sequence: in_text -> rewrite -> error if ($current_mode === 'in_text') { if (!empty($fallback) && is_array($fallback) && isset($fallback['mode']) && $fallback['mode'] === 'rewrite') { $current_mode = 'rewrite'; $current_original_target = $fallback['original_target'] ?? ''; $current_result_html = $fallback['result_html'] ?? ''; $current_anchor = $fallback['exact_anchor'] ?? $current_anchor; continue; } else { return $last_error ? $last_error : new WP_Error('fallback_failed', 'Link insertion failed: in_text mode could not be applied and no rewrite fallback is available.'); } } elseif ($current_mode === 'rewrite') { return $last_error ? $last_error : new WP_Error('fallback_failed', 'Link insertion failed: rewrite mode could not be applied.'); } else { return $last_error ? $last_error : new WP_Error('fallback_failed', 'Prioritized fallback failed.'); } } }; if ($is_elementor) { AILM_Elementor_Handler::process_content($donor_id, $process_html); } else { $new_content = $process_html($donor_post->post_content); if (is_wp_error($new_content)) { return $new_content; } if ($global_modified) { wp_update_post([ 'ID' => $donor_id, 'post_content' => $new_content ]); } } if (!$global_modified) { return new WP_Error('paragraph_missing', 'Could not locate the exact paragraph in the donor content. The post may have been modified.'); } return [ 'success' => true, 'mode' => $final_mode, 'anchor' => $final_anchor, ]; } /** * In-Text mode: replace an existing phrase within the paragraph with the anchor HTML. */ private function apply_in_text($dom, $target_node, $original_target, $replacement_html) { $para_inner = $this->get_inner_html($dom, $target_node); $matched_fragment = null; $new_inner = $this->smart_replace_in_html($para_inner, $original_target, $replacement_html, $matched_fragment); if ($new_inner === null) { return new WP_Error('phrase_not_found', 'Could not locate the anchor phrase in the paragraph.'); } if ($matched_fragment !== null && preg_match('/<\/a>|]*>/iu', $matched_fragment)) { return new WP_Error('existing_link_present', 'Inserting the link would overwrite an existing link.'); } // Preserve original casing from the HTML source when the LLM altered it. if ($matched_fragment !== null) { $html_plain = wp_strip_all_tags($matched_fragment); $html_plain = html_entity_decode($html_plain, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $llm_plain = html_entity_decode($original_target, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ( mb_strtolower($html_plain, 'UTF-8') === mb_strtolower($llm_plain, 'UTF-8') && $html_plain !== $llm_plain ) { // Rebuild replacement with correct casing from the HTML source if (preg_match('/^(]*>)/u', $replacement_html, $tag_match)) { $corrected_replacement = $tag_match[1] . esc_html($html_plain) . ''; $corrected_inner = $this->smart_replace_in_html($para_inner, $original_target, $corrected_replacement); if ($corrected_inner !== null) { $new_inner = $corrected_inner; } } } } $this->replace_node_content($dom, $target_node, $new_inner); return true; } /** * Rewrite mode: replace an existing sentence with the rewritten anchor HTML. */ private function apply_rewrite($dom, $target_node, $original_target, $replacement_html) { $normalized_para = $this->normalize_text($target_node->textContent); $normalized_orig = $this->normalize_text($original_target); if (mb_strlen($normalized_orig) > mb_strlen($normalized_para) * 0.9) { $xpath = new DOMXPath($dom); $existing_links = $xpath->query('.//a', $target_node); if ($existing_links && $existing_links->length > 0) { return new WP_Error('existing_link_present', 'Rewriting the entire paragraph would overwrite existing links.'); } $this->replace_node_content($dom, $target_node, $replacement_html); return true; } $para_inner = $this->get_inner_html($dom, $target_node); $matched_fragment = null; $new_inner = $this->smart_replace_in_html($para_inner, $original_target, $replacement_html, $matched_fragment); if ($new_inner === null) { return new WP_Error('sentence_not_found', 'Could not locate the original sentence in the paragraph.'); } if ($matched_fragment !== null && preg_match('/<\/a>|]*>/iu', $matched_fragment)) { return new WP_Error('existing_link_present', 'Rewriting would overwrite an existing link.'); } $this->replace_node_content($dom, $target_node, $new_inner); return true; } /** * Replace a text fragment within HTML content, handling inline HTML tags and entities gracefully. * Uses normalize_text() for matching instead of blind str_replace. * * @param string $html The HTML content to search within. * @param string $search_text The plain text to find. * @param string $replacement_html The HTML to replace the found text with. * @param string|null &$matched_fragment Populated with the exact HTML fragment that was matched. * @return string|null Modified HTML, or null if the fragment was not found. */ private function smart_replace_in_html($html, $search_text, $replacement_html, &$matched_fragment = null) { $normalized = $this->normalize_text($search_text); $words = array_values(array_filter(preg_split('/\s+/u', $normalized))); if (empty($words)) { return null; } $word_patterns = []; foreach ($words as $word) { $word_patterns[] = $this->build_word_pattern($word); } $inter = '[\s.,;:!?\'"$€£¥₽₹₩₿¢%\x{2013}\x{2014}]*(?:(?:<[^>]+>|&[^;]+;)[\s.,;:!?\'"$€£¥₽₹₩₿¢%\x{2013}\x{2014}]*)*'; if (count($word_patterns) <= 8) { $pattern = '/(' . implode($inter, $word_patterns) . ')/iu'; if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $matched_text = $match[0]; $offset = $match[1]; // Check if this match is inside an existing tag or intersects any tag $before_match = substr($html, 0, $offset); $open_count = preg_match_all('//iu', $before_match); if ($open_count > $close_count || preg_match('/<\/a>|]*>/iu', $matched_text)) { continue; } $matched_fragment = $matched_text; $after_text = substr($html, $offset + strlen($matched_text)); if (strpos($after_text, '.') === 0) { $trimmed_replacement = rtrim($replacement_html); if ( preg_match('/\.<\/a>\s*$/ui', $trimmed_replacement) || preg_match('/\.\s*$/ui', $trimmed_replacement) || preg_match('/<\/a>\.\s*$/ui', $trimmed_replacement) ) { $after_text = substr($after_text, 1); } } return substr($html, 0, $offset) . $replacement_html . $after_text; } } return $this->fallback_replace_in_html($html, $search_text, $replacement_html, $matched_fragment); } $first_patterns = array_slice($word_patterns, 0, 4); $last_patterns = array_slice($word_patterns, -3); $first_pattern = implode($inter, $first_patterns); $last_pattern = implode($inter, $last_patterns); $pattern = '/(' . $first_pattern . '.+?' . $last_pattern . ')/ius'; if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $matched_text = $match[0]; $offset = $match[1]; // Check if this match is inside an existing tag or intersects any tag $before_match = substr($html, 0, $offset); $open_count = preg_match_all('//iu', $before_match); if ($open_count > $close_count || preg_match('/<\/a>|]*>/iu', $matched_text)) { continue; } $matched_fragment = $matched_text; $after_text = substr($html, $offset + strlen($matched_text)); if (strpos($after_text, '.') === 0) { $trimmed_replacement = rtrim($replacement_html); if ( preg_match('/\.<\/a>\s*$/ui', $trimmed_replacement) || preg_match('/\.\s*$/ui', $trimmed_replacement) || preg_match('/<\/a>\.\s*$/ui', $trimmed_replacement) ) { $after_text = substr($after_text, 1); } } return substr($html, 0, $offset) . $replacement_html . $after_text; } } return $this->fallback_replace_in_html($html, $search_text, $replacement_html, $matched_fragment); } /** * Fallback replacement when regex-based smart_replace_in_html fails. * Maps plain-text positions back to the original HTML string, * correctly handling inline tags like , , etc. * * @param string $html The HTML content to search within. * @param string $search_text The plain text to find. * @param string $replacement_html The HTML to replace the found text with. * @param string|null &$matched_fragment Populated with the exact HTML fragment that was matched. * @return string|null Modified HTML, or null if the fragment was not found. */ private function fallback_replace_in_html($html, $search_text, $replacement_html, &$matched_fragment = null) { $normalized_search = $this->normalize_text($search_text); if ($normalized_search === '') { return null; } $visible = []; $html_len = mb_strlen($html, 'UTF-8'); $i = 0; while ($i < $html_len) { $char = mb_substr($html, $i, 1, 'UTF-8'); if ($char === '<') { $close_pos = mb_strpos($html, '>', $i, 'UTF-8'); if ($close_pos !== false) { $i = $close_pos + 1; continue; } } if ($char === '&') { $semi_pos = mb_strpos($html, ';', $i, 'UTF-8'); if ($semi_pos !== false && ($semi_pos - $i) <= 10) { $entity = mb_substr($html, $i, $semi_pos - $i + 1, 'UTF-8'); $decoded = html_entity_decode($entity, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $visible[] = ['html_start' => $i, 'html_end' => $semi_pos + 1, 'char' => $decoded]; $i = $semi_pos + 1; continue; } } $visible[] = ['html_start' => $i, 'html_end' => $i + 1, 'char' => $char]; $i++; } $normalized = ''; $norm_to_visible = []; foreach ($visible as $vi => $vc) { $lower = mb_strtolower($vc['char'], 'UTF-8'); $len = mb_strlen($lower, 'UTF-8'); for ($j = 0; $j < $len; $j++) { $c = mb_substr($lower, $j, 1, 'UTF-8'); if (preg_match('/[\p{L}\p{N}\s]/u', $c)) { $norm_to_visible[mb_strlen($normalized, 'UTF-8')] = $vi; $normalized .= $c; } } } $search_len = mb_strlen($normalized_search, 'UTF-8'); $offset = 0; while (($pos = mb_strpos($normalized, $normalized_search, $offset, 'UTF-8')) !== false) { $start_vi = $norm_to_visible[$pos] ?? null; $end_vi = $norm_to_visible[$pos + $search_len - 1] ?? null; if ($start_vi !== null && $end_vi !== null) { $html_start = $visible[$start_vi]['html_start']; $html_end = $visible[$end_vi]['html_end']; $matched_text = mb_substr($html, $html_start, $html_end - $html_start, 'UTF-8'); // Check if this match is inside an existing tag or intersects any tag $before_match = mb_substr($html, 0, $html_start, 'UTF-8'); $open_count = preg_match_all('//iu', $before_match); if ($open_count > $close_count || preg_match('/<\/a>|]*>/iu', $matched_text)) { $offset = $pos + 1; continue; // Skip this match as it is inside a link or contains a link } $matched_fragment = $matched_text; $after_text = mb_substr($html, $html_end, null, 'UTF-8'); if (mb_strpos($after_text, '.') === 0) { $trimmed_replacement = rtrim($replacement_html); if ( preg_match('/\.<\/a>\s*$/ui', $trimmed_replacement) || preg_match('/\.\s*$/ui', $trimmed_replacement) || preg_match('/<\/a>\.\s*$/ui', $trimmed_replacement) ) { $after_text = mb_substr($after_text, 1, null, 'UTF-8'); } } return mb_substr($html, 0, $html_start, 'UTF-8') . $replacement_html . $after_text; } $offset = $pos + 1; } return null; } /** * Build a regex pattern for a single normalized word, * allowing optional intra-word punctuation (apostrophes, hyphens, curly quotes, HTML entities). * * @param string $word A normalized word (letters and digits only). * @return string */ private function build_word_pattern($word) { $chars = preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY); if (empty($chars)) { return ''; } $intra = "(?:['.,\x{2019}\x{2018}\x{2013}\x{2014}-]|&[^;]+;)*"; $pattern = preg_quote($chars[0], '/'); $count = count($chars); for ($i = 1; $i < $count; $i++) { $pattern .= $intra . preg_quote($chars[$i], '/'); } return $pattern; } /** * Get the inner HTML of a DOMNode. * * @param DOMDocument $dom * @param DOMNode $node * @return string */ private function get_inner_html($dom, $node) { $html = ''; foreach ($node->childNodes as $child) { $html .= $dom->saveHTML($child); } return $html; } /** * Replace all content of a DOMNode with new HTML. * * @param DOMDocument $dom * @param DOMNode $node * @param string $new_html */ private function replace_node_content($dom, $node, $new_html) { while ($node->firstChild) { $node->removeChild($node->firstChild); } $temp = new DOMDocument(); $temp->loadHTML('' . $new_html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); foreach ($temp->getElementsByTagName('body')->item(0)->childNodes as $child) { $imported = $dom->importNode($child, true); $node->appendChild($imported); } } public function verify_paragraph_exists($donor_id, $donor_chunk) { $donor_post = get_post($donor_id); if (!$donor_post || empty($donor_post->post_content)) { return false; } libxml_use_internal_errors(true); $dom = new DOMDocument(); $html_wrapper = '
' . $donor_post->post_content . '
'; $dom->loadHTML($html_wrapper, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); $xpath = new DOMXPath($dom); $paragraphs = $xpath->query('//p | //li | //blockquote'); $search_text = $this->normalize_text($donor_chunk); $search_prefix = mb_substr($search_text, 0, 150); $highest_percent = 0; foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, $search_prefix) !== false) { return true; } similar_text(mb_substr($p_text, 0, 200), mb_substr($search_text, 0, 200), $percent); if ($percent > $highest_percent) { $highest_percent = $percent; } } if ($highest_percent > 80) { return true; } // Suffix matching fallback for split/nested list chunks $search_suffix = mb_substr($search_text, -120); if (mb_strlen($search_suffix) >= 40) { foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, $search_suffix) !== false) { return true; } } } // Prefix matching fallback (shorter block check) foreach ($paragraphs as $p) { $p_text = $this->normalize_text($p->textContent); if (mb_strpos($p_text, mb_substr($search_prefix, 0, 80)) !== false) { return true; } } return false; } private function normalize_text($text) { // Add spaces after block tags to avoid word concatenation before stripping tags $text = preg_replace('/<\/(p|li|ul|ol|blockquote|h[1-6])>/i', '$0 ', $text); $text = wp_strip_all_tags($text); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = preg_replace('/\s+/u', ' ', $text); $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); return trim(mb_strtolower($text, 'UTF-8')); } private function urls_are_equal($url1, $url2) { $u1 = untrailingslashit(strtolower(trim($url1))); $u2 = untrailingslashit(strtolower(trim($url2))); if ($u1 === $u2) { return true; } $path1 = wp_parse_url($u1, PHP_URL_PATH); $path2 = wp_parse_url($u2, PHP_URL_PATH); if ($path1 && $path2 && untrailingslashit($path1) === untrailingslashit($path2)) { return true; } return false; } }