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 '
' . esc_html__('Save Stop Words', 'ailinkmaster-internal-auto-manager') . ' ';
echo ' ' . esc_html__('Saved successfully!', 'ailinkmaster-internal-auto-manager') . '
';
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 '' . esc_html__('20 per page', 'ailinkmaster-internal-auto-manager') . ' ' . esc_html__('50 per page', 'ailinkmaster-internal-auto-manager') . ' ';
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,
));
}
}