provider === null) { if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $this->provider = new AILM_OpenAI_Provider(); } return $this->provider; } /** * Internal helper: apply a single batch result to a donor post. * Shared by handle_apply_batch_item and handle_confirm_batch. * * @param int $donor_id Donor post ID. * @param int $target_id Target post ID. * @param string $donor_text The chunk of donor text used for paragraph matching. * @param string $mode Insertion mode: 'in_text' or 'rewrite'. * @param string $original_target The original text to find. * @param string $result_html The HTML fragment to insert. * @param string $anchor Expected anchor text. * @param array|null $fallback Fallback data. * @return array|WP_Error */ private function _apply_item_internal($donor_id, $target_id, $donor_text, $mode, $original_target, $result_html, $anchor = '', $fallback = null) { $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_text) || empty($result_html)) { return new WP_Error('missing_params', 'Missing required parameters for insertion.'); } $guard_result = AILM_Link_Validator::validate_donor($donor_id, $target_id); if (is_wp_error($guard_result)) { return new WP_Error('guard_failed', $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) { return new WP_Error('duplicate_link', 'This donor already links to the target article.'); } } $donor_post = get_post($donor_id); if (!$donor_post) { return new WP_Error('not_found', 'Donor post not found.'); } $active_rules = []; if (class_exists('AILM_Link_Repository')) { $active_rules = AILM_Link_Repository::get_rules_for_donor($donor_id); } 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(); $paragraph_node = $dom_helper->find_paragraph_node($donor_text); if (!$paragraph_node) { return new WP_Error('paragraph_not_found', 'Could not locate the donor paragraph in the content.'); } $para_pos = $dom_helper->get_paragraph_virtual_start($paragraph_node); if ($para_pos === null) { return new WP_Error('map_failed', 'Could not map paragraph to virtual text coordinates.'); } $paragraph_virtual_text = $dom_helper->get_paragraph_virtual_text($paragraph_node); $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'); 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; $skip_chars = (int) get_option('ailm_skip_chars', 100); if ($absolute_link_pos < $skip_chars) { return new WP_Error('skip_intro', 'Link insertion point is within the Skip Intro zone.'); } $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) { return new WP_Error('gap_violation', '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(); $result = $editor->apply_smart_link($donor_id, $target_id, $donor_text, $mode, $original_target, $result_html, $fallback, false); if (is_wp_error($result)) { return new WP_Error('apply_failed', $result->get_error_message()); } $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 HTML. $rule_id = AILM_Link_Repository::register_link($donor_id, $target_url, $applied_anchor, 'ai_smart_link', 1, true); // Only increment inbound counter if we actually inserted a new rule if ($rule_id && !is_wp_error($rule_id)) { $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); } } delete_post_meta($donor_id, '_ailm_is_indexed'); $this->get_provider()->saas_delete_document($donor_id); $donor_post = get_post($donor_id); if ($donor_post && !empty($donor_post->post_content) && !get_option('linkmesh_index_limit_reached', false)) { $this->get_provider()->saas_index_document($donor_id, $donor_post->post_content); } return [ 'anchor' => $applied_anchor, 'mode' => $result['mode'] ?? $mode, ]; } /** * Start a new batch generation job. * * Expects: target_id, items[] (each with donor_id, donor_chunk, target_title) */ public function handle_start_batch() { 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; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Array items sanitized individually in the loop below. $items = isset($_POST['items']) ? (array) wp_unslash($_POST['items']) : []; if (!$target_id || empty($items)) { wp_send_json_error('Missing target or items.'); } $donor_ids = []; $item_map = []; foreach ($items as $item) { $donor_id = isset($item['donor_id']) ? absint($item['donor_id']) : 0; if ($donor_id) { $donor_ids[] = $donor_id; $item_map[$donor_id] = $item; } } if (empty($donor_ids)) { wp_send_json_error('No valid donor IDs provided.'); } // Bulk validate via shared guards $validation = AILM_Link_Validator::validate_bulk_donors($donor_ids, $target_id); // Build SaaS payload $saas_items = []; foreach ($validation['valid'] as $donor_id) { $item = $item_map[$donor_id]; $saas_items[] = [ 'donor_post_id' => $donor_id, 'donor_text' => sanitize_textarea_field(wp_unslash($item['donor_chunk'] ?? '')), 'target_post_id' => $target_id, 'target_text' => sanitize_text_field(wp_unslash($item['target_title'] ?? '')), ]; } if (empty($saas_items)) { wp_send_json_error('No valid donors after guard checks.'); } // Enforce SaaS max_length=10 before hitting the API if (count($saas_items) > 10) { wp_send_json_error('Batch size cannot exceed 10 items.'); } $response = $this->get_provider()->saas_create_batch($saas_items); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } $batch_id = isset($response['batch_id']) && is_string($response['batch_id']) ? $response['batch_id'] : ''; if (empty($batch_id)) { wp_send_json_error('Invalid SaaS response: missing batch_id.'); } if (!$batch_id) { wp_send_json_error('No batch_id returned from SaaS.'); } // Store active batch in wp_options (thin client) $active_batches = get_option('ailm_active_batches', []); $active_batches[$batch_id] = [ 'batch_id' => $batch_id, 'target_id' => $target_id, 'created_at' => time(), 'total_items' => count($saas_items), 'invalid_count' => count($validation['invalid']), 'invalid_items' => $validation['invalid'], ]; update_option('ailm_active_batches', $active_batches); wp_send_json_success([ 'batch_id' => $batch_id, 'total_items' => count($saas_items), 'invalid_count' => count($validation['invalid']), 'invalid_items' => $validation['invalid'], ]); } /** * Poll batch status from the SaaS server. */ public function handle_get_batch_status() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $batch_id = isset($_POST['batch_id']) ? sanitize_text_field(wp_unslash($_POST['batch_id'])) : ''; if (!$batch_id) { wp_send_json_error('Missing batch_id.'); } $response = $this->get_provider()->saas_get_batch_status($batch_id); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } wp_send_json_success($response); } /** * Fetch paginated batch results (for Review Queue). */ public function handle_get_batch_results() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $batch_id = isset($_POST['batch_id']) ? sanitize_text_field(wp_unslash($_POST['batch_id'])) : ''; $limit = isset($_POST['limit']) ? absint($_POST['limit']) : 10; $offset = isset($_POST['offset']) ? absint($_POST['offset']) : 0; if (!$batch_id) { wp_send_json_error('Missing batch_id.'); } $response = $this->get_provider()->saas_get_batch_results($batch_id, $limit, $offset); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } // Filter out items whose links have already been physically applied if (!empty($response['results']) && is_array($response['results'])) { $target_id = 0; foreach ($response['results'] as $item) { if (!empty($item['target_post_id'])) { $target_id = (int) $item['target_post_id']; break; } } $excluded_donor_ids = []; if ($target_id && class_exists('AILM_Link_Validator')) { $excluded_donor_ids = AILM_Link_Validator::get_excluded_donor_ids($target_id); } $filtered = []; foreach ($response['results'] as $item) { $donor_id = isset($item['donor_post_id']) ? (int) $item['donor_post_id'] : 0; if ($donor_id && isset($excluded_donor_ids[$donor_id])) { continue; // Already applied โ€” skip } $filtered[] = $item; } $response['results'] = $filtered; } // Enrich SaaS results with local WordPress post data (titles + links) // and anchor over-optimization warnings. if (!empty($response['results']) && is_array($response['results'])) { // Determine target_id from results. $target_id = 0; foreach ($response['results'] as $item) { if (!empty($item['target_post_id'])) { $target_id = (int) $item['target_post_id']; break; } } $existing_anchor_counts = array(); if ($target_id && class_exists('AILM_Link_Repository')) { $existing_anchor_counts = AILM_Link_Repository::get_anchor_counts_for_target($target_id); } // Count anchors inside the current batch (from already fetched results). $in_batch_anchor_counts = array(); foreach ($response['results'] as $item) { $anchor = isset($item['anchor']) ? sanitize_text_field($item['anchor']) : ''; if ($anchor !== '') { if (!isset($in_batch_anchor_counts[$anchor])) { $in_batch_anchor_counts[$anchor] = 0; } $in_batch_anchor_counts[$anchor]++; } } foreach ($response['results'] as &$item) { $donor_id = isset($item['donor_post_id']) ? (int) $item['donor_post_id'] : 0; if ($donor_id) { $item['donor_title'] = get_the_title($donor_id) ?: sprintf('Donor #%d', $donor_id); $item['donor_view_url'] = get_permalink($donor_id); $item['donor_edit_url'] = get_edit_post_link($donor_id, 'raw'); } $anchor = isset($item['anchor']) ? sanitize_text_field($item['anchor']) : ''; if ($anchor !== '') { $existing = isset($existing_anchor_counts[$anchor]) ? $existing_anchor_counts[$anchor] : 0; $batch_total = isset($in_batch_anchor_counts[$anchor]) ? $in_batch_anchor_counts[$anchor] : 0; $item['anchor_total_count'] = $existing + $batch_total; $item['anchor_batch_count'] = $batch_total; } else { $item['anchor_total_count'] = 0; $item['anchor_batch_count'] = 0; } } unset($item); // Build batch-level summary for the UI header. $response['anchor_usage_summary'] = array(); foreach ($in_batch_anchor_counts as $anchor => $batch_count) { $existing = isset($existing_anchor_counts[$anchor]) ? $existing_anchor_counts[$anchor] : 0; $response['anchor_usage_summary'][$anchor] = array( 'existing' => $existing, 'in_batch' => $batch_count, 'total' => $existing + $batch_count, ); } } $response['available_count'] = !empty($response['results']) && is_array($response['results']) ? count($response['results']) : 0; wp_send_json_success($response); } /** * Apply a single batch item to a donor post. * * Mirrors handle_apply_smart_link but skips SaaS billing โ€” * tokens are deducted only at batch confirm time. */ public function handle_apply_batch_item() { 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'])) : ''; $result_html = isset($_POST['result_html']) ? wp_kses_post(wp_unslash($_POST['result_html'])) : ''; $anchor = isset($_POST['anchor']) ? sanitize_text_field(wp_unslash($_POST['anchor'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON decoded below; individual fields sanitized on use. $fallback = isset($_POST['fallback']) ? wp_unslash($_POST['fallback']) : null; if ($fallback && is_string($fallback)) { $fallback = json_decode($fallback, true); } $result = $this->_apply_item_internal( $donor_id, $target_id, $donor_chunk, $mode, $original_target, $result_html, $anchor, $fallback ); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success([ 'message' => 'Link successfully applied.', 'anchor' => $result['anchor'], 'mode' => $result['mode'], ]); } /** * Confirm selected items from a batch โ€” atomically deducts tokens on the SaaS * and physically applies each link to the corresponding donor post. */ public function handle_confirm_batch() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $batch_id = isset($_POST['batch_id']) ? sanitize_text_field(wp_unslash($_POST['batch_id'])) : ''; $item_indices = isset($_POST['item_indices']) ? array_map('intval', (array) $_POST['item_indices']) : []; if (!$batch_id || empty($item_indices)) { wp_send_json_error('Missing batch_id or items to confirm.'); } // 1. Fetch batch results BEFORE billing so we can validate guards $batch_results = $this->get_provider()->saas_get_batch_results($batch_id, 100, 0); if (is_wp_error($batch_results)) { wp_send_json_error('Failed to fetch batch results: ' . $batch_results->get_error_message()); } $results_map = []; if (!empty($batch_results['results'])) { foreach ($batch_results['results'] as $r) { $idx = isset($r['item_index']) ? (int) $r['item_index'] : null; if ($idx !== null) { $results_map[$idx] = $r; } } } elseif (!empty($batch_results['items'])) { foreach ($batch_results['items'] as $r) { $idx = isset($r['item_index']) ? (int) $r['item_index'] : null; if ($idx !== null) { $results_map[$idx] = $r; } } } // 2. Validate bulk settings before spending tokens $valid_indices = []; $skipped = []; foreach ($item_indices as $idx) { if (!isset($results_map[$idx])) { $skipped[] = ['idx' => $idx, 'reason' => 'Result not found.']; continue; } $r = $results_map[$idx]; $donor_id = isset($r['donor_post_id']) ? (int) $r['donor_post_id'] : 0; $target_id = isset($r['target_post_id']) ? (int) $r['target_post_id'] : 0; if (!$donor_id || !$target_id) { $skipped[] = ['idx' => $idx, 'reason' => 'Missing donor or target ID.']; continue; } $guard = AILM_Link_Validator::validate_donor($donor_id, $target_id); if (is_wp_error($guard)) { $skipped[] = ['idx' => $idx, 'reason' => $guard->get_error_message()]; continue; } $valid_indices[] = $idx; } if (empty($valid_indices)) { wp_send_json_error('No items passed bulk settings checks. ' . implode('; ', array_column($skipped, 'reason'))); } // 3. Deduct tokens on SaaS only for validated items $response = $this->get_provider()->saas_confirm_batch($batch_id, $valid_indices); if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } if (isset($response['tokens_balance'])) { update_option('linkmesh_tokens_balance', (int) $response['tokens_balance']); } // 4. Physically apply links $applied = 0; $failed = 0; $errors = []; foreach ($valid_indices as $idx) { $r = $results_map[$idx]; $donor_id = (int) $r['donor_post_id']; $target_id = (int) $r['target_post_id']; $donor_text = isset($r['donor_text']) ? sanitize_textarea_field($r['donor_text']) : ''; $mode = isset($r['mode']) ? sanitize_text_field($r['mode']) : 'in_text'; $original_target = isset($r['original_target']) ? sanitize_text_field($r['original_target']) : ''; $result_html = isset($r['result_html']) ? $r['result_html'] : ''; $anchor = isset($r['anchor']) ? sanitize_text_field($r['anchor']) : ''; if (empty($donor_text) || empty($result_html)) { $failed++; $errors[] = "Item {$idx}: missing required fields."; continue; } $apply_result = $this->_apply_item_internal( $donor_id, $target_id, $donor_text, $mode, $original_target, $result_html, $anchor, null ); if (is_wp_error($apply_result)) { $failed++; $errors[] = "Item {$idx}: " . $apply_result->get_error_message(); } else { $applied++; } } // Clean up the batch from WordPress options and SaaS server // only if ALL batch items were successfully applied. if ($failed === 0 && $applied === count($results_map)) { $active_batches = get_option('ailm_active_batches', []); if (isset($active_batches[$batch_id])) { unset($active_batches[$batch_id]); update_option('ailm_active_batches', $active_batches); } $provider = $this->get_provider(); if (method_exists($provider, 'saas_delete_batch')) { $provider->saas_delete_batch($batch_id); } } wp_send_json_success([ 'confirmed_count' => $response['confirmed_count'] ?? count($valid_indices), 'tokens_balance' => $response['tokens_balance'] ?? null, 'applied_count' => $applied, 'failed_count' => $failed, 'skipped_count' => count($skipped), 'skipped' => $skipped, 'errors' => $errors, ]); } /** * List all active batches for the Review Queue dashboard. */ public function handle_review_queue_list() { check_ajax_referer('ailm_semantic_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized access.'); } $active_batches = get_option('ailm_active_batches', []); $batches = []; foreach ($active_batches as $batch_id => $meta) { // Skip corrupted entries (empty or non-string keys) if (!is_string($batch_id) || empty($batch_id)) { continue; } $status = $this->get_provider()->saas_get_batch_status($batch_id); if (is_wp_error($status)) { // Silently skip stale batches so one bad entry doesn't block the whole queue continue; } $merged = array_merge($meta, $status); if (empty($merged['target_title']) && !empty($merged['target_id'])) { $merged['target_title'] = get_the_title($merged['target_id']); } // Calculate real available count by filtering already-applied items $available_count = 0; $batch_results = $this->get_provider()->saas_get_batch_results($batch_id, 100, 0); if (!is_wp_error($batch_results) && !empty($batch_results['results']) && is_array($batch_results['results'])) { $target_id = !empty($merged['target_id']) ? (int) $merged['target_id'] : 0; $excluded = []; if ($target_id && class_exists('AILM_Link_Validator')) { $excluded = AILM_Link_Validator::get_excluded_donor_ids($target_id); } foreach ($batch_results['results'] as $r) { $donor_id = isset($r['donor_post_id']) ? (int) $r['donor_post_id'] : 0; if ($donor_id && isset($excluded[$donor_id])) { continue; } $available_count++; } } else { $available_count = $merged['completed_items'] ?? 0; } $merged['available_count'] = $available_count; $batches[] = $merged; } usort($batches, function ($a, $b) { return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0); }); wp_send_json_success(['batches' => $batches]); } /** * WP-Cron callback: remove stale batches older than 48 hours from wp_options. */ public static function cleanup_stale_batches() { $active_batches = get_option('ailm_active_batches', []); if (empty($active_batches)) { return; } $cutoff = time() - (48 * HOUR_IN_SECONDS); $cleaned = false; foreach ($active_batches as $batch_id => $meta) { $created_at = isset($meta['created_at']) ? (int) $meta['created_at'] : 0; if ($created_at < $cutoff) { unset($active_batches[$batch_id]); $cleaned = true; } } if ($cleaned) { update_option('ailm_active_batches', $active_batches); } } }