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', ), ); /** @var AICL_Plugin */ protected static $instance = null; /** @var array Resolved options (defaults merged). */ 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() { $this->register_meta(); ( new AICL_Media() )->hooks(); ( 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(); } /** * 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 */ /* ---------------------------------------------------------------------- */ /** * 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. * * @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 ] ); } // Saved settings predating a newly added type lack its key; fall back to // that type's shipped default so provenance types stay badge-less on upgrade. $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 ) { $token = get_post_meta( (int) $attachment_id, self::META_KEY, true ); return self::is_valid_type( $token ) ? $token : ''; } /** * 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. * * @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_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 ); } /** * 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 Pro add-on validates against Dodo Payments and writes the result into LICENSE_OPTION; * this reader lives in the free plugin so any feature can gate on Pro without the add-on * needing to be loaded. There is no public filter — the only non-license override is the * AICL_PRO_OVERRIDE constant (wp-config.php), for development and complimentary licenses. * * @since 1.1.0 * * @return bool True when Pro is licensed and unexpired, false otherwise. */ public static function is_pro() { // Pro entitlement always requires the add-on to be active — a license option left // behind after the add-on is removed must never keep Pro-gated features unlocked. if ( ! self::pro_installed() ) { return false; } // Explicit code-level override for development / complimentary licenses. Set in // wp-config.php; deliberately not a filter, so no plugin or snippet can flip it. if ( defined( 'AICL_PRO_OVERRIDE' ) && AICL_PRO_OVERRIDE ) { return true; } $license = get_option( self::LICENSE_OPTION, array() ); $active = is_array( $license ) && ! empty( $license['status'] ) && 'active' === $license['status']; if ( $active && ! empty( $license['expires'] ) ) { $active = strtotime( $license['expires'] ) > time(); } return $active; } /** * 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() ); } } }