plugin_name = $plugin_name; $this->version = $version; $this->core = $core; add_action('wp_ajax_ailm_analyze_orphan', array($this, 'ajax_analyze_orphan')); add_action('wp_ajax_ailm_search_donors_batch', array($this, 'ajax_search_donors_batch')); add_action('wp_ajax_ailm_create_rescue_rule', array($this, 'ajax_create_rescue_rule')); // NEW HOOK: For saving stop words add_action('wp_ajax_ailm_save_orphan_stop_words', array($this, 'ajax_save_stop_words')); } public function render_tab() { // Find orphan posts via Repository (single source of truth). $orphan_ids = AILM_Link_Repository::get_orphan_ids(); $orphan_count = count($orphan_ids); $all_post_ids = get_posts(array( 'numberposts' => -1, 'post_type' => get_option('ailm_allowed_post_types', array('post', 'page')), 'post_status' => 'publish', 'fields' => 'ids', )); $total_posts = count($all_post_ids); $orphans_data = array(); foreach ($orphan_ids as $pid) { $orphans_data[] = array( 'id' => $pid, 'title' => html_entity_decode(get_the_title($pid) ?: __('(No Title)', 'ailinkmaster-internal-auto-manager'), ENT_QUOTES, 'UTF-8'), 'url' => get_permalink($pid), ); } // Stop words $default_stop_words = 'a, an, and, are, as, at, be, by, for, from, has, he, in, is, it, its, of, on, that, the, to, was, were, will, with'; $saved_stop_words = get_option('ailm_orphan_stop_words', $default_stop_words); echo '
'; // --- NEW CARD: Stop words settings --- echo '
'; echo '

' . esc_html__('Stop Words Filter', 'ailinkmaster-internal-auto-manager') . '

'; echo '

' . esc_html__('Words listed here will be completely ignored by the Analyzer when extracting keywords. Separate words by commas.', 'ailinkmaster-internal-auto-manager') . '

'; echo ''; echo '
'; echo '
'; echo '
'; // Orphans card echo '
'; echo '
'; echo '

' . esc_html__('Orphan Rescue', 'ailinkmaster-internal-auto-manager') . '

'; echo '

' . wp_kses_post(sprintf( /* translators: 1: total number of articles, 2: number of orphan articles */ __('Articles without any automated inbound links.
Total Articles: %1$s | Orphans: %2$s', 'ailinkmaster-internal-auto-manager'), esc_html(number_format_i18n($total_posts)), esc_html(number_format_i18n($orphan_count)) )) . '

'; if ($orphan_count > 0) { echo '
'; echo ''; echo ''; echo '' . esc_html__('1 of 1', 'ailinkmaster-internal-auto-manager') . ''; echo ''; echo '
'; } echo '
'; if ($orphan_count > 0) { echo '
  • ' . esc_html__('Loading data...', 'ailinkmaster-internal-auto-manager') . '
'; } else { echo '

' . esc_html__('All your articles have inbound links! Great job.', 'ailinkmaster-internal-auto-manager') . '

