provider = new AILM_OpenAI_Provider(); } public function handle_get_opportunities() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $target_id = isset($_POST['target_id']) ? absint($_POST['target_id']) : 0; if (!$target_id) { wp_send_json_error('Missing target post ID.'); } $target_title = get_the_title($target_id); // 0. Pre-check: if target already reached its inbound link limit, // return a clear message instead of hitting SaaS and filtering everything out. $target_limit = (int) get_option('ailm_target_limit', 5); if (class_exists('AILM_Link_Repository')) { $inbound_count = AILM_Link_Repository::get_active_inbound_rule_count($target_id); } else { $inbound_count = (int) get_post_meta($target_id, '_ailm_inbound_count', true); } if ($inbound_count >= $target_limit) { wp_send_json_success([ 'opportunities' => [], 'limit_reached' => true, 'message' => sprintf( /* translators: 1: current inbound count, 2: limit */ __('Target inbound link limit reached (%1$d/%2$d).', 'ailinkmaster-internal-auto-manager'), $inbound_count, $target_limit ), ]); } // 1. Запрашиваем возможности и реранкинг у SaaS-сервера $response = $this->provider->saas_get_opportunities($target_id, $target_title); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } // FastAPI отдает {"results": [...]}, мапим обратно в "opportunities" для старого JS $opportunities = isset($response['results']) ? $response['results'] : []; // Filter out opportunities that violate global settings or already have active links/rules targeting the same target post (Level 3 safeguard) if (!empty($opportunities)) { $excluded_donor_ids = AILM_Link_Validator::get_excluded_donor_ids($target_id); $filtered_opps = []; foreach ($opportunities as $opp) { $post_id = (int) $opp['post_id']; // A. Exclude if already linked or targeted if (isset($excluded_donor_ids[$post_id])) { continue; } $result = AILM_Link_Validator::validate_donor($post_id, $target_id); if ($result !== true) { continue; } $filtered_opps[] = $opp; } $opportunities = $filtered_opps; } // Внедряем заголовки и ссылки из локальной базы WordPress foreach ($opportunities as &$opp) { $post_id = $opp['post_id']; $opp['donor_title'] = get_the_title($post_id); $opp['donor_view_url'] = get_permalink($post_id); $opp['donor_edit_url'] = get_edit_post_link($post_id, 'raw'); } unset($opp); wp_send_json_success(['opportunities' => $opportunities]); } public function handle_generate_smart_append() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $donor_id = isset($_POST['donor_id']) ? absint($_POST['donor_id']) : 0; $target_id = isset($_POST['target_id']) ? absint($_POST['target_id']) : 0; $donor_chunk = isset($_POST['donor_chunk']) ? sanitize_textarea_field(wp_unslash($_POST['donor_chunk'])) : ''; $target_title = isset($_POST['target_title']) ? sanitize_text_field(wp_unslash($_POST['target_title'])) : ''; if (empty($donor_chunk) || empty($target_title)) { wp_send_json_error('Missing required text parameters.'); } if ($donor_id) { require_once dirname(__DIR__) . '/core/class-ailm-content-editor.php'; $editor = new AILM_Content_Editor(); if (!$editor->verify_paragraph_exists($donor_id, $donor_chunk)) { wp_send_json_error('The paragraph was modified or removed from the source. Please re-index the article.'); } } $init_response = $this->provider->saas_generate_smart_append($donor_id, $donor_chunk, $target_title, $target_id); if (is_wp_error($init_response)) { wp_send_json_error($init_response->get_error_message()); } $task_id = isset($init_response['task_id']) ? $init_response['task_id'] : ''; if (!$task_id) { wp_send_json_error('No task_id returned from SaaS.'); } // 2. PHP Polling: ждем завершения задачи сервером Celery $attempts = 0; $max_attempts = 10; // 10 попыток по 3 секунды = 30 сек таймаут while ($attempts < $max_attempts) { sleep(3); // Ждем 3 секунды перед проверкой статуса $status_response = $this->provider->saas_check_task_status($task_id); if (is_wp_error($status_response)) { wp_send_json_error($status_response->get_error_message()); } $status = isset($status_response['status']) ? $status_response['status'] : ''; // Celery статусы (SUCCESS или completed в зависимости от реализации) if ($status === 'SUCCESS' || $status === 'completed') { wp_send_json_success($status_response['result']); } elseif ($status === 'FAILURE' || $status === 'error') { wp_send_json_error('SaaS Error: Task failed during LLM generation.'); } $attempts++; } wp_send_json_error('Timeout: SaaS server took too long to generate the link (30s+).'); } public function handle_get_index_queue() { check_ajax_referer('ailm_settings_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } // Purge all vector_chunks on the SaaS server so that sync returns // accurate data during re-indexing. If the request fails we still // proceed — the old chunks will simply be overwritten by Celery. $this->provider->saas_purge_index(); // Clear all indexed flags so the user sees checkmarks disappear // before re-indexing. They will be restored by syncIndexedStatus() // as Celery processes each article. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_ailm_is_indexed'"); $post_types = get_option('ailm_allowed_post_types', ['post', 'page']); $posts = get_posts([ 'numberposts' => -1, 'post_type' => $post_types, 'post_status' => 'publish', 'fields' => 'ids', 'suppress_filters' => true ]); wp_send_json_success($posts); } public function handle_process_index_batch() { check_ajax_referer('ailm_settings_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } // Check if index limit has been reached if (get_option('linkmesh_index_limit_reached', false)) { wp_send_json_error('⚠️ Index limit reached: 10 000 posts. Please upgrade your plan.'); } $post_ids = isset($_POST['post_ids']) ? array_map('absint', (array) $_POST['post_ids']) : []; if (empty($post_ids)) { wp_send_json_error('No IDs provided.'); } foreach ($post_ids as $id) { $post = get_post($id); if (!$post || empty($post->post_content)) { continue; } // Отправляем сырой HTML на Python-сервер. Парсинг и нарезку он сделает сам. $response = $this->provider->saas_index_document($id, $post->post_content); // If limit was reached mid-batch, stop and report if (get_option('linkmesh_index_limit_reached', false)) { wp_send_json_error('⚠️ Index limit reached during batch. Partially indexed.'); } } wp_send_json_success(); } public function handle_sync_indexed_status() { check_ajax_referer('ailm_settings_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $response = $this->provider->saas_get_indexed_ids(); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } $indexed_ids = isset($response['indexed_ids']) ? $response['indexed_ids'] : []; global $wpdb; // Remove old indexed flags from all posts // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_ailm_is_indexed'"); if (!empty($indexed_ids)) { $time = current_time('mysql'); $values = []; foreach ($indexed_ids as $id) { $values[] = $wpdb->prepare("(%d, %s, %s)", $id, '_ailm_is_indexed', $time); } // Batch insert in chunks of 500 records $chunks = array_chunk($values, 500); foreach ($chunks as $chunk) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Values prepared via $wpdb->prepare() above. $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " . implode(', ', $chunk)); } } wp_send_json_success(['synced_count' => count($indexed_ids)]); } public function handle_apply_smart_link() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $donor_id = isset($_POST['donor_id']) ? absint($_POST['donor_id']) : 0; $target_id = isset($_POST['target_id']) ? absint($_POST['target_id']) : 0; $donor_chunk = isset($_POST['donor_chunk']) ? sanitize_textarea_field(wp_unslash($_POST['donor_chunk'])) : ''; $mode = isset($_POST['mode']) ? sanitize_text_field(wp_unslash($_POST['mode'])) : 'in_text'; $original_target = isset($_POST['original_target']) ? sanitize_text_field(wp_unslash($_POST['original_target'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via wp_kses() below. $result_html = isset($_POST['result_html']) ? wp_unslash($_POST['result_html']) : ''; $anchor = isset($_POST['anchor']) ? sanitize_text_field(wp_unslash($_POST['anchor'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Unslashed and decoded as JSON below; array values are sanitized before use. $fallback = isset($_POST['fallback']) ? $_POST['fallback'] : null; if ($fallback && is_string($fallback)) { $fallback = json_decode(wp_unslash($fallback), true); } $allowed_html = [ 'a' => ['href' => true, 'target' => true, 'rel' => true, 'data-ailm' => true], 'strong' => [], 'em' => [], 'b' => [], 'i' => [], ]; $result_html = wp_kses($result_html, $allowed_html); if (!in_array($mode, ['in_text', 'rewrite'], true)) { $mode = 'in_text'; } if (!$donor_id || !$target_id || empty($donor_chunk) || empty($result_html)) { wp_send_json_error('Missing required parameters for insertion.'); } // ENFORCE GUARDS via shared validator (Level 3 backend checks) $guard_result = AILM_Link_Validator::validate_donor($donor_id, $target_id); if (is_wp_error($guard_result)) { wp_send_json_error($guard_result->get_error_message()); } // Duplicate check: donor already links to this target if (!class_exists('AILM_Link_Repository')) { require_once dirname(dirname(dirname(__DIR__))) . '/includes/class-ailm-link-repository.php'; } $existing_rules = AILM_Link_Repository::get_rules_for_target($target_id); foreach ($existing_rules as $rule) { if ((int) $rule->donor_id === $donor_id) { wp_send_json_error(__('This donor already links to the target article.', 'ailinkmaster-internal-auto-manager')); } } $donor_post = get_post($donor_id); if (!$donor_post) { wp_send_json_error('Donor post not found.'); } $active_rules = []; if (class_exists('AILM_Link_Repository')) { $active_rules = AILM_Link_Repository::get_rules_for_donor($donor_id); } // Character position validation (Skip Intro & Min Gap) if (!class_exists('AILM_DOM_Helper')) { require_once dirname(dirname(dirname(__DIR__))) . '/includes/class-ailm-dom-helper.php'; } $dom_helper = new AILM_DOM_Helper($donor_post->post_content, false); $virtual_text = $dom_helper->get_virtual_text(); // Locate the donor paragraph in the DOM $paragraph_node = $dom_helper->find_paragraph_node($donor_chunk); if (!$paragraph_node) { wp_send_json_error('Could not locate the donor paragraph in the content.'); } // Get absolute position of paragraph start in virtual_text $para_pos = $dom_helper->get_paragraph_virtual_start($paragraph_node); if ($para_pos === null) { wp_send_json_error('Could not map paragraph to virtual text coordinates.'); } // Get virtual text of the paragraph (without link anchors) to find phrase position $paragraph_virtual_text = $dom_helper->get_paragraph_virtual_text($paragraph_node); // Calculate absolute link position $phrase_to_match = !empty($original_target) ? $original_target : $anchor; $phrase_in_para_pos = mb_strpos(mb_strtolower($paragraph_virtual_text, 'UTF-8'), mb_strtolower($phrase_to_match, 'UTF-8'), 0, 'UTF-8'); // Fallback: if the phrase contained link text, search by first 3 words if ($phrase_in_para_pos === false) { $words = array_filter(preg_split('/\s+/u', $phrase_to_match)); $phrase_prefix = implode(' ', array_slice($words, 0, 3)); if ($phrase_prefix !== '') { $phrase_in_para_pos = mb_strpos(mb_strtolower($paragraph_virtual_text, 'UTF-8'), mb_strtolower($phrase_prefix, 'UTF-8'), 0, 'UTF-8'); } } if ($phrase_in_para_pos === false) { $phrase_in_para_pos = 0; } $absolute_link_pos = $para_pos + $phrase_in_para_pos; // Pre-check: is the anchor phrase already inside an existing link in this paragraph? if (!empty($phrase_to_match)) { $xpath = new DOMXPath($dom_helper->get_dom()); $links_in_para = $xpath->query('.//a', $paragraph_node); $phrase_lower = mb_strtolower($phrase_to_match, 'UTF-8'); foreach ($links_in_para as $link) { $link_text = mb_strtolower(trim($link->textContent), 'UTF-8'); if ($link_text !== '' && mb_stripos($link_text, $phrase_lower) !== false) { wp_send_json_error(__('The selected anchor phrase is already part of an existing link in this paragraph.', 'ailinkmaster-internal-auto-manager')); } } } // A. Skip Intro check $skip_chars = (int) get_option('ailm_skip_chars', 100); if ($absolute_link_pos < $skip_chars) { wp_send_json_error('Link insertion point is within the Skip Intro zone.'); } // B. Min Gap check $gap = (int) get_option('ailm_min_gap', 200); $occupied_ranges = []; $manual_link_positions = $dom_helper->get_manual_links_positions(); foreach ($manual_link_positions as $pos) { $occupied_ranges[] = [ 'start' => max(0, $pos - $gap), 'end' => $pos + $gap ]; } foreach ($active_rules as $rule) { $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 = '/(?occurrence; foreach ($matches[0] as $match) { $current_match_idx++; if ($current_match_idx === $item_occ) { $char_offset = mb_strlen(substr($virtual_text, 0, $match[1]), 'UTF-8'); $char_length = mb_strlen($match[0], 'UTF-8'); $occupied_ranges[] = [ 'start' => max(0, $char_offset - $gap), 'end' => $char_offset + $char_length + $gap, ]; break; } } } } $link_length = mb_strlen($phrase_to_match, 'UTF-8'); $overlap = false; foreach ($occupied_ranges as $range) { if ($absolute_link_pos < $range['end'] && ($absolute_link_pos + $link_length) > $range['start']) { $overlap = true; break; } } if ($overlap) { wp_send_json_error('Link insertion point violates the minimum gap between links rule.'); } require_once dirname(__DIR__) . '/core/class-ailm-content-editor.php'; $editor = new AILM_Content_Editor(); // Pass the fallback block to handle prioritized fallback chain (in_text -> rewrite -> append) // For single-link apply, disable append fallback so the user sees an error instead of a silent append. $result = $editor->apply_smart_link($donor_id, $target_id, $donor_chunk, $mode, $original_target, $result_html, $fallback, false); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } // Retrieve the actually applied mode and anchor after any fallbacks $applied_anchor = isset($result['anchor']) ? $result['anchor'] : $anchor; if (!empty($applied_anchor)) { if (!class_exists('AILM_Link_Repository')) { require_once dirname(dirname(dirname(__DIR__))) . '/includes/class-ailm-link-repository.php'; } $target_url = get_permalink($target_id); // in_html=1: apply_smart_link() already inserted the into the // donor's HTML, so the injector must skip this rule. AILM_Link_Repository::register_link($donor_id, $target_url, $applied_anchor, 'ai_smart_link', 1, true); // Deduct 1 token on the server — this is the ONLY place tokens are spent. $confirm_result = $this->provider->saas_confirm_link($donor_id, $target_id, $applied_anchor); if (is_array($confirm_result) && isset($confirm_result['tokens_balance'])) { update_option('linkmesh_tokens_balance', (int) $confirm_result['tokens_balance']); } $inbound_count = (int) get_post_meta($target_id, '_ailm_inbound_count', true); update_post_meta($target_id, '_ailm_inbound_count', $inbound_count + 1); if (class_exists('AILM_Scout_Scanner')) { AILM_Scout_Scanner::process_post($donor_id); } } // Remove the indexed flag and delete stale vector_chunks on the SaaS // server so the checkmark disappears until Celery finishes re-indexing. delete_post_meta($donor_id, '_ailm_is_indexed'); $this->provider->saas_delete_document($donor_id); // Re-index the donor article so the new sentence is reflected in the vector database. // Skip if index limit has been reached. $donor_post = get_post($donor_id); if ($donor_post && !empty($donor_post->post_content) && !get_option('linkmesh_index_limit_reached', false)) { $this->provider->saas_index_document($donor_id, $donor_post->post_content); } wp_send_json_success('Link successfully applied and saved to the database!'); } }