*/ private const RANK_MATH_SAFE_TOKENS = [ 'index', 'follow', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'notranslate', ]; /** @var list */ private const DEFAULT_ALLOWED_TYPES = ['post', 'page']; /** @var list */ private const PREVIEW_STATUSES = ['publish', 'draft', 'private', 'pending', 'future', 'trash']; /** @return list */ public static function excluded_content_ids(): array { $settings = TheDotDevAgenticBridge_Storage::get(); $raw = $settings['excluded_content_ids'] ?? []; if (!is_array($raw)) { return []; } $ids = []; foreach ($raw as $id) { $int_id = (int) $id; if ($int_id > 0) { $ids[] = $int_id; } } return array_values(array_unique($ids)); } /** @param list $ids */ public static function save_excluded_content_ids(array $ids): void { $sanitized = []; foreach ($ids as $id) { $int_id = (int) $id; if ($int_id > 0) { $sanitized[] = $int_id; } } TheDotDevAgenticBridge_Storage::update(['excluded_content_ids' => array_values(array_unique($sanitized))]); TheDotDevAgenticBridge_Artifacts::mark_preview_stale(); } /** * Server-authoritative exclusion update for one preview page. * * Loads stored exclusions, removes only IDs on the server-validated preview page, * then adds checked IDs from that page. Off-page exclusions are preserved without * trusting client-supplied hidden fields. * * @param list $submitted_checked_ids */ public static function apply_exclusion_submission(int $preview_page, int $per_page, array $submitted_checked_ids): void { $per_page = max(1, $per_page); $page = self::clamp_preview_page($preview_page, $per_page); $server_visible = self::preview_page_visible_ids($page, $per_page); $server_visible_lookup = array_fill_keys($server_visible, true); $checked = []; foreach ($submitted_checked_ids as $id) { $int_id = (int) $id; if ($int_id > 0 && isset($server_visible_lookup[$int_id])) { $checked[] = $int_id; } } $still_excluded = []; foreach (self::excluded_content_ids() as $id) { if (!isset($server_visible_lookup[$id])) { $still_excluded[] = $id; } } self::save_excluded_content_ids(array_merge($still_excluded, $checked)); } public static function is_manageable_preview_post(int $post_id): bool { $post = get_post($post_id); if (!$post instanceof WP_Post) { return false; } if (!in_array($post->post_type, self::allowed_types(), true)) { return false; } return in_array($post->post_status, self::PREVIEW_STATUSES, true); } public static function is_site_indexable(): bool { $blog_public = (string) get_option('blog_public', '1'); if ($blog_public === '0') { return false; } if ((bool) get_option('thedotdev_agentic_bridge_site_noindex', false)) { return false; } /** @var bool $filtered */ $filtered = apply_filters('thedotdev_agentic_bridge_site_indexable', true); return (bool) $filtered; } public static function is_password_protected(WP_Post $post): bool { return (string) $post->post_password !== ''; } public static function is_internal_blocked(WP_Post $post): bool { return (bool) get_post_meta($post->ID, '_thedotdev_agentic_bridge_block_from_agents', true); } public static function is_explicitly_excluded(int $post_id): bool { return in_array($post_id, self::excluded_content_ids(), true); } public static function has_post_noindex(WP_Post $post): bool { if ((bool) get_post_meta($post->ID, '_thedotdev_agentic_bridge_noindex', true)) { return true; } $yoast = (string) get_post_meta($post->ID, '_yoast_wpseo_meta-robots-noindex', true); if ($yoast === '1') { return true; } $rank_math = get_post_meta($post->ID, 'rank_math_robots', true); if (self::rank_math_indicates_noindex($rank_math)) { return true; } /** @var bool $filtered */ $filtered = apply_filters('thedotdev_agentic_bridge_post_noindex', false, $post); return (bool) $filtered; } private static function rank_math_indicates_noindex(mixed $rank_math): bool { if ($rank_math === '' || $rank_math === null || $rank_math === false) { return false; } return !self::rank_math_allows_indexing($rank_math); } private static function rank_math_allows_indexing(mixed $rank_math): bool { $tokens = self::parse_rank_math_tokens($rank_math); if ($tokens === null) { return false; } if ($tokens === []) { return true; } foreach ($tokens as $token) { if ($token === 'noindex') { return false; } if (!self::is_known_rank_math_token($token)) { return false; } } return true; } /** @return list|null */ private static function parse_rank_math_tokens(mixed $value): ?array { if (is_array($value)) { $tokens = []; foreach ($value as $key => $item) { if (!is_int($key) || !is_string($item)) { return null; } $token = strtolower(trim($item)); if ($token === '') { return null; } $tokens[] = $token; } return $tokens; } if (is_object($value)) { return null; } if (!is_string($value)) { return null; } if ($value === '') { return []; } $unserialized = maybe_unserialize($value); if ($unserialized !== $value) { if (!is_array($unserialized)) { return null; } return self::parse_rank_math_tokens($unserialized); } $parts = preg_split('/[,|]/', $value) ?: []; $tokens = []; foreach ($parts as $part) { $token = strtolower(trim($part)); if ($token === '') { return null; } $tokens[] = $token; } return $tokens; } private static function is_known_rank_math_token(string $token): bool { if (in_array($token, self::RANK_MATH_SAFE_TOKENS, true)) { return true; } if ($token === 'noindex') { return true; } return (bool) preg_match('/^max-(snippet|image-preview|video-preview):[-a-z0-9]+$/', $token); } /** @return array{eligible: bool, reason: string} */ public static function evaluate_post(WP_Post $post): array { if (!in_array($post->post_type, self::allowed_types(), true)) { return ['eligible' => false, 'reason' => self::REASON_WRONG_TYPE]; } if ($post->post_status !== 'publish') { return ['eligible' => false, 'reason' => self::REASON_NOT_PUBLISHED]; } return self::evaluate_publish_constraints($post); } /** @return array{eligible: bool, reason: string} */ public static function evaluate_preview_post(WP_Post $post): array { if (!in_array($post->post_type, self::allowed_types(), true)) { return ['eligible' => false, 'reason' => self::REASON_WRONG_TYPE]; } $status_reason = match ($post->post_status) { 'publish' => null, 'draft' => self::REASON_DRAFT, 'private' => self::REASON_PRIVATE, 'pending' => self::REASON_PENDING, 'future' => self::REASON_FUTURE, 'trash' => self::REASON_TRASH, default => self::REASON_NOT_PUBLISHED, }; if ($status_reason !== null) { return ['eligible' => false, 'reason' => $status_reason]; } return self::evaluate_publish_constraints($post); } /** @return array{eligible: bool, reason: string} */ private static function evaluate_publish_constraints(WP_Post $post): array { if (!self::is_site_indexable()) { return ['eligible' => false, 'reason' => self::REASON_SITE_NOINDEX]; } if (self::is_password_protected($post)) { return ['eligible' => false, 'reason' => self::REASON_PASSWORD_PROTECTED]; } if (self::has_post_noindex($post)) { return ['eligible' => false, 'reason' => self::REASON_POST_NOINDEX]; } if (self::is_internal_blocked($post)) { return ['eligible' => false, 'reason' => self::REASON_INTERNAL_BLOCKED]; } if (self::is_explicitly_excluded((int) $post->ID)) { return ['eligible' => false, 'reason' => self::REASON_EXPLICITLY_EXCLUDED]; } /** @var bool $eligible */ $eligible = apply_filters('thedotdev_agentic_bridge_content_eligible', true, $post); if (!$eligible) { return ['eligible' => false, 'reason' => self::REASON_INTERNAL_BLOCKED]; } return ['eligible' => true, 'reason' => self::REASON_ELIGIBLE]; } public static function is_eligible_post(WP_Post $post): bool { return self::evaluate_post($post)['eligible']; } /** @return array{eligible: bool, reason: string} */ public static function evaluate_post_ignoring_exclusion(WP_Post $post): array { if (!in_array($post->post_type, self::allowed_types(), true)) { return ['eligible' => false, 'reason' => self::REASON_WRONG_TYPE]; } if ($post->post_status !== 'publish') { return ['eligible' => false, 'reason' => self::REASON_NOT_PUBLISHED]; } if (!self::is_site_indexable()) { return ['eligible' => false, 'reason' => self::REASON_SITE_NOINDEX]; } if (self::is_password_protected($post)) { return ['eligible' => false, 'reason' => self::REASON_PASSWORD_PROTECTED]; } if (self::has_post_noindex($post)) { return ['eligible' => false, 'reason' => self::REASON_POST_NOINDEX]; } if (self::is_internal_blocked($post)) { return ['eligible' => false, 'reason' => self::REASON_INTERNAL_BLOCKED]; } /** @var bool $eligible */ $eligible = apply_filters('thedotdev_agentic_bridge_content_eligible', true, $post); return $eligible ? ['eligible' => true, 'reason' => self::REASON_ELIGIBLE] : ['eligible' => false, 'reason' => self::REASON_INTERNAL_BLOCKED]; } /** * @param list $types * @param list|string $statuses * @return list */ public static function query_posts_batch(array $types, array|string $statuses, int $offset, int $limit): array { $posts = get_posts([ 'post_type' => $types, 'post_status' => $statuses, 'numberposts' => $limit, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', ]); return is_array($posts) ? $posts : []; } /** @param callable(WP_Post): (bool|void) $callback Return false to stop iteration. */ public static function each_outbound_candidate(callable $callback): void { $offset = 0; while (true) { $batch = self::query_posts_batch(self::allowed_types(), 'publish', $offset, self::BATCH_SIZE); if ($batch === []) { break; } foreach ($batch as $post) { if ($callback($post) === false) { return; } } if (count($batch) < self::BATCH_SIZE) { break; } $offset += self::BATCH_SIZE; } } /** * @return list */ public static function eligible_content_items(): array { $items = []; self::each_outbound_candidate(function (WP_Post $post) use (&$items): void { if (!self::is_eligible_post($post)) { return; } $items[] = self::content_item_from_post($post); }); return $items; } /** * Snapshot v1.0 content item for an already fail-closed eligible published post/page. * Callers must only pass posts that passed eligibility; wire flags are contract constants * for that gate (not re-derived in a way that could serialize ineligible truth). * * @return array{ * id: int, * type: 'post'|'page', * slug: string, * title: string, * url: string, * modifiedAt: string, * isIndexable: true, * hasNoindex: false, * isPasswordProtected: false, * status: 'publish' * } */ public static function content_item_from_post(WP_Post $post): array { return [ 'id' => (int) $post->ID, 'type' => $post->post_type === 'page' ? 'page' : 'post', 'slug' => self::public_content_slug($post), 'title' => get_the_title($post), 'url' => get_permalink($post), 'modifiedAt' => TheDotDevAgenticBridge_Readiness_Clock::format_mysql_gmt_as_utc_z( get_post_modified_time('Y-m-d H:i:s', true, $post) ), 'isIndexable' => true, 'hasNoindex' => false, 'isPasswordProtected' => false, 'status' => 'publish', ]; } /** Bounded public slug for snapshot wire format (no body/password/meta leakage). */ private static function public_content_slug(WP_Post $post): string { $raw = (string) $post->post_name; if ($raw === '') { $raw = (string) (int) $post->ID; } $slug = sanitize_title($raw); if ($slug === '') { $slug = (string) (int) $post->ID; } if (strlen($slug) > 200) { $slug = substr($slug, 0, 200); } return $slug; } /** @return list */ public static function preview_page_visible_ids(int $page, int $per_page): array { $page = self::clamp_preview_page($page, $per_page); $offset = ($page - 1) * $per_page; $ids = []; foreach (self::query_posts_batch(self::allowed_types(), self::PREVIEW_STATUSES, $offset, $per_page) as $post) { $ids[] = (int) $post->ID; } return $ids; } public static function preview_total_count(): int { $count = 0; $offset = 0; while (true) { $batch = self::query_posts_batch(self::allowed_types(), self::PREVIEW_STATUSES, $offset, self::BATCH_SIZE); if ($batch === []) { break; } $count += count($batch); if (count($batch) < self::BATCH_SIZE) { break; } $offset += self::BATCH_SIZE; } return $count; } public static function preview_page_count(int $per_page): int { $per_page = max(1, $per_page); $total = self::preview_total_count(); if ($total === 0) { return 1; } return (int) ceil($total / $per_page); } public static function clamp_preview_page(int $page, int $per_page): int { $page = max(1, $page); return min($page, self::preview_page_count($per_page)); } /** Iterate the complete inventory in bounded batches, without an implicit first-page ceiling. * @return \Generator> */ public static function each_preview_row(): \Generator { $offset = 0; do { $batch = self::query_posts_batch(self::allowed_types(), self::PREVIEW_STATUSES, $offset, self::BATCH_SIZE); foreach ($batch as $post) { $evaluation = self::evaluate_preview_post($post); yield ['id' => (int) $post->ID, 'type' => (string) $post->post_type, 'title' => get_the_title($post), 'postStatus' => (string) $post->post_status, 'eligible' => $evaluation['eligible'], 'reason' => $evaluation['reason']]; } $offset += self::BATCH_SIZE; } while (count($batch) === self::BATCH_SIZE); } /** @return list */ public static function preview_rows(int $page = 1, int $per_page = self::PREVIEW_PER_PAGE): array { $per_page = max(1, $per_page); $page = self::clamp_preview_page($page, $per_page); $offset = ($page - 1) * $per_page; $rows = []; foreach (self::query_posts_batch(self::allowed_types(), self::PREVIEW_STATUSES, $offset, $per_page) as $post) { $evaluation = self::evaluate_preview_post($post); $rows[] = [ 'id' => (int) $post->ID, 'type' => (string) $post->post_type, 'title' => get_the_title($post), 'postStatus' => (string) $post->post_status, 'eligible' => $evaluation['eligible'], 'reason' => $evaluation['reason'], ]; } return $rows; } public static function is_claim_outbound_eligible(array $claim): bool { if (($claim['state'] ?? '') === 'blocked') { return false; } if (($claim['state'] ?? '') !== 'published') { return false; } $post = get_post((int) ($claim['contentId'] ?? 0)); if (!$post instanceof WP_Post) { return false; } return self::is_eligible_post($post) && TheDotDevAgenticBridge_Content_Metadata::is_artifact_eligible($post); } /** @return list> */ public static function outbound_claims(): array { $settings = TheDotDevAgenticBridge_Storage::get(); $claims = is_array($settings['approved_claims'] ?? null) ? $settings['approved_claims'] : []; $output = []; foreach ($claims as $claim) { if (!is_array($claim) || !self::is_claim_outbound_eligible($claim)) { continue; } $output[] = [ 'id' => (string) ($claim['id'] ?? ''), 'contentId' => (int) ($claim['contentId'] ?? 0), 'claimType' => (string) ($claim['claimType'] ?? 'summary'), 'claimText' => (string) ($claim['claimText'] ?? ''), 'state' => 'published', ]; } return $output; } /** @return list */ public static function allowed_types(): array { $settings = TheDotDevAgenticBridge_Storage::get(); $content_types = is_array($settings['admin_ui']['contentTypes'] ?? null) ? $settings['admin_ui']['contentTypes'] : []; $allowed = []; foreach (self::DEFAULT_ALLOWED_TYPES as $type) { if (!empty($content_types[$type])) { $allowed[] = $type; } } return $allowed === [] ? self::DEFAULT_ALLOWED_TYPES : $allowed; } }