self::CRON_INTERVAL, 'display' => __('AILM Sync Fallback (2 min)', 'ailinkmaster-internal-auto-manager'), ); return $schedules; } /** * AJAX: start the sync process. * * @return void */ public static function ajax_start() { check_ajax_referer('ailm_orphan_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Permission denied.', 'ailinkmaster-internal-auto-manager')); } if (self::is_running()) { wp_send_json_error(__('Sync is already running.', 'ailinkmaster-internal-auto-manager')); } self::start_import(); wp_send_json_success(array('message' => __('Sync started.', 'ailinkmaster-internal-auto-manager'))); } /** * AJAX: get current sync status AND process one batch. * * This is the primary execution driver. The browser polls this endpoint * every 2 seconds; each call processes one batch (up to * TIME_LIMIT_SECONDS) and returns the updated status. This avoids the * need for wp_remote_post loopback, which is blocked on many hostings. * * @return void */ public static function ajax_status() { check_ajax_referer('ailm_orphan_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Permission denied.', 'ailinkmaster-internal-auto-manager')); } // Process one batch if the sync is running and not locked. if (self::is_running() && !self::is_batch_locked()) { self::process_batch(); } wp_send_json_success(self::get_status()); } /** * AJAX: cancel a running sync. * * @return void */ public static function ajax_cancel() { check_ajax_referer('ailm_orphan_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Permission denied.', 'ailinkmaster-internal-auto-manager')); } if (!self::is_running()) { wp_send_json_error(__('No sync is currently running.', 'ailinkmaster-internal-auto-manager')); } self::cancel_import(); wp_send_json_success(array('message' => __('Sync cancelled.', 'ailinkmaster-internal-auto-manager'))); } /** * Start the sync: count posts, set options. * * @return void */ public static function start_import() { global $wpdb; $allowed_types = get_option('ailm_allowed_post_types', array('post', 'page')); $type_placeholders = implode(',', array_fill(0, count($allowed_types), '%s')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total_posts = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type IN ($type_placeholders) AND post_status = 'publish'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $allowed_types ) ); $token = wp_generate_password(24, false); update_option('ailm_import_token', $token); update_option('ailm_import_last_post_id', 0); update_option('ailm_import_total_posts', $total_posts); update_option('ailm_import_processed_posts', 0); update_option('ailm_import_links_registered', 0); update_option('ailm_import_links_found', 0); update_option('ailm_import_running', 'yes'); update_option('ailm_import_status_text', __('Syncing manual links...', 'ailinkmaster-internal-auto-manager')); update_option('ailm_import_last_heartbeat', time()); delete_option('ailm_import_batch_lock'); // Enable bulk mode in the repository: skips per-link cache // invalidation and SaaS sync-queue enqueuing for performance. if (class_exists('AILM_Link_Repository')) { AILM_Link_Repository::set_bulk_mode(true); } // Disable reconciler during bulk sync — we register rules directly. if (class_exists('AILM_Reconciler')) { AILM_Reconciler::disable(); } // Schedule the cron fallback (in case the browser is closed). self::schedule_cron_fallback(); 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 Sync: started. Total posts: ' . $total_posts); } } /** * Check if sync is currently running. * * @return bool */ public static function is_running() { return get_option('ailm_import_running', 'no') === 'yes'; } /** * Get current status for UI polling. * * @return array */ public static function get_status() { $total = (int) get_option('ailm_import_total_posts', 0); $processed = (int) get_option('ailm_import_processed_posts', 0); $links = (int) get_option('ailm_import_links_registered', 0); $links_found = (int) get_option('ailm_import_links_found', 0); $running = self::is_running(); $percent = $total > 0 ? round(($processed / $total) * 100) : 0; return array( 'running' => $running, 'total_posts' => $total, 'processed_posts' => $processed, 'links_registered' => $links, 'links_found' => $links_found, 'percent' => $percent, 'status_text' => get_option('ailm_import_status_text', ''), ); } /** * Check if a batch is currently being processed (lock active). * * @return bool True if locked. */ private static function is_batch_locked() { $lock = (int) get_option('ailm_import_batch_lock', 0); if ($lock === 0) { return false; } // Lock expired? if ((time() - $lock) > self::LOCK_TIMEOUT) { delete_option('ailm_import_batch_lock'); return false; } return true; } /** * Acquire the batch lock. * * @return bool True if acquired, false if already locked. */ private static function acquire_batch_lock() { if (self::is_batch_locked()) { return false; } update_option('ailm_import_batch_lock', time()); return true; } /** * Release the batch lock. * * @return void */ private static function release_batch_lock() { delete_option('ailm_import_batch_lock'); } /** * Worker callback: process one batch of posts (legacy HTTP trigger). * * Kept for backward compatibility but no longer the primary driver. * * @return void */ public static function run_worker() { // phpcs:ignore WordPress.Security.NonceVerification.Missing $sent_token = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : ''; $stored_token = get_option('ailm_import_token'); if (empty($sent_token) || !hash_equals($stored_token, $sent_token)) { wp_die(); } if (!self::is_running()) { wp_die(); } self::process_batch(); wp_die(); } /** * Process one batch of posts: scan + register links. * * Acquires a lock to prevent concurrent processing, runs for up to * TIME_LIMIT_SECONDS, then releases the lock and returns. * * Supports both regular post_content and Elementor-built posts. * * @return void */ private static function process_batch() { if (!self::acquire_batch_lock()) { 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 Sync: batch skipped — lock held by another process.'); } return; } global $wpdb; if (!class_exists('AILM_Link_Repository')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-ailm-link-repository.php'; } if (!class_exists('AILM_DOM_Helper')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-ailm-dom-helper.php'; } if (!class_exists('AILM_Scout_Scanner')) { require_once plugin_dir_path(dirname(__FILE__)) . 'modules/404-scout/class-ailm-scout-scanner.php'; } // Ensure bulk mode is on (worker/cron runs in a fresh request). if (class_exists('AILM_Link_Repository')) { AILM_Link_Repository::set_bulk_mode(true); } $last_id = (int) get_option('ailm_import_last_post_id', 0); $allowed_types = get_option('ailm_allowed_post_types', array('post', 'page')); $type_placeholders = implode(',', array_fill(0, count($allowed_types), '%s')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $posts = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_type IN ($type_placeholders) AND post_status = 'publish' AND ID > %d ORDER BY ID ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge($allowed_types, array($last_id, self::BATCH_SIZE)) ) ); if (empty($posts)) { self::release_batch_lock(); self::complete_import(); return; } $home_url = untrailingslashit(get_home_url()); $home_host = wp_parse_url($home_url, PHP_URL_HOST); $target_limit = (int) get_option('ailm_target_limit', 5); $links_this_batch = 0; $found_this_batch = 0; // Local cache of inbound rule counts to avoid a SELECT COUNT(*) per // link. Keyed by target_id; incremented when we register a new rule // for that target. $inbound_count_cache = array(); $batch_start = microtime(true); foreach ($posts as $p) { $last_id = $p->ID; // Time guard: stop before PHP's max_execution_time kills us. if ((microtime(true) - $batch_start) > self::TIME_LIMIT_SECONDS) { break; } // Collect HTML chunks from both regular content and Elementor. $html_chunks = self::collect_html_chunks($p); if (empty($html_chunks)) { continue; } // Scan the post so wp_ailm_links (reality) is populated. if (class_exists('AILM_Scout_Scanner')) { AILM_Scout_Scanner::process_post($p->ID); } // Pre-load existing rules for this donor (avoid N+1). // Store rule_id and source so we can correct the source if a // manual link was previously registered with a different source. $existing_rules = AILM_Link_Repository::get_rules_for_donor($p->ID); $existing_map = array(); foreach ($existing_rules as $rule) { $key = (int) $rule->target_id . '|' . mb_strtolower($rule->anchor, 'UTF-8'); $existing_map[$key] = array( 'rule_id' => (int) $rule->id, 'source' => $rule->source, ); } foreach ($html_chunks as $html) { $dom_helper = new AILM_DOM_Helper($html); $dom = $dom_helper->get_dom(); if (!$dom) { continue; } $xpath = new DOMXPath($dom); $links = $xpath->query('//a'); if ($links->length === 0) { continue; } // Build the virtual_text ONCE per chunk to compute occurrences. $virtual_text = $dom_helper->get_virtual_text(); foreach ($links as $a) { $href = $a->getAttribute('href'); if (empty($href)) { continue; } // Skip plugin-managed links. $data_ailm = $a->getAttribute('data-ailm'); $class = $a->getAttribute('class'); if (!empty($data_ailm) || strpos($class, 'ailm-internal-link') !== false) { continue; } // Only internal links. $link_host = wp_parse_url($href, PHP_URL_HOST); if ($link_host && $link_host !== $home_host) { continue; } if (strpos($href, '#') === 0 || strpos($href, 'mailto:') === 0 || strpos($href, 'tel:') === 0) { continue; } $target_id = url_to_postid($href); if (!$target_id || $target_id === $p->ID) { continue; } $anchor = trim(preg_replace('/\s+/u', ' ', str_replace("\xC2\xA0", ' ', $a->textContent))); if (empty($anchor)) { continue; } // Skip if rule already exists. $key = $target_id . '|' . mb_strtolower($anchor, 'UTF-8'); if (isset($existing_map[$key])) { $found_this_batch++; // If the existing rule has a different source (e.g. // 'ai' from the reconciler), correct it to // 'pre_existing' so the Scanner marks this link as // 'manual' origin, not 'plugin'. if ($existing_map[$key]['source'] !== 'pre_existing') { AILM_Link_Repository::update_rule_source( $existing_map[$key]['rule_id'], 'pre_existing' ); $existing_map[$key]['source'] = 'pre_existing'; if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log(sprintf( 'AILM Sync: corrected source to pre_existing for rule %d (donor %d, target %d).', $existing_map[$key]['rule_id'], $p->ID, $target_id )); } } continue; } $found_this_batch++; // Respect target limit — use local cache to avoid N+1. if (!isset($inbound_count_cache[$target_id])) { $inbound_count_cache[$target_id] = AILM_Link_Repository::get_active_inbound_rule_count($target_id); } if ($inbound_count_cache[$target_id] >= $target_limit) { continue; } // Compute the real occurrence of this anchor in the // virtual_text, consistent with how // inject_physical_links() counts occurrences. $occurrence = self::compute_anchor_occurrence($dom_helper, $virtual_text, $a, $anchor); // Register with in_html=1 (link already in HTML). AILM_Link_Repository::register_link( $p->ID, get_permalink($target_id), $anchor, 'pre_existing', $occurrence, true ); // Keep the local cache in sync. $inbound_count_cache[$target_id]++; $existing_map[$key] = array( 'rule_id' => 0, // newly registered, ID not needed here 'source' => 'pre_existing', ); $links_this_batch++; } } } // Update progress. $processed = (int) get_option('ailm_import_processed_posts', 0) + count($posts); $total = (int) get_option('ailm_import_total_posts', 1); $total_links = (int) get_option('ailm_import_links_registered', 0) + $links_this_batch; $total_found = (int) get_option('ailm_import_links_found', 0) + $found_this_batch; $percent = $total > 0 ? round(($processed / $total) * 100) : 0; update_option('ailm_import_last_post_id', $last_id); update_option('ailm_import_processed_posts', $processed); update_option('ailm_import_links_registered', $total_links); update_option('ailm_import_links_found', $total_found); update_option('ailm_import_status_text', sprintf( /* translators: 1: processed count, 2: total count, 3: links found, 4: new links registered */ __('Syncing: %1$d / %2$d posts (%3$d links found, %4$d new)', 'ailinkmaster-internal-auto-manager'), $processed, $total, $total_found, $total_links )); // Heartbeat. update_option('ailm_import_last_heartbeat', time()); self::release_batch_lock(); if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log(sprintf( 'AILM Sync: batch done. Processed %d/%d posts, %d found this batch (%d new), %d total found, %d total new.', $processed, $total, $found_this_batch, $links_this_batch, $total_found, $total_links )); } } /** * Collect HTML chunks from a post for link scanning. * * For Elementor posts, collects HTML from all text-editor and heading * widgets. For regular posts, uses post_content directly. * * @param object $post Post object with ID and post_content. * @return array Array of HTML strings. */ private static function collect_html_chunks($post) { $html_chunks = array(); $is_elementor = class_exists('AILM_Elementor_Handler') && AILM_Elementor_Handler::is_elementor_post($post->ID); if ($is_elementor) { // AILM_Elementor_Handler::process_content expects a callback // that returns modified HTML. We use a collector that does NOT // modify anything — we only read links for registration. $collector = function ($html) use (&$html_chunks) { $html_chunks[] = $html; return $html; // unchanged }; AILM_Elementor_Handler::process_content($post->ID, $collector); // Elementor posts may also have content in post_content (e.g. // a fallback or non-Elementor sections). Include it if non-empty. if (!empty($post->post_content)) { $html_chunks[] = $post->post_content; } } else { if (!empty($post->post_content)) { $html_chunks[] = $post->post_content; } } return $html_chunks; } /** * Compute the 1-based occurrence index of an anchor text inside the * donor's virtual_text, matching the counting logic of * AILM_Link_Builder::inject_physical_links(). * * This ensures that when a pre-existing manual link is registered, the * occurrence field accurately reflects which match in the text is * already linked, preventing the injector from targeting the wrong * match in the future. * * @param AILM_DOM_Helper $dom_helper The DOM helper for the chunk. * @param string $virtual_text The virtual text of the chunk. * @param DOMElement $a The node being registered. * @param string $anchor Normalized anchor text. * @return int Occurrence number (1-based), default 1 if not found. */ private static function compute_anchor_occurrence($dom_helper, $virtual_text, $a, $anchor) { // Build the same fuzzy pattern used by the injector. $escaped_parts = array_map(function ($p) { return str_replace("'", "['''`´]", preg_quote($p, '/')); }, preg_split('/\s+/u', trim($anchor), -1, PREG_SPLIT_NO_EMPTY)); if (empty($escaped_parts)) { return 1; } $fuzzy_kw = implode('[\p{Z}\s\p{P}\p{S}]+', $escaped_parts); $pattern = '/(? node's text in the virtual_text // by counting preceding text (same logic as get_manual_links_positions). $xpath = new DOMXPath($dom_helper->get_dom()); $exclusion = 'not(ancestor::a) and not(ancestor::script) and not(ancestor::style)'; $preceding = $xpath->query('preceding::text()[' . $exclusion . ']', $a); $node_offset = 0; $prev_block = null; $block_tags = array('p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'table', 'tr', 'td', 'th', 'figure', 'article', 'section', 'header', 'footer', 'aside', 'nav', 'main'); foreach ($preceding as $pt) { $len = mb_strlen($pt->nodeValue, 'UTF-8'); if ($len > 0) { $bp = null; $n = $pt; while ($n) { if ($n->nodeType === XML_ELEMENT_NODE && in_array(strtolower($n->nodeName), $block_tags, true)) { $bp = $n->getNodePath(); break; } $n = $n->parentNode; } if ($prev_block !== null && $bp !== $prev_block) { $node_offset += 2; } $prev_block = $bp; $node_offset += $len; } } // Find which match index corresponds to this node offset. $best = 1; foreach ($matches[0] as $idx => $m) { $char_off = mb_strlen(substr($virtual_text, 0, $m[1]), 'UTF-8'); if ($char_off <= $node_offset) { $best = $idx + 1; } else { break; } } return $best; } /** * Complete the sync: re-enable reconciler, sync counts, clean up. * * @return void */ private static function complete_import() { // Disable bulk mode and flush caches once. if (class_exists('AILM_Link_Repository')) { AILM_Link_Repository::set_bulk_mode(false); } // Re-enable reconciler. if (class_exists('AILM_Reconciler')) { AILM_Reconciler::enable(); } // Sync _ailm_inbound_count for all posts that have rules. if (class_exists('AILM_Link_Repository')) { global $wpdb; $rules_table = AILM_Link_DB::get_rules_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $targets = $wpdb->get_col( "SELECT DISTINCT target_id FROM {$rules_table} WHERE target_id > 0 AND status = 'active'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); foreach ($targets as $target_id) { $count = AILM_Link_Repository::get_active_inbound_rule_count((int) $target_id); update_post_meta((int) $target_id, '_ailm_inbound_count', $count); } AILM_Link_Repository::invalidate_cache(); } $total_links = (int) get_option('ailm_import_links_registered', 0); $total_found = (int) get_option('ailm_import_links_found', 0); update_option('ailm_import_running', 'no'); update_option('ailm_import_status_text', sprintf( /* translators: 1: links found, 2: new links registered */ __('Sync complete: %1$d links found, %2$d new registered.', 'ailinkmaster-internal-auto-manager'), $total_found, $total_links )); // Clean up token, lock, and cron. delete_option('ailm_import_token'); delete_option('ailm_import_batch_lock'); self::clear_cron_fallback(); if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log(sprintf('AILM Sync: complete. Found %d links, registered %d new.', $total_found, $total_links)); } } /** * Cancel a running sync and clean up. * * @return void */ public static function cancel_import() { // Disable bulk mode and re-enable reconciler. if (class_exists('AILM_Link_Repository')) { AILM_Link_Repository::set_bulk_mode(false); AILM_Link_Repository::invalidate_cache(); } if (class_exists('AILM_Reconciler')) { AILM_Reconciler::enable(); } update_option('ailm_import_running', 'no'); update_option('ailm_import_status_text', __('Sync cancelled by user.', 'ailinkmaster-internal-auto-manager')); delete_option('ailm_import_token'); delete_option('ailm_import_batch_lock'); self::clear_cron_fallback(); 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 Sync: cancelled by user.'); } } // ========================================================================= // CRON FALLBACK: process a batch if the browser is closed. // ========================================================================= /** * Schedule the recurring cron fallback. * * @return void */ private static function schedule_cron_fallback() { if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_event(time() + self::CRON_INTERVAL, 'ailm_import_interval', self::CRON_HOOK); } } /** * Clear the cron fallback event. * * @return void */ private static function clear_cron_fallback() { $timestamp = wp_next_scheduled(self::CRON_HOOK); if ($timestamp) { wp_unschedule_event($timestamp, self::CRON_HOOK); } wp_clear_scheduled_hook(self::CRON_HOOK); } /** * Cron callback: process one batch if the sync is running. * * This fires every CRON_INTERVAL seconds as a fallback when the browser * is closed. If the browser is actively polling, the ajax_status() * endpoint drives the processing and this cron is a no-op (lock held). * * @return void */ public static function cron_process_batch() { if (!self::is_running()) { // Sync finished normally — make sure cron is cleared. self::clear_cron_fallback(); return; } // Only process if not already being processed by a browser poll. if (self::is_batch_locked()) { return; } 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 Sync: cron fallback processing a batch.'); } self::process_batch(); } }