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() { add_action( 'init', array( $this, 'load_textdomain' ) ); $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(); ( 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 */ /* ---------------------------------------------------------------------- */ /** * 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. */ /** * 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 ) ); } 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 ) { $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. * * @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 ) { $id = (int) $attachment_id; $candidates = array(); /* * Ask every API that might know, and take the first that names a *different* post. * 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, which is why this collects candidates instead of returning on the first hit. */ 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, 'attachment', 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. * * @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 / staging. Set in wp-config.php; // deliberately not a filter, so no plugin or snippet can flip it — and honoured // only outside production (since 1.2.3), so it cannot serve as a live-site bypass. if ( defined( 'AICL_PRO_OVERRIDE' ) && AICL_PRO_OVERRIDE && 'production' !== wp_get_environment_type() ) { 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() ); } AICL_Welcome::flag_redirect(); } }