'; } echo '
'; echo ''; $this->render_rescue_modal(); } private function render_rescue_modal() { ?> post_content), 'UTF-8'); $keyword_counts = []; // Helper function to add and count words $add_word = function ($word, $force_count = null) use (&$keyword_counts, $plain_text) { $word = mb_strtolower(trim($word), 'UTF-8'); if (mb_strlen($word, 'UTF-8') < 3) return; if (!isset($keyword_counts[$word])) { if ($force_count !== null) { $keyword_counts[$word] = $force_count; } else { // Count actual occurrences for SEO keywords and tags $keyword_counts[$word] = mb_substr_count($plain_text, $word); } } }; // 1. Metadata (Highest priority) $yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); if (!empty($yoast_kw)) { $add_word($yoast_kw); } $rm_kw = get_post_meta($post_id, 'rank_math_focus_keyword', true); if (!empty($rm_kw)) { $rm_kws = explode(',', $rm_kw); foreach ($rm_kws as $kw) { $add_word($kw); } } $terms = get_the_terms($post_id, 'category'); if ($terms && !is_wp_error($terms)) { foreach ($terms as $term) { if ($term->name !== 'Uncategorized' && $term->name !== 'Not found') { $add_word($term->name); } } } $tags = get_the_terms($post_id, 'post_tag'); if ($tags && !is_wp_error($tags)) { foreach ($tags as $tag) { $add_word($tag->name); } } // 2. AILM Keyword Intelligence Analyzer if (class_exists('AILM_KI_Analyzer')) { $default_stop_words = 'a, an, and, are, as, at, be, by, for, from, has, he, in, is, it, its, of, on, that, the, to, was, were, will, with'; $stop_words_string = get_option('ailm_orphan_stop_words', $default_stop_words); $stop_words_array = array_filter(array_map('mb_strtolower', array_map('trim', explode(',', $stop_words_string)))); $analyzer = new AILM_KI_Analyzer([$post_id], $stop_words_array, true); $ngrams = $analyzer->get_ngrams([2, 3], 15); if (!empty($ngrams)) { foreach ($ngrams as $ngram_word => $count) { $add_word($ngram_word, $count); } } $single_words = $analyzer->get_single_keywords(5); if (!empty($single_words)) { foreach ($single_words as $word => $count) { $add_word($word, $count); } } } // Sort array from largest to smallest by value (by frequency) arsort($keyword_counts); // Format for JS and keep the top 12 $final_keywords = []; $count = 0; foreach ($keyword_counts as $word => $freq) { if ($count >= 12) break; $final_keywords[] = [ 'word' => $word, 'count' => $freq ]; $count++; } wp_send_json_success(['keywords' => $final_keywords]); } public function ajax_search_donors_batch() { check_ajax_referer('ailm_orphan_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Permission denied', 'ailinkmaster-internal-auto-manager')); } $target_id = isset($_POST['target_id']) ? intval($_POST['target_id']) : 0; $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; $limit = 50; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw_keywords = isset($_POST['keywords']) ? (array) wp_unslash($_POST['keywords']) : []; $keywords = array_filter(array_map('sanitize_text_field', $raw_keywords)); if (!$target_id || empty($keywords)) { wp_send_json_error(__('Invalid data provided.', 'ailinkmaster-internal-auto-manager')); } global $wpdb; // Only valid post type slugs may enter the SQL fragment below. The // option is sanitized and whitelisted here, so the placeholder list // can never carry user input into the query string. $allowed_types = get_option('ailm_allowed_post_types', array('post', 'page')); $allowed_types = array_values(array_unique(array_filter(array_map('sanitize_key', (array) $allowed_types)))); if (empty($allowed_types)) { $allowed_types = array('post'); } $type_placeholders = implode(',', array_fill(0, count($allowed_types), '%s')); $posts_table = $wpdb->posts; // Core table name, never user input. // The two interpolations below are trusted: $posts_table is the core // posts table and $type_placeholders is a fixed run of "%s" built from // the sanitized slugs above โ€” no user input reaches the query string. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- see note above: core table + an equivalent number of %s placeholders $total_posts = 0; if ($offset === 0) { $total_posts = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(ID) FROM {$posts_table} WHERE post_status = 'publish' AND post_type IN ($type_placeholders)", $allowed_types )); } $posts = $wpdb->get_results($wpdb->prepare( "SELECT ID, post_title, post_content FROM {$posts_table} WHERE post_status = 'publish' AND post_type IN ($type_placeholders) ORDER BY ID DESC LIMIT %d OFFSET %d", array_merge($allowed_types, array($limit, $offset)) )); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber if (empty($posts)) { wp_send_json_success(['finished' => true]); } $global_limit = (int) get_option('ailm_global_limit', 5); $skip_chars = (int) get_option('ailm_skip_chars', 100); $min_chars = (int) get_option('ailm_min_chars', 2000); $count_manual = (int) get_option('ailm_count_manual_links', 0); $regex_kws = []; foreach ($keywords as $kw) { $escaped_parts = array_map(function ($p) { return str_replace("'", "['โ€™โ€˜`ยด]", preg_quote($p, '/')); }, preg_split('/\s+/u', trim($kw), -1, PREG_SPLIT_NO_EMPTY)); $regex_kws[] = implode('[\p{Z}\s\p{P}\p{S}]+', $escaped_parts); } usort($regex_kws, function ($a, $b) { return mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'); }); $pattern = '/(?ID === $target_id) continue; $raw_content = $p->post_content; $plain_text = wp_strip_all_tags($raw_content); if (mb_strlen($plain_text, 'UTF-8') < $min_chars) continue; if (!preg_match($pattern, $plain_text)) continue; // DEPRECATED v2.0: _ailm_frozen_map meta is replaced by wp_ailm_link_rules. /* $frozen_map = get_post_meta($p->ID, '_ailm_frozen_map', true); $current_frozen_count = is_array($frozen_map) ? count($frozen_map) : 0; */ $current_frozen_count = 0; if (class_exists('AILM_Link_Repository')) { $current_frozen_count = count(AILM_Link_Repository::get_rules_for_donor($p->ID)); } $allowed_limit = $global_limit; if ($count_manual) { $home_url = untrailingslashit(home_url()); if (preg_match_all('/]*href=[\'"]([^\'" ]+)[\'"][^>]*>/i', $raw_content, $matches)) { $manual_internal = 0; foreach ($matches[1] as $href) { if (strpos($href, $home_url) === 0 || strpos($href, '/') === 0) { $manual_internal++; } } $allowed_limit = max(0, $global_limit - $manual_internal); } } if ($current_frozen_count >= $allowed_limit) continue; if (!class_exists('AILM_DOM_Helper')) { require_once plugin_dir_path(__FILE__) . '../../includes/class-ailm-dom-helper.php'; } $dom_helper = new AILM_DOM_Helper($raw_content); $virtual_text = $dom_helper->get_virtual_text(); $search_text = mb_substr($virtual_text, $skip_chars, null, 'UTF-8'); // Match against the FULL virtual_text so the occurrence index is // consistent with AILM_Link_Builder::inject_physical_links(), which // counts occurrences across the whole document (and only skips // matches that fall inside the skip_chars zone or occupied ranges). if (preg_match_all($pattern, $virtual_text, $all_matches, PREG_OFFSET_CAPTURE)) { $selected = null; $selected_occurrence = 0; foreach ($all_matches[0] as $m) { $selected_occurrence++; $byte_off = $m[1]; $char_off = mb_strlen(substr($virtual_text, 0, $byte_off), 'UTF-8'); if ($char_off < $skip_chars) { continue; } $selected = $m; break; } if ($selected !== null) { $matched_word = $selected[0]; $char_offset = mb_strlen(substr($virtual_text, 0, $selected[1]), 'UTF-8'); // Build context from the skip_chars-trimmed view for display. $display_offset = $char_offset - $skip_chars; if ($display_offset < 0) { $display_offset = 0; } $context_start = max(0, $display_offset - 40); $context_length = 80 + mb_strlen($matched_word, 'UTF-8'); $context_raw = mb_substr($search_text, $context_start, $context_length, 'UTF-8'); $context_escaped = esc_html($context_raw); $word_escaped = esc_html($matched_word); $highlighted = preg_replace( '/(' . preg_quote($word_escaped, '/') . ')/iu', '$1', $context_escaped ); $found_donors[] = [ 'id' => $p->ID, 'title' => html_entity_decode($p->post_title, ENT_QUOTES, 'UTF-8'), 'url' => get_permalink($p->ID), 'edit' => get_edit_post_link($p->ID), 'keyword' => mb_strtolower($matched_word, 'UTF-8'), 'occurrence' => $selected_occurrence, 'context' => '... ' . trim($highlighted) . ' ...' ]; } } } wp_send_json_success([ 'finished' => false, 'next_offset' => $offset + $limit, 'total_posts' => $total_posts, 'found_donors' => $found_donors ]); } public function ajax_create_rescue_rule() { check_ajax_referer('ailm_orphan_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Permission denied', 'ailinkmaster-internal-auto-manager')); } $target_id = isset($_POST['target_id']) ? intval($_POST['target_id']) : 0; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $donors_raw = isset($_POST['donors']) ? wp_unslash($_POST['donors']) : []; if (!$target_id || empty($donors_raw) || !is_array($donors_raw)) { wp_send_json_error(__('Invalid data provided.', 'ailinkmaster-internal-auto-manager')); } $target_limit = (int) get_option('ailm_target_limit', 5); if (class_exists('AILM_Link_Repository')) { $current_inbound = AILM_Link_Repository::get_active_inbound_rule_count($target_id); } else { $current_inbound = (int) get_post_meta($target_id, '_ailm_inbound_count', true); } $available_slots = max(0, $target_limit - $current_inbound); if ($available_slots <= 0) { wp_send_json_error(__('Target article has already reached its incoming links limit.', 'ailinkmaster-internal-auto-manager')); } if (!class_exists('AILM_Link_Repository')) { require_once plugin_dir_path(dirname(dirname(__FILE__))) . 'includes/class-ailm-link-repository.php'; } if (!class_exists('AILM_Link_Builder')) { require_once plugin_dir_path(dirname(dirname(__FILE__))) . 'includes/class-ailm-link-builder.php'; } $target_url = get_permalink($target_id); $link_builder = new AILM_Link_Builder(); $total_added = 0; $results = array( 'inserted' => array(), 'failed' => array(), 'skipped' => array(), ); foreach ($donors_raw as $d) { if ($total_added >= $available_slots) { break; } $d_id = isset($d['id']) ? intval($d['id']) : 0; $kw = isset($d['keyword']) ? sanitize_text_field($d['keyword']) : ''; // Occurrence from the search step (fix #3). Falls back to 1 for // legacy requests that do not send it. $occurrence = isset($d['occurrence']) ? max(1, intval($d['occurrence'])) : 1; if (!$d_id || !$kw) { continue; } $existing_rules = AILM_Link_Repository::get_rules_for_donor($d_id); $exists = false; foreach ($existing_rules as $er) { if ((int) $er->target_id === $target_id && mb_strtolower($er->anchor, 'UTF-8') === mb_strtolower($kw, 'UTF-8')) { $exists = true; break; } } if ($exists) { $results['skipped'][] = array('id' => $d_id, 'keyword' => $kw, 'reason' => 'duplicate'); continue; } $rule_id = AILM_Link_Repository::register_link( $d_id, $target_url, $kw, 'orphan_rescue', $occurrence, false // in_html = 0; injector will insert and mark in_html=1. ); if (!$rule_id) { $results['failed'][] = array('id' => $d_id, 'keyword' => $kw, 'reason' => 'rule_not_created'); continue; } // Fix #1: physically inject the link into post_content. // inject_physical_links() also re-scans via AILM_Scout_Scanner and // increments _ailm_inbound_count on success, so we must NOT do // those manually here (fix #2). $injected = $link_builder->inject_physical_links($d_id); if ($injected) { $total_added++; $results['inserted'][] = array('id' => $d_id, 'keyword' => $kw, 'rule_id' => $rule_id); } else { // Injection failed (phrase not found / occupied zone / limit). // Roll back the rule so it does not linger as 'active' and // block future attempts. AILM_Link_Repository::delete_rule($rule_id); $results['failed'][] = array('id' => $d_id, 'keyword' => $kw, 'reason' => 'injection_failed'); } } // Fix #2: _ailm_inbound_count is incremented inside // inject_physical_links() per successful insertion. We only sync the // meta with the authoritative rule count to guard against drift. if ($total_added > 0 && class_exists('AILM_Link_Repository')) { $authoritative = AILM_Link_Repository::get_active_inbound_rule_count($target_id); update_post_meta($target_id, '_ailm_inbound_count', $authoritative); } // Fix #4: return a structured per-donor report instead of a flat string. wp_send_json_success(array( 'message' => sprintf( /* translators: %d: number of links created */ _n( 'Successfully created %d link.', 'Successfully created %d links.', $total_added, 'ailinkmaster-internal-auto-manager' ), $total_added ), 'inserted' => $results['inserted'], 'failed' => $results['failed'], 'skipped' => $results['skipped'], 'total' => $total_added, )); } }