array( 'label' => 'AI Generated', 'admin_label' => 'AI Generated (fully AI-created)', 'badge' => 'AI Generated', ), 'compositeWithTrainedAlgorithmicMedia' => array( 'label' => 'AI Modified', 'admin_label' => 'AI Modified (real media edited by AI)', 'badge' => 'AI Modified', ), 'basicAI' => array( 'label' => 'AI', 'admin_label' => 'AI (basic mark — AI involved, unspecified)', 'badge' => 'AI', 'iptc' => 'trainedAlgorithmicMedia', // basic mark embeds the generic AI-generated value ), 'algorithmicMedia' => array( 'label' => 'Algorithmic', 'admin_label' => 'Algorithmic / procedural (non-AI, e.g. fractal or formula art)', 'badge' => 'Algorithmic', ), 'digitalCapture' => array( 'label' => 'Camera Photo', 'admin_label' => 'Camera photo (captured from real life, no AI)', 'badge' => 'Camera Photo', ), 'digitalCreation' => array( 'label' => 'Human Created', 'admin_label' => 'Human created (digital / 3D work, non-generative tools)', 'badge' => 'Human Created', ), 'composite' => array( 'label' => 'Composite', 'admin_label' => 'Composite (mix of non-AI elements, e.g. photos + renders)', 'badge' => 'Composite', ), ); /** * The one plugin instance. * * @var AICL_Plugin */ protected static $instance = null; /** * Resolved options, with defaults merged in. * * @var array */ protected $options = null; /** * Returns the shared singleton instance, booting it on first access. * * @since 1.0.0 * * @return AICL_Plugin The shared plugin instance. */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); self::$instance->boot(); } return self::$instance; } /** * Loads the text domain, registers meta, and wires up every concern's hooks. * * @since 1.0.0 * * @return void */ protected function boot() { add_action( 'init', array( $this, 'load_textdomain' ) ); $this->register_meta(); ( new AICL_Media() )->hooks(); AICL_Text::boot(); ( new AICL_Embed() )->hooks(); ( new AICL_Frontend() )->hooks(); ( new AICL_Settings() )->hooks(); ( new AICL_Directory() )->hooks(); ( new AICL_Dashboard() )->hooks(); ( new AICL_Report() )->hooks(); ( new AICL_Readiness() )->hooks(); ( new AICL_Notice() )->hooks(); ( new AICL_Welcome() )->hooks(); ( new AICL_Detector() )->hooks(); ( new AICL_Setup() )->hooks(); } /** * Merge a partial settings patch over the current options and persist it. * * AICL_Settings::sanitize() rebuilds its output from scratch and only writes the keys it enumerates, * so handing it a partial patch silently resets every setting the patch does not mention. Callers must * therefore always merge over the current options first. Making that the only supported way to write * settings means a new caller cannot get it wrong by omission. * * @since 2.0.0 * * @param array $patch Settings keys to change. * @return array The full sanitized settings as stored. */ public static function apply_settings( $patch ) { $current = self::instance()->options(); $merged = array_merge( $current, is_array( $patch ) ? $patch : array() ); $clean = AICL_Settings::sanitize( $merged ); update_option( self::OPTION, $clean ); self::instance()->options = null; return $clean; } /** * Loads the translations bundled in the plugin's own /languages folder. * * WordPress only auto-discovers translations under WP_LANG_DIR/plugins, so the .mo files that ship * with this plugin are never found on their own. Registering the path explicitly keeps the visitor * facing disclosures translated even before a wordpress.org language pack exists, which matters * because an Article 50 notice only informs someone if it is in a language they read. Language * packs still win when present: load_plugin_textdomain checks WP_LANG_DIR first. * * @since 1.3.0 * * @return void */ public function load_textdomain() { load_plugin_textdomain( 'aim-transparency', false, dirname( AICL_BASENAME ) . '/languages' ); } /** * Registers the source-type attachment meta and exposes it over REST so the block editor / API can read it. * * @since 1.0.0 * * @return void */ protected function register_meta() { register_meta( 'post', self::META_KEY, array( 'object_subtype' => 'attachment', 'type' => 'string', 'single' => true, 'show_in_rest' => true, 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { return current_user_can( 'edit_post', (int) $object_id ); }, 'sanitize_callback' => function ( $value ) { $token = sanitize_text_field( (string) $value ); return self::is_valid_type( $token ) ? $token : ''; }, ) ); } /* ---------------------------------------------------------------------- */ /* Vocabulary helpers */ /* ---------------------------------------------------------------------- */ /** * The source types that assert no AI was involved. * * The dashboard's own list keeps a matching set in `pages.tsx` for grouping rows it has already * fetched. This one is the server's copy, used to filter before paging, so the two must agree: * a token added to SOURCE_TYPES belongs in exactly one of AI or non-AI, in both places. * * @since 2.0.1 */ const NON_AI_TYPES = array( 'algorithmicMedia', 'digitalCapture', 'digitalCreation', 'composite' ); /** * The source types that do assert AI involvement. * * Derived rather than listed, so a new AI token is covered the moment it joins SOURCE_TYPES. * * @since 2.0.1 * * @return string[] The AI source-type tokens. */ public static function ai_types() { return array_values( array_diff( array_keys( self::SOURCE_TYPES ), self::NON_AI_TYPES ) ); } /** * Determines whether a token is a recognised source-type in the controlled vocabulary. * * @since 1.0.0 * * @param string $token The source-type token to validate. * @return bool True when the token is a known source type, false otherwise. */ public static function is_valid_type( $token ) { return is_string( $token ) && isset( self::SOURCE_TYPES[ $token ] ); } /** * Returns the full IPTC vocabulary URI for a token, honouring any 'iptc' override. * * @since 1.0.0 * * @param string $token The source-type token to resolve. * @return string The canonical IPTC URI, or an empty string when the token is invalid. */ public static function uri_for( $token ) { if ( ! self::is_valid_type( $token ) ) { return ''; } $iptc = isset( self::SOURCE_TYPES[ $token ]['iptc'] ) ? self::SOURCE_TYPES[ $token ]['iptc'] : $token; return self::CV_BASE . $iptc; } /** * Returns the translated display strings for every source type. * * SOURCE_TYPES is a class constant and cannot hold translation calls; this * mirror map carries the translatable label, admin label, and badge text. * * @since 1.2.1 * * @return array> Display strings keyed by token. */ public static function type_strings() { return array( 'trainedAlgorithmicMedia' => array( 'label' => __( 'AI Generated', 'aim-transparency' ), 'admin_label' => __( 'AI Generated (fully AI-created)', 'aim-transparency' ), 'badge' => __( 'AI Generated', 'aim-transparency' ), ), 'compositeWithTrainedAlgorithmicMedia' => array( 'label' => __( 'AI Modified', 'aim-transparency' ), 'admin_label' => __( 'AI Modified (real media edited by AI)', 'aim-transparency' ), 'badge' => __( 'AI Modified', 'aim-transparency' ), ), 'basicAI' => array( 'label' => __( 'AI', 'aim-transparency' ), 'admin_label' => __( 'AI (basic mark — AI involved, unspecified)', 'aim-transparency' ), 'badge' => __( 'AI', 'aim-transparency' ), ), 'algorithmicMedia' => array( 'label' => __( 'Algorithmic', 'aim-transparency' ), 'admin_label' => __( 'Algorithmic / procedural (non-AI, e.g. fractal or formula art)', 'aim-transparency' ), 'badge' => __( 'Algorithmic', 'aim-transparency' ), ), 'digitalCapture' => array( 'label' => __( 'Camera Photo', 'aim-transparency' ), 'admin_label' => __( 'Camera photo (captured from real life, no AI)', 'aim-transparency' ), 'badge' => __( 'Camera Photo', 'aim-transparency' ), ), 'digitalCreation' => array( 'label' => __( 'Human Created', 'aim-transparency' ), 'admin_label' => __( 'Human created (digital / 3D work, non-generative tools)', 'aim-transparency' ), 'badge' => __( 'Human Created', 'aim-transparency' ), ), 'composite' => array( 'label' => __( 'Composite', 'aim-transparency' ), 'admin_label' => __( 'Composite (mix of non-AI elements, e.g. photos + renders)', 'aim-transparency' ), 'badge' => __( 'Composite', 'aim-transparency' ), ), ); } /** * Returns the human-readable disclosure label for a token. * * @since 1.0.0 * * @param string $token The source-type token to resolve. * @return string The disclosure label, or an empty string when the token is invalid. */ public static function label_for( $token ) { if ( ! self::is_valid_type( $token ) ) { return ''; } $strings = self::type_strings(); return isset( $strings[ $token ]['label'] ) ? $strings[ $token ]['label'] : self::SOURCE_TYPES[ $token ]['label']; } /** * Returns the translated admin dropdown label for a token. * * @since 1.2.1 * * @param string $token The source-type token to resolve. * @return string The admin label, or an empty string when the token is invalid. */ public static function admin_label_for( $token ) { if ( ! self::is_valid_type( $token ) ) { return ''; } $strings = self::type_strings(); return isset( $strings[ $token ]['admin_label'] ) ? $strings[ $token ]['admin_label'] : self::SOURCE_TYPES[ $token ]['admin_label']; } /** * Returns the visible badge text for a token, preferring the deployer's custom override. * * @since 1.0.0 * * @param string $token The source-type token to resolve. * @return string The custom badge text when set, otherwise the per-type badge text, or an empty string when invalid. */ public static function badge_text_for( $token ) { $labels = self::opt( 'badge_labels' ); if ( is_array( $labels ) && isset( $labels[ $token ] ) && '' !== trim( (string) $labels[ $token ] ) ) { return $labels[ $token ]; } $custom = trim( (string) self::opt( 'badge_text' ) ); if ( '' !== $custom ) { return $custom; } if ( ! self::is_valid_type( $token ) ) { return ''; } $strings = self::type_strings(); return isset( $strings[ $token ]['badge'] ) ? $strings[ $token ]['badge'] : self::SOURCE_TYPES[ $token ]['badge']; } /** * Determines whether the visible badge should be shown for a source type. * * Defaults to showing every valid AI type; the deployer can hide a type (e.g. Algorithmic) * via the per-type display setting. Gates only the visible badge — not embedded metadata or JSON-LD. * * Settings saved before a type was added lack its key, so the type's shipped default is used * instead. That keeps provenance types badge-less across an upgrade rather than silently turning * badges on for them. * * @since 1.1.0 * * @param string $token The source-type token. * @return bool True when the badge should be displayed for this type. */ public static function should_badge( $token ) { $show = self::opt( 'badge_show' ); if ( is_array( $show ) && array_key_exists( $token, $show ) ) { return ! empty( $show[ $token ] ); } $defaults = self::defaults(); if ( isset( $defaults['badge_show'][ $token ] ) ) { return ! empty( $defaults['badge_show'][ $token ] ); } return self::is_valid_type( $token ); } /** * Returns the source-type token for an attachment only when its badge should be displayed. * * Used at the front-end badge-emission points so a hidden type produces no visible badge, * while embedding and JSON-LD still rely on get_type(). * * @since 1.1.0 * * @param int $attachment_id The attachment ID. * @return string The token when the badge is shown, or an empty string otherwise. */ public static function display_type( $attachment_id ) { $token = self::get_type( $attachment_id ); return ( '' !== $token && self::should_badge( $token ) ) ? $token : ''; } /** * Returns the qualifier word for the official EU label. * * The qualifier is 'Generated', 'Modified', or an empty string for the basic AI mark. * * @since 1.0.0 * * @param string $token The source-type token to resolve. * @return string The EU qualifier word, or an empty string when none applies. */ public static function eu_word_for( $token ) { $map = array( 'trainedAlgorithmicMedia' => 'Generated', 'compositeWithTrainedAlgorithmicMedia' => 'Modified', 'algorithmicMedia' => '', 'digitalCapture' => '', 'digitalCreation' => '', 'composite' => '', ); return isset( $map[ $token ] ) ? $map[ $token ] : ''; } /* ---------------------------------------------------------------------- */ /* Attachment flag */ /* ---------------------------------------------------------------------- */ /** * Returns the source-type token stored on an attachment, or an empty string when it is not flagged. * * @since 1.0.0 * * @param int $attachment_id The attachment post ID to read. * @return string The stored source-type token, or an empty string when not flagged. */ public static function get_type( $attachment_id ) { $id = (int) $attachment_id; $token = get_post_meta( $id, self::META_KEY, true ); if ( ! self::is_valid_type( $token ) ) { $origin = self::origin_attachment( $id ); $token = $origin ? get_post_meta( $origin, self::META_KEY, true ) : ''; } return self::is_valid_type( $token ) ? $token : ''; } /** * Resolves a translated attachment back to the one it was copied from. * * WPML and Polylang can give every language its own attachment post for the same * underlying file. The copy is a separate post ID with its own postmeta, so a source * type set on the original is absent on the translation, and the badge silently * disappears on the translated page — the one failure an Article 50 plugin cannot * have. Both plugins can name the source-language post, so ask them rather than * keeping a second copy of their data in step. * * Only reached when the attachment has no source type of its own, and both lookups * are no-ops on a site running neither plugin. * * Every API that might know is asked, and the first that names a *different* post wins. * Polylang implements WPML's `wpml_object_id` as a compatibility shim, and for an attachment * it hands back the ID it was given rather than the source-language one. Treating that as an * answer would stop the search before Polylang's own function is reached, so this collects * candidates instead of returning on the first hit. * * @since 2.0.0 * * @param int $attachment_id The attachment to resolve. * @return int The source-language attachment ID, or 0 when there is no other one. */ protected static function origin_attachment( $attachment_id ) { return self::origin_post( $attachment_id, 'attachment' ); } /** * Resolves a translated post back to the one it was translated from. * * The same problem the attachment lookup solves, for any post type. A copy rule only runs at the * moment a translation is created, so a post translated before it was marked, or on a site with * field sync switched off, keeps an empty record of its own and would disclose nothing. * * Candidates are collected and the first that DIFFERS is returned, rather than returning on the * first answer. Polylang implements WPML's wpml_object_id as a shim that hands back the same ID, * so returning early would stop the search before Polylang's own function is ever reached, and * the fallback would silently never fire on Polylang while working perfectly on WPML. * * @since 2.1.5 * * @param int $post_id The post to resolve. * @param string $type Optional element type for WPML; derived from the post when empty. * @return int The source post ID, or 0 when this post is not a translation. */ public static function origin_post( $post_id, $type = '' ) { $id = (int) $post_id; if ( ! $id ) { return 0; } if ( '' === $type ) { $type = (string) get_post_type( $id ); } if ( '' === $type ) { return 0; } $candidates = array(); if ( has_filter( 'wpml_object_id' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own documented API; consuming it, not defining it. $default = apply_filters( 'wpml_default_language', null ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own documented API; consuming it, not defining it. $candidates[] = (int) apply_filters( 'wpml_object_id', $id, $type, false, $default ); } if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_default_language' ) ) { $candidates[] = (int) pll_get_post( $id, pll_default_language() ); } foreach ( $candidates as $candidate ) { if ( $candidate && $candidate !== $id ) { return $candidate; } } return 0; } /** * Determines whether an attachment carries a valid source-type flag. * * @since 1.0.0 * * @param int $attachment_id The attachment post ID to check. * @return bool True when the attachment is flagged, false otherwise. */ public static function is_flagged( $attachment_id ) { return '' !== self::get_type( $attachment_id ); } /** * Primes the meta cache for a set of attachment IDs to avoid N+1 lookups in render loops. * * @since 1.0.0 * * @param int[] $ids The attachment IDs to warm in the meta cache. * @return void */ public static function prime( $ids ) { $ids = array_filter( array_map( 'intval', (array) $ids ) ); if ( $ids ) { update_meta_cache( 'post', $ids ); } } /** * Returns the cached count of flagged attachments so the front end can skip work when zero. * * @since 1.0.0 * * @return int The number of flagged attachments. */ public static function flagged_count() { $n = get_transient( self::COUNT_TRANSIENT ); if ( false === $n ) { $q = new WP_Query( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'posts_per_page' => 1, 'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off flagged count, cached. 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $n = (int) $q->found_posts; set_transient( self::COUNT_TRANSIENT, $n, 12 * HOUR_IN_SECONDS ); } return (int) $n; } /** * Invalidates the cached flagged count and URL map after a flag changes. * * @since 1.0.0 * * @return void */ public static function flush_flagged_count() { delete_transient( self::COUNT_TRANSIENT ); delete_transient( 'aicl_url_map' ); } /* ---------------------------------------------------------------------- */ /* Options */ /* ---------------------------------------------------------------------- */ /** * Returns the default plugin option values. * * `public_interest` is Art. 50(4), second subparagraph, and defaults to '' rather than 0 because * "we have not asked yet" and "no" are different states the readiness view has to tell apart. An * upgrade must never silently claim the site answered a question nobody put to it. * * The text disclosure carries deliberately fewer knobs than the image badge. That one floats over * somebody else's picture and has to hold its own against it; this sits inside the prose, so the * useful default is to inherit the theme rather than fight it. Hence three styles instead of a * colour picker per part. * * @since 1.0.0 * * @return array The default settings array keyed by option name. */ public static function defaults() { return array( 'badge_enabled' => 1, 'badge_style' => 'aim', // aim = built-in CSS badge | eu = official EU icons | custom = text pill 'badge_text' => '', // empty = use per-type label 'badge_position' => 'top-left', // top-left|top-right|bottom-left|bottom-right 'badge_bg' => '#111111', 'badge_color' => '#ffffff', 'badge_shape' => 'pill', // custom-badge shape preset (full list in AICL_Settings::sanitize) 'badge_dot' => 1, // the small dot the badge draws before its label 'badge_modal' => 0, // click-to-disclose disclosure modal 'badge_labels' => array(), // per-type custom label text (keyed by source type) 'badge_show' => array( // per-type visible-badge display 'trainedAlgorithmicMedia' => 1, 'compositeWithTrainedAlgorithmicMedia' => 1, 'basicAI' => 1, 'algorithmicMedia' => 0, 'digitalCapture' => 0, 'digitalCreation' => 0, 'composite' => 0, ), 'badge_radius' => 2, // border radius preset (0 none, 1 soft, 2 round) 'badge_border' => 0, // custom-badge border width (px) 'badge_border_color' => '#ffffff', // custom-badge border color 'badge_shadow' => 1, // custom-badge shadow (0 none, 1 soft, 2 strong) 'badge_modal_title' => '', // custom click-to-disclose modal heading (empty = per-type default) 'badge_modal_text' => '', // custom click-to-disclose modal body (empty = per-type default) 'badge_logo' => '', // custom logo image URL for the built-in (aim/custom) badge; empty = none 'embed_metadata' => 1, // write IPTC/XMP into the file 'jsonld_enabled' => 1, // emit JSON-LD ImageObject 'scan_all' => 0, // JS universal scan for non-standard themes 'auto_flag' => 1, // Pro: auto-detect AI provenance on upload (read by the Pro add-on) 'chatbot_notice' => 0, // Art. 50(1): show the AI-assistant disclosure notice 'chatbot_notice_text' => '', // empty = the translatable built-in default (AICL_Notice::text) 'chatbot_notice_position' => 'bottom-left', // corner for the auto floating notice 'chatbot_notice_selectors' => '', // extra CSS selectors (one per line) for unrecognised chat widgets 'public_interest' => '', // '' unanswered | '0' no | '1' publishes on matters of public interest 'text_disclosure' => 1, // render the visible text disclosure when one is owed 'text_disclosure_position' => 'above', // above|below|both 'text_disclosure_text' => '', // empty = the translatable built-in default 'text_disclosure_style' => 'subtle', // subtle = bordered box | plain = one line of text | custom = own colours 'text_disclosure_bg' => '#f4f4f5', // custom style only 'text_disclosure_color' => '#1d2327', // custom style only 'text_disclosure_mark' => 1, // show the small "AI" chip before the sentence 'text_disclosure_mark_text' => '', // empty = "AI" 'text_disclosure_mark_style' => 'outline', // outline = ruled chip | solid = filled chip 'text_disclosure_radius' => 1, // box corners: 0 square, 1 rounded, 2 pill 'text_disclosure_mark_radius' => 1, // mark corners: 0 square, 1 rounded, 2 circle 'text_disclosure_mark_bg' => '#1d2327', // mark colours, used only by the custom mark style 'text_disclosure_mark_color' => '#ffffff', ); } /** * Returns the resolved options, lazily merging saved values over the defaults. * * @since 1.0.0 * * @return array The resolved settings array keyed by option name. */ public function options() { if ( null === $this->options ) { $saved = get_option( self::OPTION, array() ); $this->options = wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() ); } return $this->options; } /** * Returns a single resolved option value, or a fallback when the key is absent. * * @since 1.0.0 * * @param string $key The option key to read. * @param mixed $fallback Optional. The value to return when the key is absent. Default null. * @return mixed The resolved option value, or the fallback when the key is absent. */ public static function opt( $key, $fallback = null ) { $opts = self::instance()->options(); if ( array_key_exists( $key, $opts ) ) { return $opts[ $key ]; } return $fallback; } /* ---------------------------------------------------------------------- */ /* Pro / licensing */ /* ---------------------------------------------------------------------- */ /** * Determines whether a valid AIM Transparency Pro license is active on this site. * * The decision belongs to the Pro add-on; this only asks it. That split is deliberate: this file * ships to wordpress.org, so anything decided here is published source, readable along with the * shape of whatever record it reads. Pro is not distributed publicly, so the rules live there. * * Entitlement still requires the add-on. A license left behind after Pro is removed must not keep * Pro features unlocked, and a build of Pro too old to answer is treated as a no. * * @since 1.1.0 * @since 2.0.3 Delegates to AIMPRO_License::entitled(); the rules no longer live in free source. * * @return bool True when Pro is licensed and unexpired, false otherwise. */ public static function is_pro() { if ( ! self::pro_installed() ) { return false; } if ( ! is_callable( array( 'AIMPRO_License', 'entitled' ) ) ) { return false; } return (bool) call_user_func( array( 'AIMPRO_License', 'entitled' ) ); } /** * Determines whether the AIM Transparency Pro add-on plugin is installed and loaded. * * @since 1.1.0 * * @return bool True when the Pro add-on is active, false otherwise. */ public static function pro_installed() { return defined( 'AIMPRO_VERSION' ); } /* ---------------------------------------------------------------------- */ /* Activation */ /* ---------------------------------------------------------------------- */ /** * Seeds the default options on plugin activation when none exist yet. * * @since 1.0.0 * * @return void */ public static function activate() { if ( false === get_option( self::OPTION ) ) { add_option( self::OPTION, self::defaults() ); } AICL_Welcome::flag_redirect(); } }