post_content)) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: abort - no post or empty content. post_id=' . $post_id); } return false; } // Phase 4: If no explicit links provided, read from the unified rules table. if (empty($links_to_inject)) { if (!class_exists('AILM_Link_Repository')) { require_once plugin_dir_path(__FILE__) . 'class-ailm-link-repository.php'; } $rules = AILM_Link_Repository::get_rules_for_donor($post_id); if (empty($rules)) { return false; } $links_to_inject = array(); foreach ($rules as $rule) { // Phase 2.1: Skip rules whose link is already present in the // donor's HTML. These were either injected previously, imported // from pre-existing manual links, or inserted by AI/Gutenberg. // Re-injecting them would create duplicates. // // Backward compat: on pre-2.1 DBs the `in_html` column does not // exist, so the property is null → (int)null === 0 → the rule // is injected exactly as before. if (isset($rule->in_html) && (int) $rule->in_html === 1) { continue; } $links_to_inject[] = array( 'word' => $rule->anchor, 'url' => $rule->target_url, 'origin' => $rule->source, 'occurrence' => (int) $rule->occurrence, 'rule_id' => (int) $rule->id, ); } } else { // Legacy: wrap raw arrays with a dummy rule_id for consistent tracking. foreach ($links_to_inject as $idx => $item) { if (!isset($item['rule_id'])) { $links_to_inject[$idx]['rule_id'] = 0; } } } if (empty($links_to_inject)) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: abort - no links to inject. post_id=' . $post_id); } return false; } if (!class_exists('AILM_DOM_Helper')) { require_once plugin_dir_path(__FILE__) . 'class-ailm-dom-helper.php'; } $skip_chars = get_option('ailm_skip_chars', 100); $global_limit = get_option('ailm_global_limit', 5); $target_limit = get_option('ailm_target_limit', 5); $gap = get_option('ailm_min_gap', 200); $global_limit = $this->adjust_global_limit_for_manual_links($post, $global_limit); if ($global_limit <= 0) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: abort - global_limit <= 0 after manual link adjustment. post_id=' . $post_id); } return false; } $links_to_inject = array_slice($links_to_inject, 0, $global_limit); // Sort links by length (longest first) to prevent partial word overwriting. usort($links_to_inject, function ($a, $b) { return mb_strlen($b['word'], 'UTF-8') <=> mb_strlen($a['word'], 'UTF-8'); }); $links_to_inject = array_reverse($links_to_inject); $is_elementor = class_exists('AILM_Elementor_Handler') && AILM_Elementor_Handler::is_elementor_post($post_id); $links_inserted = 0; $occupied_ranges = array(); if (!$is_elementor) { $dom_helper = new AILM_DOM_Helper($post->post_content); $manual_link_positions = $dom_helper->get_manual_links_positions(); foreach ($manual_link_positions as $pos) { $occupied_ranges[] = array('start' => max(0, $pos - $gap), 'end' => $pos + $gap); } } $global_modified = false; $global_char_offset = 0; // Track which rules succeeded and which failed. $injected_rule_ids = array(); $failed_rule_ids = array(); $process_html = function ($html) use (&$global_modified, &$links_inserted, &$occupied_ranges, &$global_char_offset, &$injected_rule_ids, &$failed_rule_ids, $post_id, $global_limit, $target_limit, $skip_chars, $gap, $links_to_inject) { if ($links_inserted >= $global_limit) { return $html; } $dom_helper = new AILM_DOM_Helper($html); $virtual_text = $dom_helper->get_virtual_text(); $widget_length = mb_strlen($virtual_text, 'UTF-8'); if ($widget_length === 0) { return $html; } $local_modified = false; foreach ($links_to_inject as $item) { if ($links_inserted >= $global_limit) { break; } $kw = $item['word']; $url = $item['url']; $origin = isset($item['origin']) ? $item['origin'] : 'ki'; $occurrence = isset($item['occurrence']) ? (int) $item['occurrence'] : 1; $rule_id = isset($item['rule_id']) ? (int) $item['rule_id'] : 0; $target_id = url_to_postid($url); if ($target_id && $target_id === $post_id) { if ($rule_id > 0) { $failed_rule_ids[] = $rule_id; } continue; } if ($target_id) { if (get_post_status($target_id) !== 'publish') { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: target post ' . $target_id . ' is not published. rule_id=' . $rule_id); } if ($rule_id > 0) { $failed_rule_ids[] = $rule_id; } continue; } if (class_exists('AILM_Link_Repository')) { $inbound = AILM_Link_Repository::get_active_inbound_rule_count($target_id); } else { $inbound = (int) get_post_meta($target_id, '_ailm_inbound_count', true); } if ($inbound >= $target_limit) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: target post ' . $target_id . ' has reached inbound limit (' . $inbound . '/' . $target_limit . '). rule_id=' . $rule_id); } if ($rule_id > 0) { $failed_rule_ids[] = $rule_id; } continue; } } $escaped_parts = array_map( function ($p) { return str_replace("'", "['''`´]", preg_quote($p, '/')); }, preg_split('/\s+/u', trim($kw), -1, PREG_SPLIT_NO_EMPTY) ); $fuzzy_kw = implode('[\p{Z}\s\p{P}\p{S}]+', $escaped_parts); $pattern = '/(?= $global_limit) { break; } $current_match++; if ($current_match !== $occurrence) { continue; } $found_word = $match[0]; $byte_offset = $match[1]; $char_offset = mb_strlen(substr($virtual_text, 0, $byte_offset), 'UTF-8'); $absolute_offset = $global_char_offset + $char_offset; $char_length = mb_strlen($found_word, 'UTF-8'); if ($absolute_offset < $skip_chars) { continue; } $overlap = false; foreach ($occupied_ranges as $range) { if ($absolute_offset < $range['end'] && ($absolute_offset + $char_length) > $range['start']) { $overlap = true; break; } } if (!$overlap) { $dom_helper->queue_link($char_offset, $char_length, $url, $found_word, $origin); $occupied_ranges[] = array( 'start' => max(0, $absolute_offset - $gap), 'end' => $absolute_offset + $char_length + $gap, ); $links_inserted++; $local_modified = true; $global_modified = true; $match_injected = true; if ($target_id) { update_post_meta($target_id, '_ailm_inbound_count', $inbound + 1); } if ($rule_id > 0) { $injected_rule_ids[] = $rule_id; } break; } } if (!$match_injected && $rule_id > 0) { $failed_rule_ids[] = $rule_id; } } else { // Keyword not found at all. if ($rule_id > 0) { $failed_rule_ids[] = $rule_id; } } } $global_char_offset += $widget_length; if ($local_modified) { $dom_helper->apply_links(); return $dom_helper->get_html(); } return $html; }; if ($is_elementor) { $elementor_ok = AILM_Elementor_Handler::process_content($post_id, $process_html); if (!$elementor_ok) { // Elementor path failed - safe to reset state and fallback to raw post_content. $global_char_offset = 0; $occupied_ranges = array(); $links_inserted = 0; $new_content = $process_html($post->post_content); if ($global_modified) { wp_update_post( array( 'ID' => $post_id, 'post_content' => $new_content, ) ); } } } else { $new_content = $process_html($post->post_content); if ($global_modified) { wp_update_post( array( 'ID' => $post_id, 'post_content' => $new_content, ) ); } } // Phase 4: Update rule statuses after injection attempt. if (class_exists('AILM_Link_Repository')) { $injected_rule_ids = array_unique($injected_rule_ids); $failed_rule_ids = array_unique($failed_rule_ids); foreach ($injected_rule_ids as $rid) { AILM_Link_Repository::update_rule_status($rid, 'active'); // Phase 2.1: Mark the rule as already present in HTML so the // next inject_physical_links() call skips it (no duplicates). // mark_in_html() is a no-op on pre-2.1 DBs. AILM_Link_Repository::mark_in_html($rid, true); } foreach ($failed_rule_ids as $rid) { AILM_Link_Repository::update_rule_status($rid, 'failed'); } } if ($global_modified) { if (class_exists('AILM_Scout_Scanner')) { AILM_Scout_Scanner::process_post($post_id); } return true; } if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM inject_physical_links: no links were injected. post_id=' . $post_id . ' rules_count=' . count($links_to_inject)); } return false; } /** * Checks if a phrase can be injected (limits, exclusions, etc). * * Phase 4: Reads active rules from wp_ailm_link_rules instead of _ailm_frozen_map. * * @param int $post_id Post ID. * @param string $phrase Phrase to check. * @return bool True if the phrase can be injected. */ public function can_inject_phrase($post_id, $phrase) { $post = get_post($post_id); if (!$post || empty($post->post_content)) { return false; } if (!class_exists('AILM_DOM_Helper')) { require_once plugin_dir_path(__FILE__) . 'class-ailm-dom-helper.php'; } $skip_chars = get_option('ailm_skip_chars', 100); $gap = get_option('ailm_min_gap', 200); $global_limit = get_option('ailm_global_limit', 5); $global_limit = $this->adjust_global_limit_for_manual_links($post, $global_limit); if ($global_limit <= 0) { return false; } $dom_helper = new AILM_DOM_Helper($post->post_content); $virtual_text = $dom_helper->get_virtual_text(); $manual_link_positions = $dom_helper->get_manual_links_positions(); $occupied_ranges = array(); foreach ($manual_link_positions as $pos) { $occupied_ranges[] = array('start' => max(0, $pos - $gap), 'end' => $pos + $gap); } // Phase 4: Check active rules from the unified rules table. if (class_exists('AILM_Link_Repository')) { $active_rules = AILM_Link_Repository::get_rules_for_donor($post_id); foreach ($active_rules as $rule) { $item_occ = (int) $rule->occurrence; $escaped_parts = array_map( function ($p) { return str_replace("'", "['''`´]", preg_quote($p, '/')); }, preg_split('/\s+/u', trim($rule->anchor), -1, PREG_SPLIT_NO_EMPTY) ); $fuzzy_kw = implode('[\p{Z}\s\p{P}\p{S}]+', $escaped_parts); $pattern = '/(? max(0, $char_offset - $gap), 'end' => $char_offset + $char_length + $gap, ); break; } } } } } $escaped_parts = array_map( function ($p) { return str_replace("'", "['''`´]", preg_quote($p, '/')); }, preg_split('/\s+/u', trim($phrase), -1, PREG_SPLIT_NO_EMPTY) ); $fuzzy_kw = implode('[\p{Z}\s\p{P}\p{S}]+', $escaped_parts); $pattern = '/(? $range['start']) { $overlap = true; break; } } if (!$overlap) { return true; } } } return false; } /** * Adjusts the global link limit by subtracting manual links already present. * * @param WP_Post $post Post object. * @param int $global_limit Base global limit. * @return int Adjusted limit. */ private function adjust_global_limit_for_manual_links($post, $global_limit) { if (!get_option('ailm_count_manual_links', 0) || empty($post)) { return $global_limit; } $home_url = untrailingslashit(home_url()); $manual_internal_count = 0; $raw_content = $post->post_content; if (preg_match_all('/]*href=[\'"]([^\'"\s]+)[\'"][^>]*>/i', $raw_content, $matches)) { foreach ($matches[1] as $href) { if (empty($href)) { continue; } if (strpos($href, $home_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) { $manual_internal_count++; } } } return max(0, $global_limit - $manual_internal_count); } }