post_type, $allowed_post_types, true)) { return new WP_Error('post_type', sprintf('Post type "%s" is not allowed.', $donor_post->post_type)); } // 3. Blacklist $blacklisted_posts = get_option('ailm_blacklisted_posts', []); if (in_array($donor_id, $blacklisted_posts, true)) { return new WP_Error('blacklisted', 'Donor post is blacklisted.'); } // 4. Excluded terms (taxonomies) $excluded_terms = get_option('ailm_excluded_terms', []); if (!empty($excluded_terms)) { $post_taxonomies = get_post_taxonomies($donor_id); if (!empty($post_taxonomies)) { $post_terms = wp_get_post_terms($donor_id, $post_taxonomies, ['fields' => 'ids']); if (!is_wp_error($post_terms) && !empty($post_terms)) { $intersect = array_intersect($post_terms, $excluded_terms); if (!empty($intersect)) { return new WP_Error('excluded_term', 'Donor post belongs to an excluded term.'); } } } } // 5. Minimum content length $min_chars = (int) get_option('ailm_min_chars', 2000); if ($min_chars > 0) { $content_len = mb_strlen(wp_strip_all_tags($donor_post->post_content), 'UTF-8'); if ($content_len < $min_chars) { return new WP_Error('min_chars', sprintf('Content too short (%d chars, minimum %d).', $content_len, $min_chars)); } } // 6. Outbound link limit (active rules + manual links) $global_limit = (int) get_option('ailm_global_limit', 5); $count_manual = (int) get_option('ailm_count_manual_links', 0); $active_rules = []; if (class_exists('AILM_Link_Repository')) { $active_rules = AILM_Link_Repository::get_rules_for_donor($donor_id); } $active_rules_count = count($active_rules); $manual_count = 0; if ($count_manual) { $home_url = untrailingslashit(home_url()); if (preg_match_all('/]*href=[\'"]([^\'"\s]+)[\'"][^>]*>/i', $donor_post->post_content, $matches)) { foreach ($matches[1] as $href) { if (empty($href)) { continue; } if (strpos($href, $home_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) { $manual_count++; } } } } if (($active_rules_count + $manual_count) >= $global_limit) { return new WP_Error('outbound_limit', sprintf('Outbound link limit reached (%d/%d).', $active_rules_count + $manual_count, $global_limit)); } // 7. Silo check (strict) $silo_taxonomies = get_option('ailm_silo_taxonomies', []); if (!empty($silo_taxonomies)) { $is_silo_match = false; foreach ($silo_taxonomies as $taxonomy) { $donor_tax_terms = wp_get_post_terms($donor_id, $taxonomy, ['fields' => 'ids']); $target_tax_terms = wp_get_post_terms($target_id, $taxonomy, ['fields' => 'ids']); if ( !is_wp_error($donor_tax_terms) && !is_wp_error($target_tax_terms) && !empty($donor_tax_terms) && !empty($target_tax_terms) ) { $intersect = array_intersect($donor_tax_terms, $target_tax_terms); if (!empty($intersect)) { $is_silo_match = true; break; } } } if (!$is_silo_match) { return new WP_Error('silo', 'Donor and target do not share a silo taxonomy term.'); } } // 8. Inbound limit on TARGET $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) { return new WP_Error('inbound_limit', sprintf('Target inbound link limit reached (%d/%d).', $inbound_count, $target_limit)); } return true; } /** * Batch-validate multiple donors against a single target. * * @param int[] $donor_ids Array of donor post IDs. * @param int $target_id Target post ID. * @return array ['valid' => [...], 'invalid' => [...]] * Each invalid item is ['donor_id' => int, 'reason' => string]. */ public static function validate_bulk_donors($donor_ids, $target_id) { $valid = []; $invalid = []; foreach ((array) $donor_ids as $donor_id) { $result = self::validate_donor($donor_id, $target_id); if ($result === true) { $valid[] = (int) $donor_id; } else { $invalid[] = [ 'donor_id' => (int) $donor_id, 'reason' => $result->get_error_message(), ]; } } return [ 'valid' => $valid, 'invalid' => $invalid, ]; } /** * Validate a donor that is about to receive an applied link. * Used by handle_apply_smart_link to ensure the link still * complies with guards at the moment of application. * * @param int $donor_id Donor post ID. * @param int $target_id Target post ID. * @return true|WP_Error */ public static function validate_single_link($donor_id, $target_id) { // Re-use the full donor guard set return self::validate_donor($donor_id, $target_id); } /** * Get donor IDs that should be excluded for a given target * because they already link to it (Level-3 safeguard). * * @param int $target_id Target post ID. * @return array Associative array mapping donor_id => true. */ public static function get_excluded_donor_ids($target_id) { $excluded = []; if (!class_exists('AILM_Link_Repository')) { return $excluded; } $existing_links = AILM_Link_Repository::get_inbound_links($target_id, 'internal'); if (is_array($existing_links)) { foreach ($existing_links as $link) { if (isset($link->donor_id)) { $excluded[(int) $link->donor_id] = true; } } } $existing_rules = AILM_Link_Repository::get_rules_for_target($target_id); if (is_array($existing_rules)) { foreach ($existing_rules as $rule) { if (isset($rule->donor_id)) { $excluded[(int) $rule->donor_id] = true; } } } return $excluded; } /** * Validate a donor -> target pair using an explicit content string. * * In the Gutenberg editor the current post content is not yet saved to the * database, so validate_donor() (which reads get_post()->post_content) would * check stale data. This variant accepts a $content string and uses it for: * - minimum content length (min_chars) check * - manual link count check * * If $content is null, falls back to the saved post_content (legacy behavior). * * @param int $donor_id Donor post ID. * @param int $target_id Target post ID. * @param string|null $content Explicit content string, or null to use saved post. * @return true|WP_Error */ public static function validate_donor_with_content($donor_id, $target_id, $content = null) { $donor_id = (int) $donor_id; $target_id = (int) $target_id; // 1. Existence $donor_post = get_post($donor_id); if (!$donor_post) { return new WP_Error('donor_missing', 'Donor post does not exist.'); } // Use the provided content if given, otherwise fall back to saved content. $effective_content = ($content !== null) ? $content : $donor_post->post_content; // 2. Allowed post type $allowed_post_types = get_option('ailm_allowed_post_types', ['post', 'page']); if (!in_array($donor_post->post_type, $allowed_post_types, true)) { return new WP_Error('post_type', sprintf('Post type "%s" is not allowed.', $donor_post->post_type)); } // 3. Blacklist $blacklisted_posts = get_option('ailm_blacklisted_posts', []); if (in_array($donor_id, $blacklisted_posts, true)) { return new WP_Error('blacklisted', 'Donor post is blacklisted.'); } // 4. Excluded terms (taxonomies) $excluded_terms = get_option('ailm_excluded_terms', []); if (!empty($excluded_terms)) { $post_taxonomies = get_post_taxonomies($donor_id); if (!empty($post_taxonomies)) { $post_terms = wp_get_post_terms($donor_id, $post_taxonomies, ['fields' => 'ids']); if (!is_wp_error($post_terms) && !empty($post_terms)) { $intersect = array_intersect($post_terms, $excluded_terms); if (!empty($intersect)) { return new WP_Error('excluded_term', 'Donor post belongs to an excluded term.'); } } } } // 5. Minimum content length (using effective_content) $min_chars = (int) get_option('ailm_min_chars', 2000); if ($min_chars > 0) { $content_len = mb_strlen(wp_strip_all_tags($effective_content), 'UTF-8'); if ($content_len < $min_chars) { return new WP_Error('min_chars', sprintf('Content too short (%d chars, minimum %d).', $content_len, $min_chars)); } } // 6. Outbound link limit (active rules + manual links in effective_content) $global_limit = (int) get_option('ailm_global_limit', 5); $count_manual = (int) get_option('ailm_count_manual_links', 0); $active_rules = []; if (class_exists('AILM_Link_Repository')) { $active_rules = AILM_Link_Repository::get_rules_for_donor($donor_id); } $active_rules_count = count($active_rules); $manual_count = 0; if ($count_manual) { $home_url = untrailingslashit(home_url()); if (preg_match_all('/]*href=[\'"]([^\'"\s]+)[\'"][^>]*>/i', $effective_content, $matches)) { foreach ($matches[1] as $href) { if (empty($href)) { continue; } if (strpos($href, $home_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) { $manual_count++; } } } } if (($active_rules_count + $manual_count) >= $global_limit) { return new WP_Error('outbound_limit', sprintf('Outbound link limit reached (%d/%d).', $active_rules_count + $manual_count, $global_limit)); } // 7. Silo check (strict) $silo_taxonomies = get_option('ailm_silo_taxonomies', []); if (!empty($silo_taxonomies)) { $is_silo_match = false; foreach ($silo_taxonomies as $taxonomy) { $donor_tax_terms = wp_get_post_terms($donor_id, $taxonomy, ['fields' => 'ids']); $target_tax_terms = wp_get_post_terms($target_id, $taxonomy, ['fields' => 'ids']); if ( !is_wp_error($donor_tax_terms) && !is_wp_error($target_tax_terms) && !empty($donor_tax_terms) && !empty($target_tax_terms) ) { $intersect = array_intersect($donor_tax_terms, $target_tax_terms); if (!empty($intersect)) { $is_silo_match = true; break; } } } if (!$is_silo_match) { return new WP_Error('silo', 'Donor and target do not share a silo taxonomy term.'); } } // 8. Inbound limit on TARGET $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) { return new WP_Error('inbound_limit', sprintf('Target inbound link limit reached (%d/%d).', $inbound_count, $target_limit)); } return true; } }