settings = $settings; $this->tdmrep = $tdmrep; $this->signals = $signals; $this->meta = $meta; $this->terms = $terms; $this->history = $history; $this->badge = $badge; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'admin_post_aiblackout_save_declarations', array( $this, 'handle_save' ) ); add_action( 'admin_post_aiblackout_save_retention', array( $this, 'handle_retention' ) ); } /** * Returns the URL of the merged screen these sections live on. * * @param array $args Optional query arguments to add. * @return string Admin URL. */ private function get_screen_url( array $args = array() ): string { $url = MainScreen::url(); $url = empty( $args ) ? $url : add_query_arg( $args, $url ); return $url . '#declarations'; } /** * Saves the declaration settings and records what is now published. * * @return void */ public function handle_save(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE_SAVE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- check_admin_referer() ran above; every value is sanitized or validated against a fixed list below. $posted = isset( $_POST['aiblackout'] ) && is_array( $_POST['aiblackout'] ) ? wp_unslash( $_POST['aiblackout'] ) : array(); $declarations = $this->settings->get( 'declarations', array() ); $declarations = is_array( $declarations ) ? $declarations : array(); foreach ( array( 'tdm_reservation', 'content_signals', 'noai', 'noimageai' ) as $switch ) { $declarations[ $switch ] = ! empty( $posted[ $switch ] ); } $declarations['tdm_policy_url'] = isset( $posted['tdm_policy_url'] ) && is_string( $posted['tdm_policy_url'] ) ? esc_url_raw( trim( $posted['tdm_policy_url'] ) ) : ''; $refused = false; foreach ( array( 'signal_search', 'signal_ai_input', 'signal_ai_train' ) as $key ) { $value = isset( $posted[ $key ] ) ? sanitize_key( $posted[ $key ] ) : Settings::SIGNAL_UNSET; if ( ! in_array( $value, array( Settings::SIGNAL_YES, Settings::SIGNAL_NO, Settings::SIGNAL_UNSET ), true ) ) { $value = Settings::SIGNAL_UNSET; } if ( 'signal_search' === $key && Settings::SIGNAL_NO === $value && empty( $posted['ack_search_signal'] ) ) { $refused = true; $value = isset( $declarations[ $key ] ) ? (string) $declarations[ $key ] : Settings::SIGNAL_YES; } $declarations[ $key ] = $value; } $this->settings->set( 'declarations', $declarations ); $this->settings->set( 'badge', array( 'enabled' => ! empty( $posted['badge']['enabled'] ), ) ); $this->tdmrep->record(); $this->signals->record(); $this->meta->record(); wp_safe_redirect( $this->get_screen_url( $refused ? array( 'aiblackout_decl_refused' => '1' ) : array( 'aiblackout_decl_saved' => '1' ) ) ); exit; } /** * Saves the choice of what survives deleting the plugin. * * @return void */ public function handle_retention(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE_RETENTION ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above; the value is only ever read as present or absent. $this->settings->set( 'preserve_settings', isset( $_POST['aiblackout_preserve_settings'] ) ); wp_safe_redirect( $this->get_screen_url( array( 'aiblackout_retention_saved' => '1' ) ) ); exit; } /** * Renders every declaration section on the screen. * * @return void */ public function render_sections(): void { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $this->render_notices(); printf( '

%s

', esc_html__( 'Blocking a crawler stops it reading your site. A declaration states, in a form courts and companies both recognise, that you have not given permission for your work to be used to train AI. It costs nothing, cannot break your site, and is the part that still counts if someone takes your content anyway.', 'ai-blackout' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_SAVE ); $reserved = (bool) $this->settings->get_declaration( 'tdm_reservation', false ); $this->render_card( __( 'Reservation of rights', 'ai-blackout' ), $reserved ? __( 'Reserved', 'ai-blackout' ) : __( 'Not reserved', 'ai-blackout' ), array( $this, 'render_reservation' ) ); $this->render_card( __( 'What you allow, use by use', 'ai-blackout' ), $this->signals_status(), array( $this, 'render_signals' ) ); $this->render_card( __( 'Page-level notices', 'ai-blackout' ), $this->meta_status(), array( $this, 'render_meta' ) ); $this->render_card( __( 'Say so on your site', 'ai-blackout' ), $this->badge_status(), array( $this, 'render_badge' ) ); printf( '

', esc_html__( 'Save and publish', 'ai-blackout' ) ); echo '
'; $this->render_card( __( 'A clause for your terms page', 'ai-blackout' ), '', array( $this, 'render_terms' ) ); $this->render_card( __( 'What you have declared, and since when', 'ai-blackout' ), $this->history_status(), array( $this, 'render_history' ) ); $kept = (bool) $this->settings->get( 'preserve_settings', false ); $this->render_card( __( 'If you delete AI Blackout', 'ai-blackout' ), $kept ? __( 'Kept', 'ai-blackout' ) : __( 'Not kept', 'ai-blackout' ), array( $this, 'render_retention' ) ); } /** * Summarises the three Content Signals for the card header. * * @return string Compact status text. */ private function signals_status(): string { $counts = array( Settings::SIGNAL_YES => 0, Settings::SIGNAL_NO => 0, Settings::SIGNAL_UNSET => 0, ); foreach ( array( 'signal_search', 'signal_ai_input', 'signal_ai_train' ) as $key ) { $value = (string) $this->settings->get_declaration( $key, Settings::SIGNAL_UNSET ); if ( isset( $counts[ $value ] ) ) { ++$counts[ $value ]; } } $templates = array( /* translators: %s: Number of uses. */ Settings::SIGNAL_YES => _n( '%s allowed', '%s allowed', $counts[ Settings::SIGNAL_YES ], 'ai-blackout' ), /* translators: %s: Number of uses. */ Settings::SIGNAL_NO => _n( '%s not allowed', '%s not allowed', $counts[ Settings::SIGNAL_NO ], 'ai-blackout' ), /* translators: %s: Number of uses. */ Settings::SIGNAL_UNSET => _n( '%s unset', '%s unset', $counts[ Settings::SIGNAL_UNSET ], 'ai-blackout' ), ); $parts = array(); foreach ( $counts as $signal => $count ) { if ( $count > 0 ) { $parts[] = sprintf( $templates[ $signal ], number_format_i18n( $count ) ); } } return implode( ' · ', $parts ); } /** * Summarises the two page-level notices for the card header. * * @return string Compact status text. */ private function meta_status(): string { $on = 0; foreach ( array( 'noai', 'noimageai' ) as $key ) { if ( $this->settings->get_declaration( $key, false ) ) { ++$on; } } if ( 0 === $on ) { return __( 'Off', 'ai-blackout' ); } return sprintf( /* translators: %s: Number of notices switched on, out of 2. */ __( '%s of 2 on', 'ai-blackout' ), number_format_i18n( $on ) ); } /** * Summarises the footer badge for the card header. * * @return string Compact status text. */ private function badge_status(): string { $badge = $this->settings->get( 'badge', array() ); $badge = is_array( $badge ) ? $badge : array(); if ( empty( $badge['enabled'] ) ) { return __( 'Off', 'ai-blackout' ); } return $this->badge->is_earned() ? __( 'Visible', 'ai-blackout' ) : __( 'Not shown yet', 'ai-blackout' ); } /** * Summarises the declaration history for the card header. * * @return string Compact status text. */ private function history_status(): string { $since = $this->history->first_published( Tdmrep::TYPE ); if ( null === $since ) { return __( 'Nothing recorded yet', 'ai-blackout' ); } return sprintf( /* translators: %s: Date the reservation was first published. */ __( 'Since %s', 'ai-blackout' ), wp_date( get_option( 'date_format' ), $since ) ); } /** * Prints the choice of what survives deleting the plugin. * * @return void */ private function render_retention(): void { printf( '

%s

', esc_html__( 'Deleting the plugin always puts your files back and always removes the activity log. That part is not a choice: rules left in a file that nothing is managing any more are worse than no rules at all.', 'ai-blackout' ) ); printf( '

%s

', esc_html__( 'What you can keep is every setting on this page and every other page of AI Blackout -- which crawlers you block, how enforcement is set up, your declarations -- plus the record above, so that reinstalling picks up where you left off instead of starting from nothing.', 'ai-blackout' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_RETENTION ); printf( '

', checked( (bool) $this->settings->get( 'preserve_settings', false ), true, false ), esc_html__( 'Keep my settings and my declaration record if I delete AI Blackout', 'ai-blackout' ) ); printf( '

%s

', esc_html__( 'This keeps information, not protection. Once AI Blackout is deleted nothing is blocking any crawler, whichever way this box is ticked.', 'ai-blackout' ) ); printf( '

', esc_html__( 'Save this choice', 'ai-blackout' ) ); echo '
'; } /** * Prints the outcome notices. * * @return void */ private function render_notices(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Display-only flags; the save itself is nonce-checked in handle_save(). if ( isset( $_GET['aiblackout_decl_saved'] ) ) { printf( '

%s

', esc_html__( 'Saved. Your declarations are published and the change has been recorded below.', 'ai-blackout' ) ); } if ( isset( $_GET['aiblackout_retention_saved'] ) ) { printf( '

%s

', esc_html( $this->settings->get( 'preserve_settings', false ) ? __( 'Saved. Your settings and your declaration record will be kept if you delete AI Blackout.', 'ai-blackout' ) : __( 'Saved. Deleting AI Blackout will remove everything it stored, including your declaration record.', 'ai-blackout' ) ) ); } if ( isset( $_GET['aiblackout_decl_refused'] ) ) { printf( '

%1$s %2$s

', esc_html__( 'One change was not applied.', 'ai-blackout' ), esc_html__( 'Telling search engines not to index your site was left as it was, because the confirmation box next to it was not ticked. Everything else on this page was saved.', 'ai-blackout' ) ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** * Prints the reservation of rights section. * * @return void */ private function render_reservation(): void { printf( '

%s

', esc_html__( 'European copyright law lets anyone mine published work for text and data unless the rights holder has reserved that right in a machine-readable way. This publishes exactly that reservation, at a standard address AI companies check, so "we did not know" stops being available to them.', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Reserve my rights', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Link to your terms', 'ai-blackout' ) ); echo ''; echo ''; $preview = $this->tdmrep->preview(); if ( '' === $preview ) { printf( '

%s

%s

', esc_html__( 'Nothing is being published.', 'ai-blackout' ), esc_html__( 'With this switched off, your site makes no reservation of rights, and mining your content stays lawful by default in the EU.', 'ai-blackout' ) ); return; } printf( '
%s
%s
', esc_html( sprintf( /* translators: %s: The URL of the published tdmrep.json file. */ __( 'What is published at %s', 'ai-blackout' ), $this->tdmrep->get_url() ) ), esc_html( $preview ) ); } /** * Prints the Content Signals section. * * @return void */ private function render_signals(): void { printf( '

%s

', esc_html__( 'Blocking a crawler is all or nothing. This is finer: it separates being findable in search from being fed to an AI, and states your answer for each one in your robots.txt file, in the wording the Content Signals policy defines. Leaving one unanswered says nothing about it either way, which is not the same as saying no.', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Publish these signals', 'ai-blackout' ) ); echo ''; $this->render_signal_row( 'signal_search', __( 'Appearing in search results', 'ai-blackout' ), __( 'Indexing your pages so a search engine can return a link and a short excerpt to a person. This does not include AI-written summaries.', 'ai-blackout' ), true ); $this->render_signal_row( 'signal_ai_input', __( 'Being used to answer questions', 'ai-blackout' ), __( 'Reading your page at the moment someone asks an AI a question, and using it to write the answer. Your content does the work; the answer usually replaces the visit.', 'ai-blackout' ), false ); $this->render_signal_row( 'signal_ai_train', __( 'Being used as training data', 'ai-blackout' ), __( 'Using your work to train or fine-tune an AI model. This is the one most people mean when they say AI is stealing their content.', 'ai-blackout' ), false ); echo ''; printf( '

%s %s

', esc_html__( 'Right now:', 'ai-blackout' ), esc_html( $this->signals->describe() ) ); $block = $this->signals->build_block(); if ( '' === $block ) { return; } printf( '
%s
%s
', esc_html__( 'What this adds to your robots.txt', 'ai-blackout' ), esc_html( $block ) ); } /** * Prints one signal's row. * * @param string $key Declaration key. * @param string $label Row label. * @param string $description What the use means. * @param bool $search_impact Whether refusing this can cost search visibility. * @return void */ private function render_signal_row( string $key, string $label, string $description, bool $search_impact ): void { $current = (string) $this->settings->get_declaration( $key, Settings::SIGNAL_UNSET ); $id = 'aiblackout-' . str_replace( '_', '-', $key ); $options = array( Settings::SIGNAL_YES => __( 'Allowed', 'ai-blackout' ), Settings::SIGNAL_NO => __( 'Not allowed', 'ai-blackout' ), Settings::SIGNAL_UNSET => __( 'Say nothing about it', 'ai-blackout' ), ); echo ''; printf( '', esc_attr( $id ), esc_html( $label ) ); echo ''; printf( ''; printf( '

%s

', esc_html( $description ) ); if ( $search_impact && Settings::SIGNAL_NO !== $current ) { printf( '', esc_html__( 'Setting this to "Not allowed" asks search engines to stop indexing your site, which will cost you traffic. Tick this to confirm you mean it. Without it the change is refused.', 'ai-blackout' ) ); } echo ''; } /** * Prints the page-level directives section. * * @return void */ private function render_meta(): void { printf( '

%s

', esc_html__( 'Two older directives that some tools and stock libraries check, added to every page. They are not a standard and nobody is obliged to honour them. They cost nothing, and they add one more place where your refusal is on the record.', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Refuse AI use of my text', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Refuse AI use of my images', 'ai-blackout' ) ); echo ''; echo ''; $directives = $this->meta->get_directives(); if ( empty( $directives ) ) { return; } printf( '
%s
%s
', esc_html__( 'What this adds to every page', 'ai-blackout' ), esc_html( sprintf( '' . "\n" . 'X-Robots-Tag: %1$s', implode( ', ', $directives ) ) ) ); } /** * Prints the optional footer badge section. * * @return void */ private function render_badge(): void { $badge = $this->settings->get( 'badge', array() ); $badge = is_array( $badge ) ? $badge : array(); $earned = $this->badge->is_earned(); printf( '

%s

', esc_html__( 'An optional mark for your footer. It is off unless you switch it on, and it never appears unless you are actually declaring or blocking something. A badge on a site doing neither would be the kind of claim this plugin exists to argue against.', 'ai-blackout' ) ); echo ''; echo ''; printf( '', esc_html__( 'Show the badge', 'ai-blackout' ) ); echo ''; echo ''; if ( ! $earned ) { printf( '

%s

%s

', esc_html__( 'The badge would not appear yet.', 'ai-blackout' ), esc_html__( 'Nothing is declared and no crawler is refused, so there is nothing for it to be true about. Switch on the reservation of rights above, or block a crawler, and it will show.', 'ai-blackout' ) ); return; } echo '
'; printf( '

%s

', esc_html__( 'How it looks:', 'ai-blackout' ) ); echo wp_kses_post( $this->badge->build() ); echo '
'; } /** * Prints the terms clause, ready to copy. * * @return void */ private function render_terms(): void { printf( '

%s

', esc_html__( 'Everything above is machine-readable, which is what an AI company\'s crawler reads. This is the same position in words, for the page a person reads. It is written from your settings, so the two agree.', 'ai-blackout' ) ); printf( '', esc_textarea( $this->terms->build() ) ); printf( '

%s

', esc_html( $this->terms->get_disclaimer() ) ); } /** * Prints the record of what has been declared and from when. * * @return void */ private function render_history(): void { $entries = $this->history->all(); if ( empty( $entries ) ) { printf( '

%s

%s

', esc_html__( 'Nothing recorded yet.', 'ai-blackout' ), esc_html__( 'Saving this page records what you published and when, so you can always answer "since when?" about your own reservation.', 'ai-blackout' ) ); return; } $this->render_history_summary(); $this->render_history_proof(); printf( '
%s', esc_html( sprintf( /* translators: %s: Number of recorded changes. */ _n( 'Show the %s recorded change', 'Show all %s recorded changes', count( $entries ), 'ai-blackout' ), number_format_i18n( count( $entries ) ) ) ) ); echo '
'; printf( '

%s

', esc_html__( 'Newest first. Times are shown in your site\'s timezone; each entry also stores the UTC time it was recorded.', 'ai-blackout' ) ); echo ''; printf( '', esc_html__( 'When', 'ai-blackout' ) ); printf( '', esc_html__( 'Declaration', 'ai-blackout' ) ); printf( '', esc_html__( 'What it said', 'ai-blackout' ) ); echo ''; foreach ( array_reverse( $entries ) as $entry ) { echo ''; printf( '', esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), isset( $entry['recorded_at'] ) ? (int) $entry['recorded_at'] : 0 ) ) ); printf( '', esc_html( $this->type_label( isset( $entry['type'] ) ? (string) $entry['type'] : '' ) ) ); printf( '', esc_html( isset( $entry['summary'] ) ? (string) $entry['summary'] : '' ) ); echo ''; } echo '
%s%s%s
%s%s%s
'; } /** * Prints the two dates that are the whole point of the record. * * @return void */ private function render_history_summary(): void { $since = $this->history->first_published( Tdmrep::TYPE ); $changed = $this->history->last_changed( Tdmrep::TYPE ); if ( null === $since ) { return; } printf( '

