registry = $registry; $this->policy = $policy; $this->matcher = $matcher; $this->settings = $settings; } /** * Prints the whole table, as the body of the Advanced tab. * * @return void */ public function render(): void { printf( '

%s

', esc_html( sprintf( /* translators: %s: Number of crawlers. */ _n( 'All %s crawler, one at a time', 'All %s crawlers, one at a time', count( $this->registry->get_agents() ), 'ai-blackout' ), number_format_i18n( count( $this->registry->get_agents() ) ) ) ) ); printf( '

%s

', esc_html__( 'Each crawler can be refused, asked to stay away in robots.txt without being refused, or allowed. Setting one here overrides your answer on the Protection tab for that crawler alone -- and changing that answer later starts every crawler again from its defaults.', 'ai-blackout' ) ); printf( '

%s

', esc_html( sprintf( /* translators: %s: Ruleset version, a date such as 2026-08-16. */ __( 'Crawler list version %s. It ships with the plugin and updates when the plugin does. Nothing is fetched from the internet.', 'ai-blackout' ), $this->registry->get_version() ) ) ); $this->render_dropped_rules(); printf( '', esc_attr__( 'Collapse all', 'ai-blackout' ), esc_html__( 'Expand all', 'ai-blackout' ) ); foreach ( $this->registry->get_groups() as $group ) { $this->render_group( $group ); } } /** * Applies the table's part of a save. * * @param array $posted The `aiblackout` array from the submitted form. * @return string[] Messages for anything the model refused. */ public function save( array $posted ): array { $refusals = array(); $acks = isset( $posted['ack'] ) && is_array( $posted['ack'] ) ? $posted['ack'] : array(); foreach ( $this->registry->get_agents() as $agent ) { if ( ! empty( $acks[ $agent['ua'] ] ) ) { $this->settings->add_search_impact_ack( $agent['ua'] ); } } $groups = isset( $posted['groups'] ) && is_array( $posted['groups'] ) ? $posted['groups'] : array(); $bulk = array(); foreach ( $this->registry->get_groups() as $group ) { $id = isset( $group['id'] ) ? (string) $group['id'] : ''; if ( '' === $id || empty( $groups[ $id ] ) ) { continue; } // No cast: sanitize_key() rejects a non-scalar on its own. $choice = sanitize_key( $groups[ $id ] ); if ( ! Policy::is_valid( $choice ) ) { continue; } $result = $this->policy->set_group_policy( $id, $choice ); if ( is_wp_error( $result ) ) { $refusals[] = $result->get_error_message(); continue; } $this->clear_agent_overrides( $id ); $bulk[] = $id; } $choices = isset( $posted['agents'] ) && is_array( $posted['agents'] ) ? $posted['agents'] : array(); foreach ( $this->registry->get_agents() as $agent ) { if ( in_array( $agent['group'], $bulk, true ) || ! isset( $choices[ $agent['ua'] ] ) ) { continue; } // As above: let sanitize_key() reject a non-scalar rather than casting. $choice = sanitize_key( $choices[ $agent['ua'] ] ); if ( ! Policy::is_valid( $choice ) || $choice === $this->policy->for_agent( $agent ) ) { continue; } $result = $this->policy->set_agent_policy( $agent['ua'], $choice ); if ( is_wp_error( $result ) ) { $refusals[] = $result->get_error_message(); } } return $refusals; } /** * Drops the per-crawler overrides belonging to one company. * * @param string $group_id Group identifier. * @return void */ private function clear_agent_overrides( string $group_id ): void { $policies = $this->settings->get( 'policies', array() ); if ( ! isset( $policies['agents'] ) || ! is_array( $policies['agents'] ) ) { return; } foreach ( $this->registry->get_agents() as $agent ) { if ( $agent['group'] !== $group_id ) { continue; } foreach ( array_keys( $policies['agents'] ) as $ua ) { if ( 0 === strcasecmp( (string) $ua, $agent['ua'] ) ) { unset( $policies['agents'][ $ua ] ); } } } $this->settings->set( 'policies', $policies ); } /** * Warns about crawlers that were set to Block but cannot be enforced. * * @return void */ private function render_dropped_rules(): void { $rejected = $this->matcher->get_rejected_tokens(); if ( empty( $rejected ) ) { return; } echo '

'; esc_html_e( 'Some crawlers you blocked cannot be refused by your server:', 'ai-blackout' ); echo '

%s

', esc_html__( 'A rule built on a name that short would turn away ordinary visitors too. They are still named in robots.txt.', 'ai-blackout' ) ); } /** * Prints one company's crawlers. * * @param array $group Group array from the registry. * @return void */ private function render_group( array $group ): void { if ( empty( $group['agents'] ) || ! is_array( $group['agents'] ) ) { return; } // Registry::normalize_group() guarantees both keys. $label = '' !== $group['label'] ? $group['label'] : $group['id']; $agents = array(); foreach ( $group['agents'] as $agent ) { if ( empty( $agent['ua'] ) ) { continue; } $full = $this->registry->get_agent( (string) $agent['ua'] ); if ( null !== $full ) { $agents[] = $full; } } $summary = $this->group_summary( $agents ); // Open by default only when this company's crawlers disagree. printf( '
', $summary['mixed'] ? ' open' : '' ); echo ''; echo ''; printf( '

%s', esc_html( $label ) ); if ( ! empty( $group['docs'] ) ) { printf( ' %2$s %3$s', esc_url( (string) $group['docs'] ), esc_html__( 'Their documentation', 'ai-blackout' ), esc_html__( '(opens in a new tab)', 'ai-blackout' ) ); } echo '

'; echo '
'; echo ''; $affects_search = $this->search_affecting( $agents ); if ( ! empty( $affects_search ) ) { printf( '%s', esc_html( sprintf( /* translators: %s: Number of crawlers. */ _n( '%s affects search', '%s affect search', count( $affects_search ), 'ai-blackout' ), number_format_i18n( count( $affects_search ) ) ) ) ); } printf( '%s', esc_html( $summary['label'] ) ); echo ''; echo ''; echo '
'; echo '
'; $this->render_group_control( $label, isset( $group['id'] ) ? (string) $group['id'] : '', $agents ); echo ''; printf( '', esc_html__( 'Crawler', 'ai-blackout' ) ); printf( '', esc_html__( 'What it does', 'ai-blackout' ) ); printf( '', esc_html__( 'Your decision', 'ai-blackout' ) ); echo ''; foreach ( $agents as $agent ) { $this->render_agent_row( $agent ); } echo '
%s%s%s
'; } /** * Summarises a company's current policy for its collapsed header. * * @param array $agents Full agent arrays already resolved from the registry. * @return array{label: string, mixed: bool} Status text, and whether they disagree. */ private function group_summary( array $agents ): array { $counts = array( Policy::BLOCK => 0, Policy::SIGNAL_ONLY => 0, Policy::ALLOW => 0, ); foreach ( $agents as $agent ) { $current = $this->policy->for_agent( $agent ); if ( isset( $counts[ $current ] ) ) { ++$counts[ $current ]; } } $templates = array( /* translators: %s: Number of crawlers. */ Policy::BLOCK => _n( '%s blocked', '%s blocked', $counts[ Policy::BLOCK ], 'ai-blackout' ), /* translators: %s: Number of crawlers. */ Policy::SIGNAL_ONLY => _n( '%s asking', '%s asking', $counts[ Policy::SIGNAL_ONLY ], 'ai-blackout' ), /* translators: %s: Number of crawlers. */ Policy::ALLOW => _n( '%s allowed', '%s allowed', $counts[ Policy::ALLOW ], 'ai-blackout' ), ); $parts = array(); foreach ( $counts as $policy => $count ) { if ( $count > 0 ) { $parts[] = sprintf( $templates[ $policy ], number_format_i18n( $count ) ); } } return array( 'label' => implode( ' · ', $parts ), 'mixed' => count( $parts ) > 1, ); } /** * Prints the company-wide control. * * @param string $label Company name. * @param string $group_id Group identifier. * @param array $agents Full agent arrays belonging to this company. * @return void */ private function render_group_control( string $label, string $group_id, array $agents ): void { if ( '' === $group_id ) { return; } $id = 'aiblackout-group-' . sanitize_html_class( $group_id ); $options = array( '' => __( 'Leave each one as it is', 'ai-blackout' ), Policy::BLOCK => __( 'Block all of them', 'ai-blackout' ), Policy::SIGNAL_ONLY => __( 'Ask all of them', 'ai-blackout' ), Policy::ALLOW => __( 'Allow all of them', 'ai-blackout' ), ); echo '

'; printf( ' ', esc_attr( $id ), esc_html( sprintf( /* translators: %s: Company name, e.g. OpenAI. */ __( 'Everything %s runs:', 'ai-blackout' ), $label ) ) ); printf( ''; echo '

'; $unconfirmed = array(); foreach ( $this->search_affecting( $agents ) as $ua ) { if ( ! $this->settings->has_search_impact_ack( $ua ) ) { $unconfirmed[] = $ua; } } if ( empty( $unconfirmed ) ) { return; } printf( '

%s

', esc_html( sprintf( /* translators: 1: Company name, e.g. OpenAI. 2: Comma-separated crawler names. */ _n( '%1$s also runs a crawler that brings people to your site: %2$s. Blocking the whole company is refused until you confirm that one below.', '%1$s also runs crawlers that bring people to your site: %2$s. Blocking the whole company is refused until you confirm those below.', count( $unconfirmed ), 'ai-blackout' ), $label, implode( ', ', $unconfirmed ) ) ) ); } /** * Prints one crawler's row. * * @param array $agent Agent array from the registry. * @return void */ private function render_agent_row( array $agent ): void { $current = $this->policy->for_agent( $agent ); $field = 'aiblackout[agents][' . $agent['ua'] . ']'; $id = 'aiblackout-agent-' . sanitize_html_class( $agent['ua'] ); $impact = $this->search_impact( $agent ); $affects = 'none' !== $impact; // Search crawlers get their own row styling. printf( '', $affects ? ' class="aiblackout-agent-search"' : '' ); $tags = ''; if ( ! empty( $agent['legacy'] ) ) { $tags .= ' ' . esc_html__( 'retired', 'ai-blackout' ) . ''; } if ( $affects ) { $tags .= ' ' . esc_html( $this->search_impact_label( $impact ) ) . ''; } printf( '%1$s%2$s
%3$s', esc_html( (string) $agent['ua'] ), $tags, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled above from fixed markup and escaped strings. esc_html( $this->purpose_label( (string) $agent['purpose'] ) ) ); echo ''; printf( '

%s

', esc_html( (string) $agent['description'] ) ); if ( Registry::MATCH_ROBOTS === $agent['match'] ) { printf( '

%s

', esc_html__( 'This one has no crawler of its own. It is a name that only works as a request in robots.txt, which the company can honour or ignore.', 'ai-blackout' ) ); } if ( $affects ) { printf( '

%s

', esc_html__( 'Unlike the others here, this one can send visitors your way. Only Allow keeps your pages in those results: Ask only and Block both publish the same line in robots.txt, and this crawler obeys it.', 'ai-blackout' ) ); } echo ''; echo '
'; printf( '%s', esc_html( sprintf( /* translators: %s: Crawler user-agent token. */ __( 'What to do about %s', 'ai-blackout' ), $agent['ua'] ) ) ); $options = array( Policy::BLOCK => __( 'Block', 'ai-blackout' ), Policy::SIGNAL_ONLY => __( 'Ask only', 'ai-blackout' ), Policy::ALLOW => __( 'Allow', 'ai-blackout' ), ); foreach ( $options as $value => $option_label ) { // Marked on Allow only, and only for a crawler that affects search. $recommended = ( $affects && Policy::ALLOW === $value ) ? ' ' . esc_html__( 'Recommended', 'ai-blackout' ) . '' : ''; printf( '', esc_attr( $id . '-' . $value ), esc_attr( $field ), esc_attr( $value ), checked( $current, $value, false ), esc_html( $option_label ), $recommended // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed markup around an escaped string, built above. ); } echo '
'; $this->render_search_impact( $agent, $current ); echo ''; } /** * Prints the confirmation a search-affecting crawler needs before it can * be blocked. * * @param array $agent Agent array. * @param string $current Effective policy. * @return void */ private function render_search_impact( array $agent, string $current ): void { $impact = $this->search_impact( $agent ); if ( 'none' === $impact ) { return; } if ( $this->settings->has_search_impact_ack( $agent['ua'] ) ) { printf( '

%s

', esc_html( Policy::BLOCK === $current ? __( 'You confirmed the search impact of blocking this.', 'ai-blackout' ) : __( 'Search impact already confirmed. Blocking this applies straight away.', 'ai-blackout' ) ) ); return; } printf( '', esc_attr( (string) $agent['ua'] ), esc_html__( 'This one affects where my site appears in search. I understand, and I still want to block it.', 'ai-blackout' ) ); } /** * Returns a crawler's declared search impact. * * @param array $agent Agent array from the registry. * @return string One of none, ai-search, web-search. */ private function search_impact( array $agent ): string { return isset( $agent['search_impact'] ) ? (string) $agent['search_impact'] : 'none'; } /** * Returns the badge text for a non-zero search impact. * * @param string $impact Search impact value. * @return string Short label. */ private function search_impact_label( string $impact ): string { return 'web-search' === $impact ? __( 'Affects web search', 'ai-blackout' ) : __( 'Affects AI search', 'ai-blackout' ); } /** * Returns the tokens of the agents in a list that affect search. * * @param array $agents Full agent arrays from the registry. * @return string[] User-agent tokens, in the order given. */ private function search_affecting( array $agents ): array { $affecting = array(); foreach ( $agents as $agent ) { if ( isset( $agent['ua'] ) && 'none' !== $this->search_impact( $agent ) ) { $affecting[] = (string) $agent['ua']; } } return $affecting; } /** * Returns the plain-language label for a crawler's purpose. * * @param string $purpose Purpose value from the registry. * @return string Human-readable label. */ private function purpose_label( string $purpose ): string { switch ( $purpose ) { case 'training': return __( 'Collects content to train AI models', 'ai-blackout' ); case 'dataset': return __( 'Builds public datasets others train on', 'ai-blackout' ); case 'scraping-tool': return __( 'A scraping tool anyone can point at your site', 'ai-blackout' ); case 'search': return __( 'Powers an AI search or answer feature', 'ai-blackout' ); case 'user-fetch': return __( 'Fetches a page because a person asked for it', 'ai-blackout' ); default: return __( 'Purpose not stated', 'ai-blackout' ); } } }