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__( '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 '
%1$s — %2$s%s
| %s | ', esc_html__( 'Crawler', 'ai-blackout' ) ); printf( '%s | ', esc_html__( 'What it does', 'ai-blackout' ) ); printf( '%s | ', esc_html__( 'Your decision', '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( '%1$s%2$s%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 '%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' ); } } }