%s

', esc_html( sprintf( /* translators: %s: Date the reservation was first published. */ __( 'You have been reserving your rights since %s.', 'ai-blackout' ), wp_date( get_option( 'date_format' ), $since ) ) ) ); if ( null !== $changed && $changed !== $since ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: Date the declaration last changed. */ __( 'Last changed %s.', 'ai-blackout' ), wp_date( get_option( 'date_format' ), $changed ) ) ) ); } } /** * Says plainly what this record is worth, and points at what is worth * more. * * @return void */ private function render_history_proof(): void { printf( '

%s

', esc_html__( 'This is your own record, kept on your own site. It is here so you can answer "since when?" — but because it lives in your database, it is not something you could hold up as proof against anyone else.', 'ai-blackout' ) ); printf( '

%s

', esc_html__( 'For a dated copy nobody can say you wrote yourself, ask the Internet Archive to save your robots.txt. That timestamps the published file independently of you, which is the copy worth having if it ever comes up — and worth repeating whenever you change something here.', 'ai-blackout' ) ); printf( '

%s %s

', esc_url( 'https://web.archive.org/save/' . home_url( '/robots.txt' ) ), esc_html__( 'Archive my robots.txt now', 'ai-blackout' ), esc_html__( '(opens in a new tab)', 'ai-blackout' ) ); } /** * Returns the plain-language name of a declaration type. * * @param string $type Declaration type. * @return string Human-readable label. */ private function type_label( string $type ): string { switch ( $type ) { case Tdmrep::TYPE: return __( 'Reservation of rights', 'ai-blackout' ); case ContentSignals::TYPE: return __( 'Content signals', 'ai-blackout' ); case MetaTags::TYPE: return __( 'Page-level notices', 'ai-blackout' ); default: return $type; } } }