'POST', 'callback' => [__CLASS__, 'handle_analyze'], 'permission_callback' => [__CLASS__, 'permission_edit_posts'], 'args' => [ 'post_id' => ['required' => true, 'sanitize_callback' => 'absint'], ], ]); register_rest_route(self::NAMESPACE, '/suggest-donors', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_suggest_donors'], 'permission_callback' => [__CLASS__, 'permission_edit_posts'], 'args' => [ 'target_id' => ['required' => true, 'sanitize_callback' => 'absint'], 'target_title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], ], ]); register_rest_route(self::NAMESPACE, '/suggest-targets', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_suggest_targets'], 'permission_callback' => [__CLASS__, 'permission_edit_posts'], 'args' => [ 'donor_text' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], 'donor_post_id' => ['sanitize_callback' => 'absint'], ], ]); register_rest_route(self::NAMESPACE, '/generate-anchor', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_generate_anchor'], 'permission_callback' => [__CLASS__, 'permission_edit_posts'], 'args' => [ 'donor_text' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], 'target_id' => ['required' => true, 'sanitize_callback' => 'absint'], 'target_title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], ], ]); register_rest_route(self::NAMESPACE, '/apply', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_apply'], 'permission_callback' => [__CLASS__, 'permission_apply_links'], 'args' => [ 'donor_post_id' => ['required' => true, 'sanitize_callback' => 'absint'], 'target_id' => ['required' => true, 'sanitize_callback' => 'absint'], 'donor_chunk' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], 'mode' => ['sanitize_callback' => 'sanitize_text_field'], 'original_target' => ['sanitize_callback' => 'sanitize_text_field'], 'anchor' => ['sanitize_callback' => 'sanitize_text_field'], 'update_dom' => ['sanitize_callback' => 'rest_sanitize_boolean'], // Gutenberg block type of the donor block (e.g. 'core/heading'). // Used to enforce ailm_excluded_html for inline (update_dom=false) links. 'block_type' => ['sanitize_callback' => 'sanitize_text_field'], ], ]); register_rest_route(self::NAMESPACE, '/task-status', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_task_status'], 'permission_callback' => [__CLASS__, 'permission_edit_posts'], 'args' => [ 'task_id' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], ], ]); } /** * Poll a SaaS task status. Used by JS for anchor generation polling. * * Returns the raw task status response from the SaaS server. The JS side * checks for status === 'SUCCESS' || 'completed' and reads response.result. * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_task_status($request) { $task_id = (string) $request->get_param('task_id'); if (empty($task_id)) { return rest_ensure_response(new WP_Error('missing_task', 'task_id is required.', ['status' => 400])); } if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); $response = $provider->saas_check_task_status($task_id); if (is_wp_error($response)) { return rest_ensure_response($response); } return rest_ensure_response($response); } // ------------------------------------------------------------------------- // Permission callbacks // ------------------------------------------------------------------------- /** * Standard edit_posts capability check. * * @param WP_REST_Request $request * @return true|WP_Error */ public static function permission_edit_posts($request) { if (!current_user_can('edit_posts')) { return new WP_Error('rest_forbidden', 'Insufficient permissions.', ['status' => 403]); } return true; } /** * Apply requires manage_options or custom ailm_apply_links capability. * Billing (token deduction) is a financial operation. * * @param WP_REST_Request $request * @return true|WP_Error */ public static function permission_apply_links($request) { if (!current_user_can('manage_options') && !current_user_can('ailm_apply_links')) { return new WP_Error('rest_forbidden', 'Insufficient permissions to apply links.', ['status' => 403]); } return true; } // ------------------------------------------------------------------------- // Endpoint: POST /analyze // ------------------------------------------------------------------------- /** * Return indexed status and inbound/outbound rule counts for a post. * * Does NOT include suggestions (those are loaded lazily by tab click). * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_analyze($request) { $post_id = (int) $request->get_param('post_id'); $post = get_post($post_id); if (!$post) { return rest_ensure_response(new WP_Error('not_found', 'Post not found.', ['status' => 404])); } $is_indexed = get_post_meta($post_id, '_ailm_is_indexed', true); $inbound_count = 0; $outbound_count = 0; if (class_exists('AILM_Link_Repository')) { $inbound_count = AILM_Link_Repository::get_inbound_count($post_id); $outbound_count = AILM_Link_Repository::get_outbound_count($post_id); } return rest_ensure_response([ 'is_indexed' => !empty($is_indexed), 'inbound_count' => (int) $inbound_count, 'outbound_count' => (int) $outbound_count, ]); } // ------------------------------------------------------------------------- // Endpoint: POST /suggest-donors // ------------------------------------------------------------------------- /** * Inbound opportunities: find donor posts that could link TO this target. * Wraps the existing saas_get_opportunities() backend call. * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_suggest_donors($request) { $target_id = (int) $request->get_param('target_id'); $target_title = (string) $request->get_param('target_title'); $target = get_post($target_id); if (!$target) { return rest_ensure_response(new WP_Error('not_found', 'Target post not found.', ['status' => 404])); } // Inbound (Link TO this post) search requires the target's indexed // macro-vector on the SaaS server. If the post is not indexed, the // server short-circuits to an empty result, which is indistinguishable // from a genuine "no semantic matches". Surface the real reason so the // user knows to index the post first. Uses the same _ailm_is_indexed // meta as handle_analyze() (the Indexed indicator in the sidebar). $is_indexed = get_post_meta($target_id, '_ailm_is_indexed', true); if (empty($is_indexed)) { return rest_ensure_response(new WP_Error( 'target_not_indexed', 'This post is not indexed. Index it first.', ['status' => 409] )); } if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); $response = $provider->saas_get_opportunities($target_id, $target_title); if (is_wp_error($response)) { return rest_ensure_response($response); } $opportunities = isset($response['results']) ? $response['results'] : []; // Filter out donors that violate guards or already link to this target. if (!empty($opportunities) && class_exists('AILM_Link_Validator')) { $excluded_donor_ids = AILM_Link_Validator::get_excluded_donor_ids($target_id); $filtered = []; foreach ($opportunities as $opp) { $donor_post_id = (int) $opp['post_id']; if (isset($excluded_donor_ids[$donor_post_id])) { continue; } if (AILM_Link_Validator::validate_donor($donor_post_id, $target_id) !== true) { continue; } $filtered[] = $opp; } $opportunities = $filtered; } // Enrich with local WordPress data. foreach ($opportunities as &$opp) { $donor_post_id = (int) $opp['post_id']; // Decode all HTML entities (numeric like ’ and named) so titles // with smart typography (apostrophes, quotes, dashes) render correctly // in any language. wp_specialchars_decode(ENT_QUOTES) does not reliably // decode numeric entities; html_entity_decode with ENT_HTML5 + UTF-8 does. $opp['donor_title'] = html_entity_decode(get_the_title($donor_post_id), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $opp['donor_view_url'] = get_permalink($donor_post_id); $opp['donor_edit_url'] = get_edit_post_link($donor_post_id, 'raw'); } unset($opp); return rest_ensure_response(['opportunities' => $opportunities]); } // ------------------------------------------------------------------------- // Endpoint: POST /suggest-targets // ------------------------------------------------------------------------- /** * Outbound opportunities: find target articles for a donor paragraph. * Calls the new backend GET /api/v1/sites/{site_id}/opportunities/quick. * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_suggest_targets($request) { $donor_text = (string) $request->get_param('donor_text'); $donor_post_id = (int) $request->get_param('donor_post_id'); if (empty($donor_text)) { return rest_ensure_response(['opportunities' => []]); } if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); $response = $provider->saas_get_quick_opportunities($donor_text, $donor_post_id); if (is_wp_error($response)) { return rest_ensure_response($response); } $opportunities = isset($response['results']) ? $response['results'] : []; // Enrich with local WordPress data (title, url, edit_url). foreach ($opportunities as &$opp) { $target_post_id = (int) $opp['post_id']; // Decode all HTML entities (numeric like ’ and named) so titles // with smart typography (apostrophes, quotes, dashes) render correctly // in any language. See note in handle_suggest_donors() above. $opp['target_title'] = html_entity_decode(get_the_title($target_post_id), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $opp['target_url'] = get_permalink($target_post_id); $opp['target_edit_url'] = get_edit_post_link($target_post_id, 'raw'); } unset($opp); return rest_ensure_response(['opportunities' => $opportunities]); } // ------------------------------------------------------------------------- // Endpoint: POST /generate-anchor // ------------------------------------------------------------------------- /** * Initiate anchor generation. Returns task_id immediately. * Polling is done on the JS side (not PHP) to avoid blocking. * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_generate_anchor($request) { $donor_text = (string) $request->get_param('donor_text'); $target_id = (int) $request->get_param('target_id'); $target_title = (string) $request->get_param('target_title'); if (empty($donor_text) || empty($target_title)) { return rest_ensure_response(new WP_Error('missing_params', 'Missing required text parameters.', ['status' => 400])); } if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); // donor_post_id = 0 because the donor paragraph may not be saved yet. $response = $provider->saas_generate_smart_append(0, $donor_text, $target_title, $target_id); if (is_wp_error($response)) { return rest_ensure_response($response); } $task_id = isset($response['task_id']) ? $response['task_id'] : ''; if (!$task_id) { return rest_ensure_response(new WP_Error('no_task', 'No task_id returned from server.', ['status' => 502])); } return rest_ensure_response(['task_id' => $task_id]); } // ------------------------------------------------------------------------- // Endpoint: POST /apply // ------------------------------------------------------------------------- /** * Apply a generated link: create rule, optionally update DOM, bill token. * * For donor != current post (sidebar, update_dom=true): * - Run guards (validate_donor_with_content). * - Duplicate check. * - Skip Intro / Min Gap via AILM_DOM_Helper. * - apply_smart_link() physically updates post_content. * - add_rule() + saas_confirm_link() (billing) only if apply succeeded. * - Re-index donor. * * For current post (inline toolbar, update_dom=false): * - Run guards. * - add_rule() + saas_confirm_link() (billing). * - Do NOT call apply_smart_link() (DOM updated via updateBlockAttributes). * - Do NOT re-index (save_post hook handles it). * * @param WP_REST_Request $request * @return WP_REST_Response */ public static function handle_apply($request) { $donor_id = (int) $request->get_param('donor_post_id'); $target_id = (int) $request->get_param('target_id'); $donor_chunk = (string) $request->get_param('donor_chunk'); $mode = (string) $request->get_param('mode'); $original_target = (string) $request->get_param('original_target'); $anchor = (string) $request->get_param('anchor'); $update_dom = (bool) $request->get_param('update_dom'); $block_type = (string) $request->get_param('block_type'); // result_html and fallback are raw HTML/JSON โ€” sanitize carefully. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via wp_kses() below. $result_html = $request->get_param('result_html'); $result_html = is_string($result_html) ? $result_html : ''; $fallback = $request->get_param('fallback'); if (is_string($fallback)) { $fallback = json_decode($fallback, true); } if (!is_array($fallback)) { $fallback = null; } // Sanitize result_html with the inline whitelist (includes class for ailm-internal-link). $allowed_html = [ 'a' => ['href' => true, 'target' => true, 'rel' => true, 'data-ailm' => true, 'class' => 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)) { return rest_ensure_response(new WP_Error('missing_params', 'Missing required parameters.', ['status' => 400])); } // Load dependencies. if (!class_exists('AILM_Link_Validator')) { require_once dirname(dirname(__DIR__)) . '/includes/class-ailm-link-validator.php'; } if (!class_exists('AILM_Link_Repository')) { require_once dirname(dirname(__DIR__)) . '/includes/class-ailm-link-repository.php'; } // 1. Guard validation. For update_dom=true use the live post content; // for inline (update_dom=false) the content is not yet saved, so we // pass the donor_chunk as the content proxy for length/manual-link checks. if ($update_dom) { $guard_result = AILM_Link_Validator::validate_donor_with_content($donor_id, $target_id, null); } else { $guard_result = AILM_Link_Validator::validate_donor_with_content($donor_id, $target_id, $donor_chunk); } if (is_wp_error($guard_result)) { return rest_ensure_response($guard_result); } // 1b. Excluded HTML areas check for inline (update_dom=false) links. // The bulk engine enforces ailm_excluded_html via AILM_DOM_Helper, // but that path is not used for inline links. Here we map the // Gutenberg block type to the excluded_html keys and reject if the // block type is protected. This is a server-side safety net that // complements the JS-side filtering of selectable blocks. if (!$update_dom && $block_type !== '') { $excluded_html = get_option('ailm_excluded_html', ['h1-h6', 'code', 'blockquote']); if (is_array($excluded_html)) { $blocked = false; if (in_array('h1-h6', $excluded_html, true) && $block_type === 'core/heading') { $blocked = true; } elseif (in_array('blockquote', $excluded_html, true) && $block_type === 'core/quote') { $blocked = true; } elseif (in_array('ul-ol', $excluded_html, true) && in_array($block_type, ['core/list', 'core/list-item'], true)) { $blocked = true; } elseif (in_array('code', $excluded_html, true) && $block_type === 'core/code') { $blocked = true; } if ($blocked) { return rest_ensure_response(new WP_Error('excluded_html', 'Links cannot be inserted into this block type (excluded by Bulk Settings).', ['status' => 400])); } } } // 2. Duplicate check: donor already links to this target. $existing_rules = AILM_Link_Repository::get_rules_for_target($target_id); foreach ($existing_rules as $rule) { if ((int) $rule->donor_id === $donor_id) { return rest_ensure_response(new WP_Error('duplicate', 'This donor already links to the target article.', ['status' => 409])); } } $applied_anchor = $anchor; // 3. If update_dom=true: apply link to post_content with Skip Intro / Min Gap guards. if ($update_dom) { if (empty($result_html)) { return rest_ensure_response(new WP_Error('missing_html', 'result_html is required for DOM update.', ['status' => 400])); } $donor_post = get_post($donor_id); if (!$donor_post) { return rest_ensure_response(new WP_Error('not_found', 'Donor post not found.', ['status' => 404])); } // Skip Intro / Min Gap calculation on the actual saved post_content. if (!class_exists('AILM_DOM_Helper')) { require_once 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_chunk); if (!$paragraph_node) { return rest_ensure_response(new WP_Error('paragraph_missing', 'Could not locate the donor paragraph in the content.', ['status' => 400])); } $para_pos = $dom_helper->get_paragraph_virtual_start($paragraph_node); if ($para_pos === null) { return rest_ensure_response(new WP_Error('mapping_failed', 'Could not map paragraph to virtual text coordinates.', ['status' => 400])); } $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; // Check anchor phrase not inside existing link. 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) { return rest_ensure_response(new WP_Error('existing_link', 'The selected anchor phrase is already part of an existing link in this paragraph.', ['status' => 400])); } } } // Skip Intro check. $skip_chars = (int) get_option('ailm_skip_chars', 100); if ($absolute_link_pos < $skip_chars) { return rest_ensure_response(new WP_Error('skip_intro', 'Link insertion point is within the Skip Intro zone.', ['status' => 400])); } // 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, ]; } $active_rules = AILM_Link_Repository::get_rules_for_donor($donor_id); 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 rest_ensure_response(new WP_Error('min_gap', 'Link insertion point violates the minimum gap between links rule.', ['status' => 400])); } // Apply the link to post_content. if (!class_exists('AILM_Content_Editor')) { require_once dirname(dirname(__DIR__)) . '/modules/semantic-engine/core/class-ailm-content-editor.php'; } $editor = new AILM_Content_Editor(); $result = $editor->apply_smart_link($donor_id, $target_id, $donor_chunk, $mode, $original_target, $result_html, $fallback, false); if (is_wp_error($result)) { // Do NOT bill if apply failed. return rest_ensure_response($result); } $applied_anchor = isset($result['anchor']) ? $result['anchor'] : $anchor; } // 4. Create rule. add_rule() returns existing rule_id on duplicate (no new insert). if (empty($applied_anchor)) { return rest_ensure_response(new WP_Error('no_anchor', 'No anchor text to create a rule.', ['status' => 400])); } $target_url = get_permalink($target_id); // in_html: when update_dom=true, apply_smart_link() already wrote the // into post_content, so the injector must skip this rule. // When update_dom=false (inline), the link lives in block attributes // only โ€” in_html=0 until save_post reconciler detects it in HTML. $gutenberg_in_html = $update_dom ? true : false; $rule_id = AILM_Link_Repository::register_link($donor_id, $target_url, $applied_anchor, 'ai_smart_link', 1, $gutenberg_in_html); if (false === $rule_id) { return rest_ensure_response(new WP_Error('rule_failed', 'Failed to create link rule.', ['status' => 500])); } // 5. Billing: only deduct token if a NEW rule was created (not a duplicate). $is_new_rule = !($rule_id && self::rule_existed_before($donor_id, $target_id, $applied_anchor, $rule_id)); if ($is_new_rule) { if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); $confirm_result = $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']); } // Update _ailm_inbound_count meta for backward compatibility. $inbound_count = (int) get_post_meta($target_id, '_ailm_inbound_count', true); update_post_meta($target_id, '_ailm_inbound_count', $inbound_count + 1); // Invalidate rule count cache. if (method_exists('AILM_Link_Repository', 'invalidate_rule_count_cache')) { AILM_Link_Repository::invalidate_rule_count_cache($donor_id); AILM_Link_Repository::invalidate_rule_count_cache($target_id); } } // 6. Run Scout Scanner on donor. if (class_exists('AILM_Scout_Scanner')) { AILM_Scout_Scanner::process_post($donor_id); } // 7. Re-index donor ONLY for update_dom=true (content changed in DB). // For inline (update_dom=false), re-indexing happens via save_post hook. if ($update_dom) { delete_post_meta($donor_id, '_ailm_is_indexed'); if (!class_exists('AILM_OpenAI_Provider')) { require_once dirname(dirname(__DIR__)) . '/modules/ai-core/providers/class-ailm-openai-provider.php'; } $provider = new AILM_OpenAI_Provider(); $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)) { $provider->saas_index_document($donor_id, $donor_post->post_content); } } return rest_ensure_response([ 'success' => true, 'rule_id' => (int) $rule_id, 'anchor' => $applied_anchor, 'billed' => $is_new_rule, 'update_dom' => $update_dom, ]); } /** * Determine whether a rule with the same donor/target/anchor existed before * the current add_rule() call (i.e. add_rule returned an existing ID). * * Since add_rule() returns the existing ID when a duplicate is found, we * check whether the returned rule_id corresponds to a pre-existing rule by * comparing against rules fetched BEFORE the call. However, because we * already performed a duplicate check in step 2 (which would have returned * a 409), reaching this point means no exact donor->target rule existed. * The add_rule() duplicate check is broader (donor + target + anchor), so * a duplicate here means the same anchor was already used โ€” we still must * not bill. * * @param int $donor_id * @param int $target_id * @param string $anchor * @param int $rule_id The ID returned by add_rule(). * @return bool True if the rule pre-existed (duplicate, do not bill). */ private static function rule_existed_before($donor_id, $target_id, $anchor, $rule_id) { // add_rule() returns the existing rule's ID (not false) when a duplicate // is found. We cannot easily distinguish "just inserted" from "already // existed" by ID alone. Instead, we rely on the fact that we already // checked for donor->target duplicates in step 2. If add_rule() found a // duplicate by (donor + target + anchor), the rule_id is the existing one. // // Heuristic: query the rule by ID and check created_at. If created_at is // older than a few seconds, it pre-existed. global $wpdb; $table = AILM_Link_DB::get_rules_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $created = $wpdb->get_var( $wpdb->prepare( "SELECT created_at FROM " . esc_sql($table) . " WHERE id = %d", $rule_id ) ); if (!$created) { return false; } $ts = strtotime($created); // If the rule was created more than 5 seconds ago, it pre-existed. return (time() - $ts) > 5; } }