, including the name of the person holding editorial * responsibility. That name is compliance evidence, not page content. */ foreach ( self::post_types() as $post_type ) { add_filter( "rest_prepare_{$post_type}", array( __CLASS__, 'filter_rest_meta' ), 10, 3 ); } /* * Stamp the review date wherever the review flag is set: the classic box, the block * editor's REST write, or a programmatic update. Hooking the meta itself is the only * place all three pass through. */ add_action( 'added_post_meta', array( __CLASS__, 'sync_review_date' ), 10, 4 ); add_action( 'updated_post_meta', array( __CLASS__, 'sync_review_date' ), 10, 4 ); } /** * Removes this feature's meta from REST responses for readers who cannot edit the post. * * The rule is that the API says exactly what the page says, and nothing more. * * The review flag, the named person and the review date are always removed. They are the * site's internal evidence for the exemption, they never render anywhere, and the name in * particular is personal data that an owner would not expect a compliance plugin to publish. * * The source type is kept only where the post actually discloses, since in that case the page * states it outright and the JSON-LD repeats it. Where the post is exempt, or the disclosure is * switched off, the page says nothing and neither should this. That is the one point where text * differs from images: a flagged image always badges, so `_aicl_source_type` is public on every * post, while an exempt article is one the law does not ask to speak. * * @since 2.1.0 * * @param WP_REST_Response $response The response being prepared. * @param WP_Post $post The post it describes. * @param WP_REST_Request $request The request being served. * @return WP_REST_Response The response, with the private fields stripped where appropriate. */ public static function filter_rest_meta( $response, $post, $request ) { unset( $request ); if ( ! isset( $response->data['meta'] ) || ! is_array( $response->data['meta'] ) ) { return $response; } if ( current_user_can( 'edit_post', $post->ID ) ) { return $response; } foreach ( array( self::META_REVIEWED, self::META_EDITOR, self::META_REVIEWED_ON ) as $key ) { unset( $response->data['meta'][ $key ] ); } if ( ! self::discloses_on_page( $post->ID ) ) { unset( $response->data['meta'][ self::META_TYPE ] ); } return $response; } /** * Keeps the review date in step with the review flag. * * Recorded on the first transition to reviewed and left alone afterwards, so editing a post * later does not silently restate when it was checked. Clearing the flag clears the date, * because a date with no review behind it is worse than no date. * * @since 2.1.0 * * @param int $meta_id The meta row ID. Unused. * @param int $post_id The post the meta belongs to. * @param string $meta_key The meta key that changed. * @param mixed $value The new value. * @return void */ public static function sync_review_date( $meta_id, $post_id, $meta_key, $value ) { unset( $meta_id ); if ( self::META_REVIEWED !== $meta_key ) { return; } if ( ! $value ) { delete_post_meta( $post_id, self::META_REVIEWED_ON ); return; } if ( '' === (string) get_post_meta( $post_id, self::META_REVIEWED_ON, true ) ) { update_post_meta( $post_id, self::META_REVIEWED_ON, gmdate( 'Y-m-d' ) ); } } /** * Makes sure the disclosure has its stylesheet even when the badge is switched off. * * The badge's own enqueue returns early on `badge_enabled`, so a site that discloses text * but shows no image badges would otherwise render this unstyled. Same handle, so nothing * loads twice when the badge is on. * * @since 2.1.0 * * @return void */ public static function front_assets() { if ( ! AICL_Plugin::opt( 'text_disclosure' ) || AICL_Plugin::opt( 'badge_enabled' ) ) { return; } if ( ! is_singular() || ! self::discloses_on_page( get_queried_object_id() ) ) { return; } $css_path = AICL_DIR . 'assets/css/badge.css'; $version = file_exists( $css_path ) ? filemtime( $css_path ) : AICL_VERSION; wp_enqueue_style( 'aicl-badge', AICL_URL . 'assets/css/badge.css', array(), $version ); } /** * Returns the source types that can describe text. * * Only the AI tokens apply. The other four describe how a file came to exist (a camera * captured it, a person modelled it, a formula drew it, several non-AI parts were * combined) and none of that means anything for a paragraph. Derived from the plugin's * own list rather than copied, so a token added there is covered here the same day. * * @since 2.1.0 * * @return string[] The source-type tokens valid for text. */ public static function types() { return AICL_Plugin::ai_types(); } /** * Returns true when the token is one this class accepts for text. * * @since 2.1.0 * * @param string $token The source-type token to test. * @return bool True when the token describes AI involvement in text. */ public static function is_valid_type( $token ) { return is_string( $token ) && in_array( $token, self::types(), true ); } /** * Returns the post types that carry a text-disclosure control. * * Posts and pages by default. Filterable because "published to inform the public" is a * description of intent, not of post type, and a site may well do its reporting in a * custom type. * * @since 2.1.0 * * @return string[] Post type names. */ public static function post_types() { /** * Filters the post types offered a text-disclosure control. * * @since 2.1.0 * * @param string[] $post_types Post type names. */ return (array) apply_filters( 'aicl_text_post_types', array( 'post', 'page' ) ); } /** * Registers the four meta keys against every eligible post type. * * `show_in_rest` is what lets the block editor read and write these through the entity * store, so the panel needs no endpoint of its own and the values save with the post * like any other field. * * @since 2.1.0 * * @return void */ public static function register_meta() { $auth = function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }; $keys = array( self::META_TYPE => array( 'string', array( __CLASS__, 'sanitize_type' ) ), self::META_REVIEWED => array( 'boolean', 'rest_sanitize_boolean' ), self::META_EDITOR => array( 'string', 'sanitize_text_field' ), self::META_REVIEWED_ON => array( 'string', array( __CLASS__, 'sanitize_date' ) ), ); foreach ( self::post_types() as $post_type ) { foreach ( $keys as $key => $spec ) { register_post_meta( $post_type, $key, array( 'type' => $spec[0], 'single' => true, 'show_in_rest' => true, 'sanitize_callback' => $spec[1], 'auth_callback' => $auth, ) ); } } } /** * Reduces any input to a token this class accepts, or to an empty string. * * @since 2.1.0 * * @param string $value The submitted source-type token. * @return string A valid text source type, or '' when the input is not one. */ public static function sanitize_type( $value ) { $value = sanitize_text_field( (string) $value ); return self::is_valid_type( $value ) ? $value : ''; } /** * Reduces any input to a Y-m-d date, or to an empty string. * * @since 2.1.0 * * @param string $value The submitted date. * @return string A Y-m-d date, or '' when the input is not one. */ public static function sanitize_date( $value ) { $value = sanitize_text_field( (string) $value ); return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ? $value : ''; } /** * Returns the recorded state of a post's text. * * @since 2.1.0 * * @param int $post_id The post to read. * @return array{type:string,reviewed:bool,editor:string,reviewed_on:string} The stored values. */ public static function record( $post_id ) { $post_id = (int) $post_id; $type = (string) get_post_meta( $post_id, self::META_TYPE, true ); return array( 'type' => self::is_valid_type( $type ) ? $type : '', 'reviewed' => (bool) get_post_meta( $post_id, self::META_REVIEWED, true ), 'editor' => trim( (string) get_post_meta( $post_id, self::META_EDITOR, true ) ), 'reviewed_on' => (string) get_post_meta( $post_id, self::META_REVIEWED_ON, true ), ); } /** * Returns true when the site has said it publishes on matters of public interest. * * Stored as '' until answered, so "not yet asked" stays distinguishable from "no". The * readiness view needs that difference; a compliance tool that cannot tell an unanswered * question from a negative answer is guessing. * * @since 2.1.0 * * @return bool True when the Article 50(4) text duty can apply to this site. */ public static function in_public_interest() { return '1' === (string) AICL_Plugin::opt( 'public_interest' ); } /** * Works out where a post stands under Article 50(4). * * The order matters and mirrors the Regulation. The duty attaches only to AI-generated or * AI-manipulated text published to inform the public on matters of public interest. It is * then lifted where the content has had human review or editorial control *and* a natural * or legal person holds editorial responsibility. Both limbs, not either: a post whose * author ticked "reviewed" and named nobody has met half a test, and half a test is not an * exemption, so it still discloses. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return string One of 'none', 'recorded', 'discloses', 'incomplete' or 'exempt'. */ public static function status( $post_id ) { $record = self::record( $post_id ); if ( '' === $record['type'] ) { return 'none'; } if ( ! self::in_public_interest() ) { return 'recorded'; } if ( ! $record['reviewed'] ) { return 'discloses'; } return '' === $record['editor'] ? 'incomplete' : 'exempt'; } /** * Returns true when Article 50(4) *obliges* a disclosure on this text. * * The legal question only. Whether a line actually appears on the page is * `discloses_on_page()`, which is a broader question, because an owner may disclose * without being made to. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return bool True when Article 50(4) requires a disclosure on this text. */ public static function requires_disclosure( $post_id ) { return in_array( self::status( $post_id ), array( 'discloses', 'incomplete' ), true ); } /** * Returns true when a disclosure line should be printed for this post. * * Deliberately wider than the legal duty. Marking a post as AI-written is an instruction, * not a request for an assessment, and a plugin named for transparency should not swallow * it because the Regulation happens not to compel it on this site. The law sets a floor. * * So: anything marked is disclosed while the setting is on, and the one case that stays * silent is the post the law itself excuses, where a person reviewed the text and someone * holds editorial responsibility for it. `public_interest` still decides whether any of * this is an *obligation*, which is what the report and the Overview speak to. * * @since 2.1.0 * * @param int $post_id The post to assess. * @return bool True when the visible line belongs on this post. */ public static function discloses_on_page( $post_id ) { if ( ! AICL_Plugin::opt( 'text_disclosure' ) ) { return false; } return in_array( self::status( $post_id ), array( 'discloses', 'incomplete', 'recorded' ), true ); } /** * Returns the sentence shown to the visitor. * * "Generated" and "manipulated" are the Regulation's own two verbs, and they are not * interchangeable: text written by a machine and text a machine rewrote are different * claims. The token already records which, so the sentence should say which. * * @since 2.1.0 * * @param int $post_id The post being disclosed. * @return string The disclosure sentence, already translated. */ public static function disclosure_text( $post_id ) { $custom = trim( (string) AICL_Plugin::opt( 'text_disclosure_text' ) ); if ( '' !== $custom ) { return $custom; } $record = self::record( $post_id ); if ( 'compositeWithTrainedAlgorithmicMedia' === $record['type'] ) { return __( 'This text was artificially manipulated using AI.', 'aim-transparency' ); } return __( 'This text was artificially generated using AI.', 'aim-transparency' ); } /** * Adds the disclosure to the post body on the front end. * * Singular main-query views only. An archive or a related-posts widget runs `the_content` * too, and a disclosure repeated down a listing page stops reading as a disclosure and * starts reading as furniture. Article 50(5) asks for it at first exposure, clearly and * distinguishably, which is the single post. * * @since 2.1.0 * * @param string $content The post content. * @return string The content, with the disclosure prepended, appended or both. */ public static function disclose( $content ) { if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } if ( ! AICL_Plugin::opt( 'text_disclosure' ) ) { return $content; } $post_id = get_the_ID(); if ( ! $post_id || ! self::discloses_on_page( $post_id ) ) { return $content; } /* * The shortcode runs while `the_content` is being filtered, and this filter is at 998 so * it has already run by the time we get here. If the owner placed the line themselves, * that is the placement they wanted and a second copy is noise. */ if ( ! empty( self::$placed[ $post_id ] ) ) { return $content; } $notice = self::markup( $post_id ); $position = (string) AICL_Plugin::opt( 'text_disclosure_position' ); if ( 'below' === $position ) { return $content . $notice; } if ( 'both' === $position ) { return $notice . $content . $notice; } return $notice . $content; } /** * Posts whose disclosure has already been printed by the shortcode this request. * * @since 2.1.0 * @var array */ protected static $placed = array(); /** * Renders the disclosure wherever the owner puts `[aicl_text_disclosure]`. * * For a theme or a layout builder that wants the line somewhere the automatic position * cannot reach, under the byline rather than above the body, say. Placing it by hand * suppresses the automatic copy for that post, so the two cannot both appear. * * It obeys the same rules as the automatic line and prints nothing when no disclosure is * due. A shortcode that rendered regardless would let a page state that its text was * AI-written when the record says otherwise, which is the one thing this plugin must not do. * * @since 2.1.0 * * @param array $atts Shortcode attributes. `id` targets a specific post. * @return string The disclosure markup, or an empty string. */ public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => 0 ), $atts, 'aicl_text_disclosure' ); $post_id = (int) $atts['id']; if ( ! $post_id ) { $post_id = (int) get_the_ID(); } if ( ! $post_id ) { return ''; } self::$placed[ $post_id ] = true; if ( ! self::discloses_on_page( $post_id ) ) { return ''; } return self::markup( $post_id ); } /** * Builds the disclosure markup for a post. * * Custom colours ride on the element as CSS custom properties rather than through a * stylesheet the plugin has to print in the head. The disclosure is already part of the * content, so keeping its one variable piece with it means no second enqueue, nothing to * invalidate, and a theme override that only has to beat one selector. * * @since 2.1.0 * * @param int $post_id The post being disclosed. * @return string The disclosure paragraph. */ public static function markup( $post_id ) { $style = (string) AICL_Plugin::opt( 'text_disclosure_style' ); $classes = 'aicl-text-disclosure aicl-text-disclosure--' . sanitize_html_class( $style ); /* Presets, not free numbers. Three corner choices a person can picture beat a pixel field they have to guess at, and they cannot produce a shape that looks broken. */ $scale = array( 0 => '0', 1 => '4px', 2 => '999px' ); $marks = array( 0 => '0', 1 => '3px', 2 => '999px' ); $radius = (int) AICL_Plugin::opt( 'text_disclosure_radius' ); $mark_radius = (int) AICL_Plugin::opt( 'text_disclosure_mark_radius' ); $vars = array( '--aicl-td-radius:' . ( isset( $scale[ $radius ] ) ? $scale[ $radius ] : '4px' ), '--aicl-td-mark-radius:' . ( isset( $marks[ $mark_radius ] ) ? $marks[ $mark_radius ] : '3px' ), ); if ( 'custom' === $style ) { $background = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_bg' ) ); $foreground = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_color' ) ); if ( $background && $foreground ) { $vars[] = '--aicl-td-bg:' . $background; $vars[] = '--aicl-td-fg:' . $foreground; } } $mark = ''; if ( AICL_Plugin::opt( 'text_disclosure_mark' ) ) { $label = trim( (string) AICL_Plugin::opt( 'text_disclosure_mark_text' ) ); if ( '' === $label ) { $label = __( 'AI', 'aim-transparency' ); } $mark_style = (string) AICL_Plugin::opt( 'text_disclosure_mark_style' ); $mark_classes = 'aicl-text-disclosure__mark aicl-text-disclosure__mark--' . sanitize_html_class( $mark_style ); /* * Own colours, but only ones a person can read. The shortcode already drops a pair * below 4.5:1 rather than rendering it, and the same floor belongs here: an unreadable * chip on a disclosure is worse than a plain one, because it still looks like the site * disclosed something. Falling back to the inherited outline is the safe failure. */ if ( 'custom' === $mark_style ) { $mark_bg = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_mark_bg' ) ); $mark_fg = sanitize_hex_color( (string) AICL_Plugin::opt( 'text_disclosure_mark_color' ) ); if ( $mark_bg && $mark_fg && AICL_Notice::contrast( $mark_bg, $mark_fg ) >= 4.5 ) { $vars[] = '--aicl-td-mark-bg:' . $mark_bg; $vars[] = '--aicl-td-mark-fg:' . $mark_fg; } else { $mark_classes = 'aicl-text-disclosure__mark aicl-text-disclosure__mark--outline'; } } if ( 2 === $mark_radius ) { $mark_classes .= ' aicl-text-disclosure__mark--round'; } $mark = sprintf( '', esc_attr( $mark_classes ), esc_html( $label ) ); } /* Built last, because the mark may have added its own colours to $vars above. */ $inline = ' style="' . esc_attr( implode( ';', $vars ) ) . '"'; return sprintf( '

%3$s%4$s

', esc_attr( $classes ), $inline, // Already escaped above; built only from sanitize_hex_color output. $mark, // Already escaped above. esc_html( self::disclosure_text( $post_id ) ) ); } /** * Returns the schema.org node describing this post's text, or an empty array. * * Same shape as the ImageObject nodes the front end already emits, because * `digitalSourceType` is defined for CreativeWork generally and was never image-only. * A machine reading the page should not have to learn a second convention. * * @since 2.1.0 * * @param int $post_id The post to describe. * @return array The JSON-LD node, or an empty array when there is nothing to declare. */ public static function jsonld_node( $post_id ) { if ( ! self::discloses_on_page( $post_id ) ) { return array(); } $record = self::record( $post_id ); return array( '@type' => 'Article', 'url' => get_permalink( $post_id ), 'headline' => get_the_title( $post_id ), 'creditText' => AICL_Plugin::label_for( $record['type'] ), 'additionalProperty' => array( '@type' => 'PropertyValue', 'propertyID' => 'IPTC:DigitalSourceType', 'value' => AICL_Plugin::uri_for( $record['type'] ), ), ); } /** * Registers the classic-editor meta box. * * This cannot ride along on the featured-image control the way the image flag does. * That one hangs off `admin_post_thumbnail_html`, so it only exists when a featured * image is set, and text provenance has to be recordable on a post with no image. * * Flagged as a back-compat box so the block editor hides it. The block panel in text-block.js is * the same control, and without the flag both render and a post shows two "AI content disclosure" * panels. Classic still gets this one, since nothing else draws it there. * * @since 2.1.0 * * @return void */ public static function add_meta_box() { foreach ( self::post_types() as $post_type ) { add_meta_box( 'aicl-text-disclosure', __( 'AI content disclosure', 'aim-transparency' ), array( __CLASS__, 'render_meta_box' ), $post_type, 'side', 'default', array( '__back_compat_meta_box' => true ) ); } } /** * Renders the classic-editor meta box. * * @since 2.1.0 * * @param WP_Post $post The post being edited. * @return void */ public static function render_meta_box( $post ) { $record = self::record( $post->ID ); $scope = self::in_public_interest(); wp_nonce_field( 'aicl_text_save', 'aicl_text_nonce' ); echo '

'; echo ''; if ( ! $scope ) { echo '

' . esc_html__( 'Article 50(4) does not require this, because the site has not said it publishes on matters of public interest. The disclosure still shows, because you asked for it.', 'aim-transparency' ) . '

'; return; } echo '

'; echo '

'; echo '

'; echo '

' . esc_html__( 'Required for the exemption. Review alone is half the test.', 'aim-transparency' ) . '

'; } /** * Saves the classic-editor meta box. * * The review date is stamped here rather than collected, because the useful date is when * the claim was made, not one the author can type. * * @since 2.1.0 * * @param int $post_id The post being saved. * @param WP_Post $post The post object. * @return void */ public static function save_meta_box( $post_id, $post ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! in_array( $post->post_type, self::post_types(), true ) ) { return; } if ( ! isset( $_POST['aicl_text_nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST['aicl_text_nonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'aicl_text_save' ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } /* * sanitize_text_field() first, then the allow-list. sanitize_type() already does both, * but PHPCS only credits sanitizers it recognises by name, so doing it here keeps * Plugin Check quiet without weakening anything. */ $type = isset( $_POST['aicl_text_type'] ) ? self::sanitize_type( sanitize_text_field( wp_unslash( $_POST['aicl_text_type'] ) ) ) : ''; if ( '' === $type ) { delete_post_meta( $post_id, self::META_TYPE ); delete_post_meta( $post_id, self::META_REVIEWED ); delete_post_meta( $post_id, self::META_EDITOR ); delete_post_meta( $post_id, self::META_REVIEWED_ON ); return; } update_post_meta( $post_id, self::META_TYPE, $type ); $reviewed = isset( $_POST['aicl_text_reviewed'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['aicl_text_reviewed'] ) ); $editor = isset( $_POST['aicl_text_editor'] ) ? sanitize_text_field( wp_unslash( $_POST['aicl_text_editor'] ) ) : ''; /* The review date follows from this write. sync_review_date() handles it for every path. */ update_post_meta( $post_id, self::META_REVIEWED, $reviewed ? 1 : 0 ); update_post_meta( $post_id, self::META_EDITOR, $reviewed ? $editor : '' ); } /** * Enqueues the block-editor panel and hands it the same vocabulary the classic box uses. * * @since 2.1.0 * * @return void */ public static function block_assets() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->post_type, self::post_types(), true ) ) { return; } wp_enqueue_script( 'aicl-text-block', AICL_URL . 'assets/js/text-block.js', array( 'wp-plugins', 'wp-element', 'wp-data', 'wp-components', 'wp-i18n', 'wp-editor' ), AICL_VERSION, true ); /* One line of plain help per option, in the words an author would use rather than the Regulation's. Passed through wp_localize_script rather than translated in the script, so these resolve from the .mo and not the JED. */ $help = array( 'trainedAlgorithmicMedia' => __( 'Written by AI', 'aim-transparency' ), 'compositeWithTrainedAlgorithmicMedia' => __( 'You wrote it, AI rewrote it', 'aim-transparency' ), 'basicAI' => __( 'AI involved, unspecified', 'aim-transparency' ), ); $options = array(); foreach ( self::types() as $token ) { $options[] = array( 'value' => $token, 'label' => AICL_Plugin::label_for( $token ), 'desc' => isset( $help[ $token ] ) ? $help[ $token ] : '', ); } wp_localize_script( 'aicl-text-block', 'AICL_TEXT', array( 'options' => $options, 'publicInterest' => self::in_public_interest() ? 1 : 0, 'metaType' => self::META_TYPE, 'metaReviewed' => self::META_REVIEWED, 'metaEditor' => self::META_EDITOR, 'noneDesc' => __( 'No AI in the text', 'aim-transparency' ), 'noneLabel' => __( 'Not flagged', 'aim-transparency' ), ) ); wp_set_script_translations( 'aicl-text-block', 'aim-transparency', AICL_DIR . 'languages' ); } }