$legacy_prefix . 'image_labels_settings',
'version' => $legacy_prefix . 'image_labels_version',
'featured_enabled' => '_' . $legacy_prefix . 'featured_enabled',
'featured_text' => '_' . $legacy_prefix . 'featured_text',
'average_color' => '_' . $legacy_prefix . 'avg_color',
);
}
/**
* Copy legacy options to the new uniquely prefixed option names.
*
* @return void
*/
private static function migrate_legacy_options() {
$legacy = self::legacy_storage_keys();
if ( false === get_option( self::OPTION_KEY, false ) ) {
$legacy_settings = get_option( $legacy['settings'], false );
if ( is_array( $legacy_settings ) ) {
add_option( self::OPTION_KEY, $legacy_settings, '', true );
}
}
if ( false === get_option( self::VERSION_OPTION, false ) ) {
$legacy_version = get_option( $legacy['version'], '' );
if ( '' !== $legacy_version ) {
add_option( self::VERSION_OPTION, (string) $legacy_version, '', true );
}
}
if ( false !== get_option( self::OPTION_KEY, false ) ) {
delete_option( $legacy['settings'] );
}
if ( false !== get_option( self::VERSION_OPTION, false ) ) {
delete_option( $legacy['version'] );
}
}
/**
* Constructor.
*/
private function __construct() {
add_action( 'init', array( $this, 'maybe_upgrade_settings' ), 1 );
add_action( 'init', array( $this, 'register_post_meta' ), 99 );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_filter( 'register_block_type_args', array( $this, 'register_image_block_attributes' ), 10, 2 );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
add_action( 'enqueue_block_assets', array( $this, 'enqueue_editor_content_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ), 99 );
add_action( 'wp_footer', array( $this, 'print_late_frontend_styles' ), 1 );
add_filter( 'render_block_core/image', array( $this, 'render_image_block_label' ), 10, 2 );
add_filter( 'render_block_core/video', array( $this, 'render_video_block_label' ), 10, 2 );
add_filter( 'wp_get_attachment_image', array( $this, 'render_featured_attachment_label' ), 20, 5 );
add_filter( 'post_thumbnail_html', array( $this, 'render_featured_image_label' ), 20, 5 );
add_action( 'wp_footer', array( $this, 'render_machine_readable_source_data' ), 99 );
add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
add_action( 'wp_enqueue_media', array( $this, 'enqueue_media_admin_assets' ), 20 );
add_action( 'update_option_' . self::OPTION_KEY, array( $this, 'handle_settings_update' ), 10, 3 );
add_filter( 'wp_update_attachment_metadata', array( $this, 'clear_attachment_color_cache' ), 10, 2 );
add_filter( 'attachment_fields_to_edit', array( $this, 'add_attachment_source_type_field' ), 10, 2 );
add_filter( 'attachment_fields_to_save', array( $this, 'save_attachment_source_type_field' ), 10, 2 );
add_filter( 'manage_media_columns', array( $this, 'add_media_source_status_column' ), 10, 2 );
add_action( 'manage_media_custom_column', array( $this, 'render_media_source_status_column' ), 10, 2 );
add_filter( 'bulk_actions-upload', array( $this, 'add_media_source_bulk_actions' ) );
add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_media_source_bulk_action' ), 10, 3 );
add_action( 'restrict_manage_posts', array( $this, 'render_media_source_status_filter' ), 10, 2 );
add_action( 'pre_get_posts', array( $this, 'filter_media_library_by_source_status' ) );
add_filter( 'posts_where', array( $this, 'filter_media_library_mime_types_where' ), 10, 2 );
add_action( 'admin_notices', array( $this, 'render_media_source_bulk_notice' ) );
add_filter( 'rocket_delay_js_exclusions', array( $this, 'exclude_frontend_script_from_delay' ) );
add_filter( 'plugin_action_links_' . plugin_basename( GDAIIDL_FILE ), array( $this, 'add_settings_link' ) );
add_filter( 'plugin_row_meta', array( $this, 'add_plugin_row_meta' ), 10, 2 );
}
/**
* Default settings.
*
* @return array
*/
public static function defaults() {
return array(
'enable_images' => true,
'enable_videos' => true,
'label_text' => 'AI-generated',
'label_text_modified' => 'AI-modified',
'machine_readable_enabled' => false,
'media_library_auto_label' => false,
'load_assets_only_when_needed' => true,
'digital_source_type' => 'generated',
'position' => 'bottom-right',
'preset' => 'subtle',
'background_color' => '#171717',
'background_opacity' => 78,
'text_color' => '#ffffff',
'border_color' => '#ffffff',
'border_width' => 1,
'border_radius' => 3,
'font_size' => 9,
'font_weight' => 600,
'padding_vertical' => 3,
'padding_horizontal' => 5,
'offset_vertical' => 7,
'offset_horizontal' => 7,
'text_transform' => 'none',
'minimum_image_width' => 180,
'minimum_text_width' => 500,
'small_image_mode' => 'icon',
'touch_compact_mode' => true,
'icon_style' => 'monogram',
'custom_icon_id' => 0,
'custom_selectors' => '',
'location_rules_enabled' => false,
'icon_only_selectors' => '',
'hidden_selectors' => '',
'icon_size_value' => 16,
'icon_size_unit' => 'px',
'icon_tooltip_enabled' => false,
'background_color_mode' => 'auto',
'font_family_mode' => 'inherit',
'font_family_custom' => '',
'video_separate_design' => false,
'video_label_text' => '',
'video_label_text_modified' => '',
'video_alignment' => 'right',
'video_background_color' => '#171717',
'video_background_opacity'=> 78,
'video_text_color' => '#ffffff',
'video_border_color' => '#ffffff',
'video_border_width' => 1,
'video_border_radius' => 3,
'video_font_size' => 9,
'video_font_weight' => 600,
'video_padding_vertical' => 3,
'video_padding_horizontal'=> 5,
'video_text_transform' => 'none',
);
}
/**
* Supported standardized digital source types.
*
* This precise provenance vocabulary is intentionally more granular than the
* publisher-facing Media Library statuses below. The editor can retain or emit
* exact generative-edit/enhancement provenance while the public classification
* UI stays simpler: AI-generated / AI-modified / No AI used.
*
* The labels follow the IPTC Digital Source Type vocabulary. The URLs are
* the corresponding Schema.org enumeration members used by the
* digitalSourceType property.
*
* @return array
*/
private function digital_source_types() {
return array(
'generated' => array(
'label' => __( 'Created using generative AI', 'ai-image-disclosure-labels' ),
'badge' => __( 'AI-generated', 'ai-image-disclosure-labels' ),
'uri' => 'https://schema.org/TrainedAlgorithmicMediaDigitalSource',
),
'edited' => array(
'label' => __( 'Edited using generative AI', 'ai-image-disclosure-labels' ),
'badge' => __( 'AI-edited', 'ai-image-disclosure-labels' ),
'uri' => 'https://schema.org/CompositeWithTrainedAlgorithmicMediaDigitalSource',
),
'enhanced' => array(
'label' => __( 'Enhanced using AI', 'ai-image-disclosure-labels' ),
'badge' => __( 'AI-enhanced', 'ai-image-disclosure-labels' ),
'uri' => 'https://schema.org/AlgorithmicallyEnhancedDigitalSource',
),
);
}
/**
* Build the ordered source-type option list used by the block editor.
*
* The list starts with the inherit choice (empty value) so a per-image
* override can fall back to the global default, followed by every
* registered digital source type. Keeping this in one place means new
* source types automatically appear in both editor panels.
*
* @return array List of { label, value } pairs.
*/
private function source_type_select_options() {
$options = array(
array(
'label' => __( 'Use global default', 'ai-image-disclosure-labels' ),
'value' => '',
),
);
foreach ( $this->digital_source_types() as $key => $data ) {
$options[] = array(
'label' => $data['label'],
'value' => $key,
);
}
return $options;
}
/**
* Media Library classification choices.
*
* The empty value means the attachment has not been classified. "No AI
* used" is an explicit publisher declaration, but it deliberately does not
* map to an AI digitalSourceType URI: this plugin cannot prove forensic
* originality or authenticity.
*
* @return array Ordered map of value => status data.
*/
private function media_source_statuses() {
return array(
'' => array(
'label' => __( 'Not classified', 'ai-image-disclosure-labels' ),
'badge' => __( 'Unclassified', 'ai-image-disclosure-labels' ),
),
'no-ai' => array(
'label' => __( 'No AI used', 'ai-image-disclosure-labels' ),
'badge' => __( 'No AI', 'ai-image-disclosure-labels' ),
),
'generated' => array(
'label' => __( 'AI-generated', 'ai-image-disclosure-labels' ),
'badge' => __( 'AI-generated', 'ai-image-disclosure-labels' ),
),
'modified' => array(
'label' => __( 'AI-modified', 'ai-image-disclosure-labels' ),
'badge' => __( 'AI-modified', 'ai-image-disclosure-labels' ),
),
);
}
/**
* Sanitize a Media Library classification.
*
* @param mixed $value Candidate status.
* @param string $fallback Fallback status.
* @param bool $allow_empty Whether an empty/unclassified value is valid.
* @return string
*/
private function sanitize_media_source_status( $value, $fallback = '', $allow_empty = true ) {
$value = is_string( $value ) ? sanitize_key( $value ) : '';
if ( '' === $value ) {
return $allow_empty ? '' : $fallback;
}
/* 2.3.x stored these as separate publisher-facing states. */
if ( in_array( $value, array( 'edited', 'enhanced' ), true ) ) {
$value = 'modified';
}
$statuses = $this->media_source_statuses();
return isset( $statuses[ $value ] ) ? $value : $fallback;
}
/**
* Read the complete Media Library classification stored on an attachment.
*
* @param int $attachment_id Attachment ID.
* @return string Empty string when unclassified or invalid.
*/
private function attachment_media_status_raw( $attachment_id ) {
$attachment_id = (int) $attachment_id;
if ( $attachment_id <= 0 ) {
return '';
}
$value = sanitize_key( (string) get_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE, true ) );
$allowed = array( 'no-ai', 'generated', 'modified', 'edited', 'enhanced' );
return in_array( $value, $allowed, true ) ? $value : '';
}
private function attachment_media_status( $attachment_id ) {
return $this->sanitize_media_source_status( $this->attachment_media_status_raw( $attachment_id ), '', true );
}
/**
* Public read-only access for the optional AI-analysis module.
*
* @param int $attachment_id Attachment ID.
* @return string Publisher-facing status.
*/
public function get_attachment_ai_status( $attachment_id ) {
return $this->attachment_media_status( $attachment_id );
}
/**
* Whether a MIME type is supported by publisher-facing media classification.
*
* AI-assisted analysis intentionally remains image-only; this helper is for
* manual/status disclosure features shared by images and videos.
*
* @param string $mime_type Attachment MIME type.
* @return bool
*/
private function is_supported_disclosure_mime_type( $mime_type ) {
$mime_type = (string) $mime_type;
return 0 === strpos( $mime_type, 'image/' ) || 0 === strpos( $mime_type, 'video/' );
}
/**
* Whether disclosure UI/output is enabled for a supported MIME type.
*
* Turning a media type off preserves stored attachment metadata; it only
* disables editor controls and public disclosure output for that type.
*
* @param string $mime_type Attachment MIME type.
* @return bool
*/
private function is_disclosure_mime_type_enabled( $mime_type ) {
$settings = $this->settings();
$mime_type = (string) $mime_type;
if ( 0 === strpos( $mime_type, 'image/' ) ) {
return ! empty( $settings['enable_images'] );
}
if ( 0 === strpos( $mime_type, 'video/' ) ) {
return ! empty( $settings['enable_videos'] );
}
return false;
}
/**
* Whether an attachment is an image or video supported by disclosure tools.
*
* @param int $attachment_id Attachment ID.
* @return bool
*/
private function is_supported_disclosure_attachment( $attachment_id ) {
return $this->is_supported_disclosure_mime_type( get_post_mime_type( (int) $attachment_id ) );
}
/**
* Set the publisher-facing attachment status from a trusted internal action.
*
* @param int $attachment_id Attachment ID.
* @param string $status One of generated, modified, no-ai or empty.
* @param bool $purge_cache Whether to purge page caches after a change.
* @return bool
*/
public function set_attachment_ai_status( $attachment_id, $status, $purge_cache = true ) {
$attachment_id = absint( $attachment_id );
$status = $this->sanitize_media_source_status( $status, '__invalid__', true );
if (
$attachment_id <= 0 ||
'__invalid__' === $status ||
! $this->is_supported_disclosure_attachment( $attachment_id )
) {
return false;
}
$previous = $this->attachment_media_status( $attachment_id );
if ( $previous === $status ) {
return true;
}
if ( '' === $status ) {
delete_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE );
} else {
update_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE, $status );
}
if ( $purge_cache ) {
$this->maybe_purge_page_cache();
}
return true;
}
/**
* Read the AI digital source type stored on an attachment.
*
* Explicit "No AI used" and unclassified attachments intentionally resolve
* to an empty source type and therefore produce no AI digitalSourceType.
*
* @param int $attachment_id Attachment ID.
* @return string Empty string when no AI source type is declared.
*/
private function attachment_source_type( $attachment_id ) {
/* Preserve precise legacy provenance where 2.3.x recorded it. */
$status = $this->attachment_media_status_raw( $attachment_id );
$types = $this->digital_source_types();
return isset( $types[ $status ] ) ? $status : '';
}
/**
* Resolve the configurable public label text for a publisher-facing status.
*
* Only positive AI classifications have visible default text. Explicit
* "No AI used" and unclassified/unknown states intentionally return an
* empty string and therefore never create an automatic visible label.
*
* @param string $status Publisher-facing status.
* @return string
*/
private function label_text_for_media_status( $status, $media = 'image' ) {
$status = $this->sanitize_media_source_status( $status, '', true );
$settings = $this->settings();
$is_video = 'video' === $media;
if ( 'generated' === $status ) {
if ( $is_video && ! empty( $settings['video_label_text'] ) ) {
return trim( (string) $settings['video_label_text'] );
}
return isset( $settings['label_text'] ) ? trim( (string) $settings['label_text'] ) : '';
}
if ( 'modified' === $status ) {
if ( $is_video && ! empty( $settings['video_label_text_modified'] ) ) {
return trim( (string) $settings['video_label_text_modified'] );
}
return isset( $settings['label_text_modified'] ) ? trim( (string) $settings['label_text_modified'] ) : '';
}
return '';
}
/**
* Map a precise technical source type to the simpler public status.
*
* @param string $source_type Digital source type.
* @return string generated, modified, or empty.
*/
private function public_status_for_source_type( $source_type ) {
$source_type = $this->sanitize_digital_source_type( $source_type, '', true );
if ( 'generated' === $source_type ) {
return 'generated';
}
if ( in_array( $source_type, array( 'edited', 'enhanced' ), true ) ) {
return 'modified';
}
return '';
}
/**
* Resolve the default visible label for one explicitly marked image use.
*
* Per-use source type wins. Otherwise a positive Media Library status is
* respected, followed by the global source-type default. A custom per-image
* text is resolved by the caller before this fallback is used.
*
* @param string $source_type Optional per-use source type.
* @param int $attachment_id Attachment ID when available.
* @return string
*/
private function default_label_text_for_usage( $source_type = '', $attachment_id = 0, $media = 'image' ) {
$source_type = $this->sanitize_digital_source_type( $source_type, '', true );
if ( '' !== $source_type ) {
return $this->label_text_for_media_status( $this->public_status_for_source_type( $source_type ), $media );
}
$attachment_id = (int) $attachment_id;
if ( $attachment_id > 0 ) {
$media_status = $this->attachment_media_status( $attachment_id );
if ( in_array( $media_status, array( 'generated', 'modified' ), true ) ) {
return $this->label_text_for_media_status( $media_status, $media );
}
}
$settings = $this->settings();
$global_source = isset( $settings['digital_source_type'] ) ? $settings['digital_source_type'] : 'generated';
$public_status = $this->public_status_for_source_type( $global_source );
return $this->label_text_for_media_status( $public_status, $media );
}
/**
* Whether the opt-in automatic Media Library labelling is active.
*
* @return bool
*/
private function media_auto_label_enabled() {
$settings = $this->settings();
return ! empty( $settings['media_library_auto_label'] );
}
/**
* Add a source-type selector to the attachment details and edit screens.
*
* @param array $form_fields Existing attachment form fields.
* @param WP_Post $post Attachment post object.
* @return array
*/
public function add_attachment_source_type_field( $form_fields, $post ) {
if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
return $form_fields;
}
if ( ! $this->is_supported_disclosure_attachment( $post->ID ) || ! $this->is_disclosure_mime_type_enabled( $post->post_mime_type ) ) {
return $form_fields;
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return $form_fields;
}
$current = $this->attachment_media_status( $post->ID );
/*
* The name must use the attachments[ID][key] array notation so the
* value is delivered to the attachment_fields_to_save handler as
* $attachment['gdaiidl_media_source_type']. The id is only used to
* associate the WordPress-rendered label with the control.
*/
$field_name = sprintf( 'attachments[%d][gdaiidl_media_source_type]', (int) $post->ID );
$field_id = sprintf( 'attachments-%d-gdaiidl_media_source_type', (int) $post->ID );
$statuses = $this->media_source_statuses();
$options = '';
foreach ( $statuses as $value => $data ) {
$options .= sprintf(
'',
esc_attr( $value ),
esc_attr( '' !== $value ? $value : 'unclassified' ),
esc_attr( $data['badge'] ),
selected( $current, $value, false ),
esc_html( $data['label'] )
);
}
$current_data = isset( $statuses[ $current ] ) ? $statuses[ $current ] : $statuses[''];
$current_key = '' !== $current ? $current : 'unclassified';
$html = sprintf(
'
',
esc_attr( $field_name ),
esc_attr( $field_id ),
$options,
esc_attr( $current_key ),
esc_html( $current_data['badge'] ),
esc_html__( 'Classify this image or video once for the Media Library, block inheritance and structured source data. “No AI used” is a publisher declaration, not an authenticity check.', 'ai-image-disclosure-labels' )
);
$form_fields['gdaiidl_media_source_type'] = array(
'label' => __( 'AI status', 'ai-image-disclosure-labels' ),
'input' => 'html',
'html' => $html,
);
return $form_fields;
}
/**
* Persist the attachment source-type selector.
*
* @param array $post Attachment post data (array form).
* @param array $attachment Submitted attachment fields.
* @return array
*/
public function save_attachment_source_type_field( $post, $attachment ) {
if ( ! is_array( $post ) || ! isset( $post['ID'] ) ) {
return $post;
}
if ( ! array_key_exists( 'gdaiidl_media_source_type', (array) $attachment ) ) {
return $post;
}
$attachment_id = (int) $post['ID'];
if ( ! current_user_can( 'edit_post', $attachment_id ) && ! current_user_can( 'upload_files' ) ) {
return $post;
}
if ( ! $this->is_supported_disclosure_attachment( $attachment_id ) ) {
return $post;
}
$previous = $this->attachment_media_status( $attachment_id );
$previous_raw = $this->attachment_media_status_raw( $attachment_id );
$raw_value = is_string( $attachment['gdaiidl_media_source_type'] )
? sanitize_key( wp_unslash( $attachment['gdaiidl_media_source_type'] ) )
: '';
if ( '' === $raw_value ) {
$value = '';
delete_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE );
} else {
$statuses = $this->media_source_statuses();
/* Unknown/tampered values must not erase a valid existing classification. */
if ( ! isset( $statuses[ $raw_value ] ) ) {
return $post;
}
$value = $raw_value;
/*
* 2.3.x knew whether a modification was generative or merely an
* algorithmic enhancement. The simplified 2.4 UI intentionally
* collapses both to “AI-modified”, but an ordinary attachment save
* must not throw that more precise legacy provenance away.
*/
if ( 'modified' === $value && in_array( $previous_raw, array( 'edited', 'enhanced' ), true ) ) {
/* Keep the precise stored value; the publisher-facing value is still modified. */
} else {
update_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE, $value );
}
}
/*
* A changed Media Library mark can alter machine-readable output and,
* when automatic labelling is enabled, the visible page. Clear caches
* so the change is reflected immediately. The value is unknown to page
* caches, so a domain-wide purge is the safe choice.
*/
if ( $previous !== $value ) {
$this->maybe_purge_page_cache();
}
return $post;
}
/**
* Add an AI status column to the Media Library list view.
*
* @param array $columns Existing columns.
* @param bool $detached Whether the list contains unattached media.
* @return array
*/
public function add_media_source_status_column( $columns, $detached = false ) {
unset( $detached );
$settings = $this->settings();
if ( empty( $settings['enable_images'] ) && empty( $settings['enable_videos'] ) ) {
return $columns;
}
$new_columns = array();
foreach ( (array) $columns as $key => $label ) {
$new_columns[ $key ] = $label;
if ( 'title' === $key ) {
$new_columns['gdaiidl_ai_status'] = __( 'AI status', 'ai-image-disclosure-labels' );
}
}
if ( ! isset( $new_columns['gdaiidl_ai_status'] ) ) {
$new_columns['gdaiidl_ai_status'] = __( 'AI status', 'ai-image-disclosure-labels' );
}
return $new_columns;
}
/**
* Render the Media Library AI status column.
*
* @param string $column_name Column name.
* @param int $post_id Attachment ID.
* @return void
*/
public function render_media_source_status_column( $column_name, $post_id ) {
if ( 'gdaiidl_ai_status' !== $column_name ) {
return;
}
if ( ! $this->is_supported_disclosure_attachment( $post_id ) ) {
echo '—' .
esc_html__( 'Not an image or video', 'ai-image-disclosure-labels' ) .
'';
return;
}
if ( ! $this->is_disclosure_mime_type_enabled( get_post_mime_type( $post_id ) ) ) {
echo '' . esc_html__( 'Disabled', 'ai-image-disclosure-labels' ) . '';
return;
}
$status = $this->attachment_media_status( $post_id );
$statuses = $this->media_source_statuses();
$data = isset( $statuses[ $status ] ) ? $statuses[ $status ] : $statuses[''];
$class = '' !== $status ? $status : 'unclassified';
printf(
'%3$s',
esc_attr( $class ),
esc_attr( $data['label'] ),
esc_html( $data['badge'] )
);
}
/**
* Add grouped Media Library bulk actions for publisher-declared AI status.
*
* @param array $actions Existing bulk actions.
* @return array
*/
public function add_media_source_bulk_actions( $actions ) {
$settings = $this->settings();
if ( empty( $settings['enable_images'] ) && empty( $settings['enable_videos'] ) ) {
return $actions;
}
$actions[ __( 'AI source status', 'ai-image-disclosure-labels' ) ] = array(
'gdaiidl_set_generated' => __( 'Mark as AI-generated', 'ai-image-disclosure-labels' ),
'gdaiidl_set_modified' => __( 'Mark as AI-modified', 'ai-image-disclosure-labels' ),
'gdaiidl_set_no_ai' => __( 'Mark as no AI used', 'ai-image-disclosure-labels' ),
'gdaiidl_clear_status' => __( 'Clear AI classification', 'ai-image-disclosure-labels' ),
);
return $actions;
}
/**
* Apply one of the custom Media Library bulk actions.
*
* @param string $redirect_url Redirect URL.
* @param string $action Selected action.
* @param array $post_ids Selected attachment IDs.
* @return string
*/
public function handle_media_source_bulk_action( $redirect_url, $action, $post_ids ) {
$action_map = array(
'gdaiidl_set_generated' => 'generated',
'gdaiidl_set_modified' => 'modified',
'gdaiidl_set_no_ai' => 'no-ai',
'gdaiidl_clear_status' => '',
);
if ( ! isset( $action_map[ $action ] ) ) {
return $redirect_url;
}
$new_status = $action_map[ $action ];
$statuses = $this->media_source_statuses();
if ( '' !== $new_status && ! isset( $statuses[ $new_status ] ) ) {
return $redirect_url;
}
$updated = 0;
$skipped = 0;
$changed = false;
foreach ( array_map( 'absint', (array) $post_ids ) as $attachment_id ) {
if (
$attachment_id <= 0 ||
! current_user_can( 'edit_post', $attachment_id ) ||
! $this->is_supported_disclosure_attachment( $attachment_id ) ||
! $this->is_disclosure_mime_type_enabled( get_post_mime_type( $attachment_id ) )
) {
++$skipped;
continue;
}
$previous = $this->attachment_media_status( $attachment_id );
if ( $previous === $new_status ) {
++$updated;
continue;
}
if ( '' === $new_status ) {
delete_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE );
} else {
update_post_meta( $attachment_id, self::META_MEDIA_SOURCE_TYPE, $new_status );
}
$changed = true;
++$updated;
}
if ( $changed ) {
$this->maybe_purge_page_cache();
}
$redirect_url = remove_query_arg(
array( 'gdaiidl_ai_updated', 'gdaiidl_ai_skipped' ),
$redirect_url
);
return add_query_arg(
array(
'gdaiidl_ai_updated' => $updated,
'gdaiidl_ai_skipped' => $skipped,
),
$redirect_url
);
}
/**
* Show a status filter in the Media Library list view.
*
* @param string $post_type Current post type.
* @param string $which Filter location.
* @return void
*/
public function render_media_source_status_filter( $post_type, $which = '' ) {
$settings = $this->settings();
if ( empty( $settings['enable_images'] ) && empty( $settings['enable_videos'] ) ) {
return;
}
if ( 'attachment' !== $post_type || ( '' !== $which && 'bar' !== $which ) ) {
return;
}
$current = '';
if ( isset( $_GET['gdaiidl_ai_status'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$current = sanitize_key( wp_unslash( $_GET['gdaiidl_ai_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
$statuses = $this->media_source_statuses();
?>
is_main_query() ||
'attachment' !== $query->get( 'post_type' ) ||
! isset( $_GET['gdaiidl_ai_status'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
) {
return;
}
$status = sanitize_key( wp_unslash( $_GET['gdaiidl_ai_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '' === $status ) {
return;
}
/* Restrict the custom AI-status filter to supported image/video media. */
$query->set( 'gdaiidl_disclosure_media_only', true );
$meta_query = $query->get( 'meta_query' );
$meta_query = is_array( $meta_query ) ? $meta_query : array();
if ( 'unclassified' === $status ) {
$meta_query[] = array(
'relation' => 'OR',
array(
'key' => self::META_MEDIA_SOURCE_TYPE,
'compare' => 'NOT EXISTS',
),
array(
'key' => self::META_MEDIA_SOURCE_TYPE,
'value' => '',
'compare' => '=',
),
array(
'key' => self::META_MEDIA_SOURCE_TYPE,
'value' => array( 'no-ai', 'generated', 'modified', 'edited', 'enhanced' ),
'compare' => 'NOT IN',
),
);
} else {
$statuses = $this->media_source_statuses();
if ( ! isset( $statuses[ $status ] ) || '' === $status ) {
return;
}
$meta_query[] = array(
'key' => self::META_MEDIA_SOURCE_TYPE,
'value' => 'modified' === $status ? array( 'modified', 'edited', 'enhanced' ) : $status,
'compare' => 'modified' === $status ? 'IN' : '=',
);
}
$query->set( 'meta_query', $meta_query );
}
/**
* Restrict an active AI-status list query to image and video attachments.
*
* This composes with WordPress' own media-type selector: selecting Images or
* Videos still narrows the query further, while the default "All media"
* view excludes unrelated audio/document attachments from AI-status results.
*
* @param string $where Existing SQL WHERE clause.
* @param WP_Query $query Current query.
* @return string
*/
public function filter_media_library_mime_types_where( $where, $query ) {
if (
! is_admin() ||
! ( $query instanceof WP_Query ) ||
! $query->is_main_query() ||
! $query->get( 'gdaiidl_disclosure_media_only' )
) {
return $where;
}
global $wpdb;
$settings = $this->settings();
$images_enabled = ! empty( $settings['enable_images'] );
$videos_enabled = ! empty( $settings['enable_videos'] );
if ( ! $images_enabled && ! $videos_enabled ) {
return $where . ' AND 1=0';
}
if ( $images_enabled && $videos_enabled ) {
return $where . $wpdb->prepare(
' AND ( %i.post_mime_type LIKE %s OR %i.post_mime_type LIKE %s )',
$wpdb->posts,
$wpdb->esc_like( 'image/' ) . '%',
$wpdb->posts,
$wpdb->esc_like( 'video/' ) . '%'
);
}
return $where . $wpdb->prepare(
' AND %i.post_mime_type LIKE %s',
$wpdb->posts,
$wpdb->esc_like( $images_enabled ? 'image/' : 'video/' ) . '%'
);
}
/**
* Show the result of an AI-status bulk action.
*
* @return void
*/
public function render_media_source_bulk_notice() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || 'upload' !== $screen->id || ! isset( $_GET['gdaiidl_ai_updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$updated = absint( wp_unslash( $_GET['gdaiidl_ai_updated'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$skipped = isset( $_GET['gdaiidl_ai_skipped'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
? absint( wp_unslash( $_GET['gdaiidl_ai_skipped'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
: 0;
?>
0 ) {
echo ' ';
printf(
/* translators: %d: number of selected items skipped. */
esc_html( _n( '%d item was skipped.', '%d items were skipped.', $skipped, 'ai-image-disclosure-labels' ) ),
(int) $skipped
);
}
?>
digital_source_types();
return isset( $types[ $value ] ) ? $value : $fallback;
}
/**
* Resolve the effective source type for one marked media use.
*
* @param mixed $override Optional per-use source type.
* @return string Empty when machine-readable output is disabled.
*/
private function resolved_digital_source_type( $override = '' ) {
$settings = $this->settings();
if ( empty( $settings['machine_readable_enabled'] ) ) {
return '';
}
$override = $this->sanitize_digital_source_type( $override, '', true );
if ( '' !== $override ) {
return $override;
}
return $this->sanitize_digital_source_type(
isset( $settings['digital_source_type'] ) ? $settings['digital_source_type'] : 'generated'
);
}
/**
* Privacy-friendly font stacks. All stacks resolve locally on the
* visitor's device; no font file is ever downloaded from a third
* party, which keeps the badge GDPR-clean and render-blocking free.
*
* @return array
*/
public static function font_stacks() {
return array(
'inherit' => '',
'system-sans' => "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif",
'system-serif'=> "Georgia,'Times New Roman',Times,serif",
'system-mono' => "ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace",
);
}
/**
* Preset values used by the admin interface.
*
* @return array
*/
private function presets() {
return array(
'subtle' => array(
'background_color' => '#171717',
'background_opacity' => 78,
'text_color' => '#ffffff',
'border_color' => '#ffffff',
'border_width' => 1,
'border_radius' => 3,
'font_size' => 9,
'font_weight' => 600,
'padding_vertical' => 3,
'padding_horizontal' => 5,
),
'light' => array(
'background_color' => '#ffffff',
'background_opacity' => 96,
'text_color' => '#111111',
'border_color' => '#5c5c5c',
'border_width' => 1,
'border_radius' => 3,
'font_size' => 9,
'font_weight' => 600,
'padding_vertical' => 3,
'padding_horizontal' => 5,
),
'pill' => array(
'background_color' => '#171717',
'background_opacity' => 82,
'text_color' => '#ffffff',
'border_color' => '#ffffff',
'border_width' => 1,
'border_radius' => 999,
'font_size' => 9,
'font_weight' => 700,
'padding_vertical' => 3,
'padding_horizontal' => 7,
),
);
}
/**
* Legacy preset values used only for a safe one-time migration.
*
* @return array
*/
private function legacy_presets() {
return array(
'subtle' => array(
'background_color' => '#171717',
'background_opacity' => 74,
'text_color' => '#ffffff',
'border_color' => '#ffffff',
'border_width' => 1,
'border_radius' => 4,
'font_size' => 11,
'font_weight' => 600,
'padding_vertical' => 4,
'padding_horizontal' => 7,
),
'light' => array(
'background_color' => '#ffffff',
'background_opacity' => 90,
'text_color' => '#171717',
'border_color' => '#4a4a4a',
'border_width' => 1,
'border_radius' => 4,
'font_size' => 11,
'font_weight' => 600,
'padding_vertical' => 4,
'padding_horizontal' => 7,
),
'pill' => array(
'background_color' => '#171717',
'background_opacity' => 80,
'text_color' => '#ffffff',
'border_color' => '#ffffff',
'border_width' => 1,
'border_radius' => 999,
'font_size' => 11,
'font_weight' => 700,
'padding_vertical' => 5,
'padding_horizontal' => 10,
),
);
}
/**
* Purge page caches when visible output may have changed.
*
* @return void
*/
/**
* Public cache-purge bridge for optional internal modules.
*
* @return void
*/
public function flush_disclosure_caches() {
$this->maybe_purge_page_cache();
}
private function maybe_purge_page_cache() {
/* WP Rocket (its Cloudflare add-on also purges the CDN / APO edge cache). */
if ( function_exists( 'rocket_clean_domain' ) ) {
rocket_clean_domain();
}
/* LiteSpeed Cache. */
if ( has_action( 'litespeed_purge_all' ) || class_exists( 'LiteSpeed\\Core' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Public LiteSpeed Cache purge hook.
do_action( 'litespeed_purge_all' );
}
/* W3 Total Cache. */
if ( function_exists( 'w3tc_flush_posts' ) ) {
w3tc_flush_posts();
} elseif ( function_exists( 'w3tc_pgcache_flush' ) ) {
w3tc_pgcache_flush();
}
/* WP Super Cache. */
if ( function_exists( 'wp_cache_clear_cache' ) ) {
wp_cache_clear_cache();
}
/* WP Fastest Cache. */
if ( function_exists( 'wpfc_clear_all_cache' ) ) {
wpfc_clear_all_cache();
}
/* SiteGround Optimizer. */
if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
sg_cachepress_purge_cache();
}
/* Cache Enabler. */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Public Cache Enabler purge hook.
do_action( 'cache_enabler_clear_complete_cache' );
/* Breeze (Cloudways). */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Public Breeze purge hook.
do_action( 'breeze_clear_all_cache' );
/* Nginx Helper. */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Public Nginx Helper purge hook.
do_action( 'rt_nginx_helper_purge_all' );
/* Hummingbird. */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Public Hummingbird purge hook.
do_action( 'wphb_clear_page_cache' );
/*
* Extension point for cache systems that are not covered above,
* for example a custom Cloudflare APO purge via the Cloudflare API.
*/
do_action( 'gdaiidl_purge_caches' );
}
/**
* Human-readable names of detected page cache systems.
*
* Used on the settings screen so site owners know that their cache is
* cleared automatically after saving.
*
* @return array
*/
private function detected_cache_systems() {
$detected = array();
if ( function_exists( 'rocket_clean_domain' ) ) {
$detected[] = 'WP Rocket';
}
if ( defined( 'LSCWP_V' ) ) {
$detected[] = 'LiteSpeed Cache';
}
if ( function_exists( 'w3tc_flush_posts' ) || function_exists( 'w3tc_pgcache_flush' ) ) {
$detected[] = 'W3 Total Cache';
}
if ( function_exists( 'wp_cache_clear_cache' ) ) {
$detected[] = 'WP Super Cache';
}
if ( function_exists( 'wpfc_clear_all_cache' ) ) {
$detected[] = 'WP Fastest Cache';
}
if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
$detected[] = 'SiteGround Optimizer';
}
if ( class_exists( 'Cache_Enabler' ) ) {
$detected[] = 'Cache Enabler';
}
if ( class_exists( 'Breeze_Admin' ) ) {
$detected[] = 'Breeze';
}
if ( has_action( 'rt_nginx_helper_purge_all' ) ) {
$detected[] = 'Nginx Helper';
}
if ( has_action( 'wphb_clear_page_cache' ) ) {
$detected[] = 'Hummingbird';
}
return array_values( array_unique( $detected ) );
}
/**
* Upgrade old untouched presets without changing customized designs.
*
* @return void
*/
public function maybe_upgrade_settings() {
self::migrate_legacy_options();
$installed_version = (string) get_option( self::VERSION_OPTION, '' );
if ( '' !== $installed_version && version_compare( $installed_version, GDAIIDL_VERSION, '>=' ) ) {
return;
}
$saved = get_option( self::OPTION_KEY, false );
if ( is_array( $saved ) ) {
$preset = isset( $saved['preset'] ) ? (string) $saved['preset'] : 'subtle';
$legacy_presets = $this->legacy_presets();
$new_presets = $this->presets();
$changed = false;
/*
* Version 1.0.4 could store preset=light while retaining the compact
* dark preset values when the already-selected Light card was clicked.
* Repair only that exact known state so custom designs remain untouched.
*/
if (
'light' === $preset &&
isset( $new_presets['subtle'], $new_presets['light'] ) &&
$this->design_matches_preset( $saved, $new_presets['subtle'] )
) {
$saved = array_merge( $saved, $new_presets['light'] );
$changed = true;
}
if (
! $changed &&
isset( $legacy_presets[ $preset ], $new_presets[ $preset ] ) &&
$this->design_matches_preset( $saved, $legacy_presets[ $preset ] )
) {
$saved = array_merge( $saved, $new_presets[ $preset ] );
$changed = true;
}
if ( $changed ) {
update_option( self::OPTION_KEY, $saved, true );
}
}
update_option( self::VERSION_OPTION, GDAIIDL_VERSION, true );
if ( function_exists( 'wp_set_options_autoload' ) ) {
wp_set_options_autoload( array( self::OPTION_KEY, self::VERSION_OPTION ), true );
}
self::$settings_cache = null;
}
/**
* Determine whether all design fields still match a known preset.
*
* @param array $settings Saved settings.
* @param array $preset Preset values.
* @return bool
*/
private function design_matches_preset( $settings, $preset ) {
foreach ( $preset as $key => $value ) {
if ( ! array_key_exists( $key, $settings ) || (string) $settings[ $key ] !== (string) $value ) {
return false;
}
}
return true;
}
/**
* Get saved settings with defaults.
*
* @return array
*/
private function settings() {
if ( is_array( self::$settings_cache ) ) {
return self::$settings_cache;
}
$saved = get_option( self::OPTION_KEY, array() );
$settings = wp_parse_args( is_array( $saved ) ? $saved : array(), self::defaults() );
$preset = isset( $settings['preset'] ) ? (string) $settings['preset'] : 'subtle';
$presets = $this->presets();
if ( isset( $presets[ $preset ] ) ) {
$settings = array_merge( $settings, $presets[ $preset ] );
}
self::$settings_cache = $settings;
return self::$settings_cache;
}
/**
* Return supported post types.
*
* @return array
*/
private function post_types() {
$post_types = apply_filters( 'gdaiidl_post_types', array( 'post', 'page' ) );
$post_types = is_array( $post_types ) ? array_map( 'sanitize_key', $post_types ) : array( 'post', 'page' );
$post_types = array_values( array_unique( array_filter( $post_types, 'post_type_exists' ) ) );
return $post_types ? $post_types : array( 'post', 'page' );
}
/**
* Convert a newline-separated selector setting to a compact array.
*
* @param string $value Saved selector list.
* @return array
*/
private function selector_lines( $value ) {
if ( ! is_string( $value ) || '' === trim( $value ) ) {
return array();
}
$selectors = array_values( array_unique( array_filter( array_map( 'trim', explode( "\n", $value ) ) ) ) );
return array_slice( $selectors, 0, 30 );
}
/**
* Return selectors used by the JavaScript featured-image fallback.
*
* @return array
*/
private function featured_selectors() {
$settings = $this->settings();
$user_selectors = array();
if ( isset( $settings['custom_selectors'] ) && '' !== $settings['custom_selectors'] ) {
$user_selectors = array_filter( array_map( 'trim', explode( "\n", (string) $settings['custom_selectors'] ) ) );
}
$selectors = array(
'figure.cs-entry__post-media img.wp-post-image',
'figure.post-media img.wp-post-image',
'.cs-entry__post-media img.wp-post-image',
'.post-thumbnail img.wp-post-image',
'.entry-thumbnail img.wp-post-image',
'.featured-image img.wp-post-image',
'img.wp-post-image',
);
$selectors = apply_filters( 'gdaiidl_featured_selectors', array_merge( $user_selectors, $selectors ) );
return is_array( $selectors ) ? array_values( array_filter( array_map( 'sanitize_text_field', $selectors ) ) ) : array();
}
/**
* Read metadata and lazily migrate a value from an earlier GitHub release.
*
* @param int $post_id Post or attachment ID.
* @param string $new_key Current metadata key.
* @param string $legacy_id Key name in legacy_storage_keys().
* @return mixed
*/
private function get_compatible_post_meta( $post_id, $new_key, $legacy_id ) {
$value = get_post_meta( $post_id, $new_key, true );
if ( '' !== $value ) {
return $value;
}
$legacy = self::legacy_storage_keys();
$legacy_key = isset( $legacy[ $legacy_id ] ) ? $legacy[ $legacy_id ] : '';
if ( '' === $legacy_key ) {
return $value;
}
$legacy_value = get_post_meta( $post_id, $legacy_key, true );
if ( '' === $legacy_value ) {
return $value;
}
update_post_meta( $post_id, $new_key, $legacy_value );
delete_post_meta( $post_id, $legacy_key );
return $legacy_value;
}
/**
* Delete current and legacy metadata for one logical field.
*
* @param int $post_id Post or attachment ID.
* @param string $new_key Current metadata key.
* @param string $legacy_id Key name in legacy_storage_keys().
* @return void
*/
private function delete_compatible_post_meta( $post_id, $new_key, $legacy_id ) {
delete_post_meta( $post_id, $new_key );
$legacy = self::legacy_storage_keys();
if ( isset( $legacy[ $legacy_id ] ) ) {
delete_post_meta( $post_id, $legacy[ $legacy_id ] );
}
}
/**
* Register featured-image metadata for posts and pages.
*
* @return void
*/
public function register_post_meta() {
foreach ( $this->post_types() as $post_type ) {
/*
* These keys are deliberately not exposed through the standard post
* REST schema. The editor panel uses the plugin's dedicated endpoint
* instead, preventing the editor from overwriting a freshly saved value
* with a stale default during the normal post-save request.
*/
register_post_meta(
$post_type,
self::META_FEATURED_ENABLED,
array(
'type' => 'boolean',
'single' => true,
'show_in_rest' => false,
'sanitize_callback' => 'rest_sanitize_boolean',
'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', (int) $post_id );
},
)
);
register_post_meta(
$post_type,
self::META_FEATURED_TEXT,
array(
'type' => 'string',
'single' => true,
'show_in_rest' => false,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', (int) $post_id );
},
)
);
register_post_meta(
$post_type,
self::META_FEATURED_SOURCE_TYPE,
array(
'type' => 'string',
'single' => true,
'show_in_rest' => false,
'sanitize_callback' => 'sanitize_key',
'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', (int) $post_id );
},
)
);
}
}
/**
* Register a dedicated REST endpoint for the featured-image label.
*
* @return void
*/
public function register_rest_routes() {
register_rest_route(
'gdaiidl/v1',
'/post/(?P\\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'rest_get_featured_label' ),
'permission_callback' => array( $this, 'rest_featured_label_permissions_check' ),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'rest_update_featured_label' ),
'permission_callback' => array( $this, 'rest_featured_label_permissions_check' ),
'args' => array(
'enabled' => array(
'type' => 'boolean',
'required' => true,
),
'text' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'source_type' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
),
),
),
'schema' => array( $this, 'rest_featured_label_schema' ),
)
);
register_rest_route(
'gdaiidl/v1',
'/attachment/(?P\\d+)/ai-status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'rest_get_attachment_ai_status' ),
'permission_callback' => array( $this, 'rest_attachment_ai_status_permissions_check' ),
)
);
}
/**
* Check access to the attachment AI-status endpoint used by the editor.
*
* @param WP_REST_Request $request Request object.
* @return true|WP_Error
*/
public function rest_attachment_ai_status_permissions_check( $request ) {
$attachment_id = absint( $request['id'] );
$post = get_post( $attachment_id );
if ( ! $post || 'attachment' !== $post->post_type || ! $this->is_supported_disclosure_mime_type( $post->post_mime_type ) ) {
return new WP_Error(
'gdaiidl_invalid_attachment',
__( 'The image or video attachment could not be found.', 'ai-image-disclosure-labels' ),
array( 'status' => 404 )
);
}
if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
return new WP_Error(
'gdaiidl_forbidden_attachment',
__( 'You are not allowed to edit this image or video.', 'ai-image-disclosure-labels' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Return Media Library AI classification for one image or video attachment.
*
* This is editor-only inheritance data. It does not create a per-block or
* per-post override, so later Media Library changes can continue to flow
* through to usages that still inherit from the attachment.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function rest_get_attachment_ai_status( $request ) {
return rest_ensure_response( $this->attachment_ai_status_rest_data( absint( $request['id'] ) ) );
}
/**
* Build editor inheritance data for an attachment.
*
* @param int $attachment_id Attachment ID.
* @return array
*/
private function attachment_ai_status_rest_data( $attachment_id ) {
$raw_status = $this->attachment_media_status_raw( $attachment_id );
$status = $this->attachment_media_status( $attachment_id );
$source_type = $this->attachment_source_type( $attachment_id );
$statuses = $this->media_source_statuses();
$types = $this->digital_source_types();
return array(
'attachment_id' => (int) $attachment_id,
'raw_status' => $raw_status,
'status' => $status,
'status_label' => isset( $statuses[ $status ]['label'] ) ? $statuses[ $status ]['label'] : '',
'source_type' => $source_type,
'source_type_label'=> isset( $types[ $source_type ]['label'] ) ? $types[ $source_type ]['label'] : '',
);
}
/**
* Check access to the dedicated REST endpoint.
*
* @param WP_REST_Request $request Request object.
* @return true|WP_Error
*/
public function rest_featured_label_permissions_check( $request ) {
$post_id = absint( $request['id'] );
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, $this->post_types(), true ) ) {
return new WP_Error(
'gdaiidl_invalid_post',
__( 'The post or page could not be found.', 'ai-image-disclosure-labels' ),
array( 'status' => 404 )
);
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'gdaiidl_forbidden',
__( 'You are not allowed to edit this post.', 'ai-image-disclosure-labels' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Return the current featured-image label state.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function rest_get_featured_label( $request ) {
return rest_ensure_response( $this->featured_label_rest_data( absint( $request['id'] ) ) );
}
/**
* Persist the featured-image label immediately.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function rest_update_featured_label( $request ) {
$post_id = absint( $request['id'] );
$enabled = rest_sanitize_boolean( $request->get_param( 'enabled' ) );
$has_text = $request->has_param( 'text' );
$has_source_type = $request->has_param( 'source_type' );
$text = $has_text ? $this->sanitize_label_text( $request->get_param( 'text' ) ) : '';
$source_type = $has_source_type
? sanitize_key( (string) $request->get_param( 'source_type' ) )
: '';
$before = $this->featured_label_rest_data( $post_id );
if ( $has_source_type && '' !== $source_type ) {
$types = $this->digital_source_types();
if ( ! isset( $types[ $source_type ] ) ) {
return new WP_Error(
'gdaiidl_invalid_source_type',
__( 'The selected AI source type is not supported.', 'ai-image-disclosure-labels' ),
array( 'status' => 400 )
);
}
}
if ( $enabled ) {
$result = update_post_meta( $post_id, self::META_FEATURED_ENABLED, '1' );
if ( false === $result && '1' !== (string) $this->get_compatible_post_meta( $post_id, self::META_FEATURED_ENABLED, 'featured_enabled' ) ) {
return new WP_Error(
'gdaiidl_save_failed',
__( 'The label could not be saved.', 'ai-image-disclosure-labels' ),
array( 'status' => 500 )
);
}
} else {
$this->delete_compatible_post_meta( $post_id, self::META_FEATURED_ENABLED, 'featured_enabled' );
}
if ( $has_text ) {
if ( '' !== $text ) {
$text_result = update_post_meta( $post_id, self::META_FEATURED_TEXT, $text );
if ( false === $text_result && $text !== (string) $this->get_compatible_post_meta( $post_id, self::META_FEATURED_TEXT, 'featured_text' ) ) {
return new WP_Error(
'gdaiidl_text_save_failed',
__( 'The custom label text could not be saved.', 'ai-image-disclosure-labels' ),
array( 'status' => 500 )
);
}
} else {
$this->delete_compatible_post_meta( $post_id, self::META_FEATURED_TEXT, 'featured_text' );
}
}
if ( $has_source_type ) {
if ( '' !== $source_type ) {
$source_result = update_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE, $source_type );
if ( false === $source_result && $source_type !== (string) get_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE, true ) ) {
return new WP_Error(
'gdaiidl_source_type_save_failed',
__( 'The machine-readable source type could not be saved.', 'ai-image-disclosure-labels' ),
array( 'status' => 500 )
);
}
} else {
delete_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE );
}
}
$after = $this->featured_label_rest_data( $post_id );
if ( $before !== $after ) {
clean_post_cache( $post_id );
$this->maybe_purge_page_cache();
}
return rest_ensure_response( $after );
}
/**
* Return the endpoint response data.
*
* @param int $post_id Post ID.
* @return array
*/
private function featured_label_rest_data( $post_id ) {
return array(
'enabled' => '1' === (string) $this->get_compatible_post_meta( $post_id, self::META_FEATURED_ENABLED, 'featured_enabled' ),
'text' => (string) $this->get_compatible_post_meta( $post_id, self::META_FEATURED_TEXT, 'featured_text' ),
'source_type' => $this->sanitize_digital_source_type( get_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE, true ), '', true ),
);
}
/**
* REST response schema.
*
* @return array
*/
public function rest_featured_label_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'gdaiidl-featured-label',
'type' => 'object',
'properties' => array(
'enabled' => array(
'type' => 'boolean',
'description' => 'Whether the featured image label is enabled.',
),
'text' => array(
'type' => 'string',
'description' => 'Optional per-post label text.',
),
'source_type' => array(
'type' => 'string',
'description' => 'Optional per-post digital source type override.',
),
),
);
}
/**
* Register custom disclosure attributes for core/image and core/video.
*
* @param array $args Block registration arguments.
* @param string $block_type Block type name.
* @return array
*/
public function register_image_block_attributes( $args, $block_type ) {
if ( ! in_array( $block_type, array( 'core/image', 'core/video' ), true ) ) {
return $args;
}
if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) {
$args['attributes'] = array();
}
$args['attributes']['gdAiLabel'] = array(
'type' => 'boolean',
'default' => false,
);
$args['attributes']['gdAiLabelText'] = array(
'type' => 'string',
'default' => '',
);
$args['attributes']['gdAiSourceType'] = array(
'type' => 'string',
'default' => '',
);
return $args;
}
/**
* Enqueue block-editor controls and preview styles.
*
* @return void
*/
public function enqueue_editor_assets() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->post_type, $this->post_types(), true ) ) {
return;
}
$settings = $this->settings();
wp_enqueue_script(
'gdaiidl-editor',
GDAIIDL_URL . 'assets/editor.js',
array(
'wp-api-fetch',
'wp-block-editor',
'wp-blocks',
'wp-components',
'wp-compose',
'wp-data',
'wp-editor',
'wp-element',
'wp-hooks',
'wp-i18n',
'wp-plugins',
),
$this->asset_version( 'assets/editor.js' ),
true
);
wp_localize_script(
'gdaiidl-editor',
'gdaiidlEditorConfig',
array(
'enableImages' => ! empty( $settings['enable_images'] ),
'enableVideos' => ! empty( $settings['enable_videos'] ),
'defaultText' => $settings['label_text'], // Backward-compatible alias for generated.
'defaultTexts' => array(
'generated' => $settings['label_text'],
'modified' => $settings['label_text_modified'],
),
'defaultVideoTexts' => array(
'generated' => ! empty( $settings['video_label_text'] ) ? $settings['video_label_text'] : $settings['label_text'],
'modified' => ! empty( $settings['video_label_text_modified'] ) ? $settings['video_label_text_modified'] : $settings['label_text_modified'],
),
'defaultSourceType' => $settings['digital_source_type'],
'position' => $settings['position'],
'videoAlignment' => ! empty( $settings['video_separate_design'] ) ? $settings['video_alignment'] : ( false !== strpos( (string) $settings['position'], 'left' ) ? 'left' : 'right' ),
'allowedPostTypes' => $this->post_types(),
'machineReadableEnabled' => ! empty( $settings['machine_readable_enabled'] ),
'sourceTypeOptions' => $this->source_type_select_options(),
'metaEnabled' => self::META_FEATURED_ENABLED,
'metaText' => self::META_FEATURED_TEXT,
'restPath' => '/gdaiidl/v1/post/',
'attachmentStatusPath' => '/gdaiidl/v1/attachment/',
)
);
wp_set_script_translations( 'gdaiidl-editor', 'ai-image-disclosure-labels' );
wp_enqueue_style(
'gdaiidl-editor',
GDAIIDL_URL . 'assets/editor.css',
array(),
$this->asset_version( 'assets/editor.css' )
);
wp_add_inline_style( 'gdaiidl-editor', $this->dynamic_badge_css( true ) );
}
/**
* Enqueue preview styles inside the block-editor content canvas.
*
* WordPress 7.1 always renders the post-editor canvas in an iframe. Assets
* added through enqueue_block_assets are also loaded inside that iframe on
* supported WordPress versions. Keep the existing editor stylesheet above
* for the editor UI and backward-compatible non-iframe rendering.
*
* @return void
*/
public function enqueue_editor_content_assets() {
if ( ! is_admin() ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->post_type, $this->post_types(), true ) ) {
return;
}
wp_enqueue_style(
'gdaiidl-editor-content',
GDAIIDL_URL . 'assets/editor-content.css',
array(),
$this->asset_version( 'assets/editor-content.css' )
);
wp_add_inline_style( 'gdaiidl-editor-content', $this->dynamic_badge_css( true ) );
}
/**
* Register front-end assets and enqueue them when the current page needs them.
*
* The performance option performs an early scan of the queried posts. A second
* safeguard in label_html() enqueues the assets when a disclosure is rendered
* later by a Query block, theme loop or other dynamic template.
*
* @return void
*/
public function enqueue_frontend_assets() {
$this->register_frontend_assets();
$settings = $this->settings();
if ( ! empty( $settings['load_assets_only_when_needed'] ) && ! $this->page_may_contain_disclosure() ) {
return;
}
$this->activate_frontend_assets();
}
/**
* Register the front-end stylesheet and script.
*
* @return void
*/
private function register_frontend_assets() {
if ( ! wp_style_is( 'gdaiidl-frontend', 'registered' ) ) {
wp_register_style(
'gdaiidl-frontend',
GDAIIDL_URL . 'assets/frontend.css',
array(),
$this->asset_version( 'assets/frontend.css' )
);
}
if ( ! wp_script_is( 'gdaiidl-frontend', 'registered' ) ) {
wp_register_script(
'gdaiidl-frontend',
GDAIIDL_URL . 'assets/frontend.js',
array(),
$this->asset_version( 'assets/frontend.js' ),
array(
'strategy' => 'defer',
'in_footer' => true,
)
);
}
}
/**
* Determine whether the main queried posts already reveal a disclosure.
*
* @return bool
*/
private function page_may_contain_disclosure() {
$settings = $this->settings();
if ( empty( $settings['enable_images'] ) && empty( $settings['enable_videos'] ) ) {
return false;
}
if ( is_admin() || wp_doing_ajax() || is_feed() ) {
return false;
}
global $wp_query;
$posts = array();
if ( isset( $wp_query->posts ) && is_array( $wp_query->posts ) ) {
$posts = $wp_query->posts;
}
if ( is_singular( $this->post_types() ) ) {
$queried_post = get_post( get_queried_object_id() );
if ( $queried_post instanceof WP_Post ) {
$posts[] = $queried_post;
}
}
$auto_label = $this->media_auto_label_enabled();
foreach ( $posts as $post ) {
if ( ! $post instanceof WP_Post ) {
continue;
}
if ( ! empty( $settings['enable_images'] ) && $this->get_compatible_post_meta( $post->ID, self::META_FEATURED_ENABLED, 'featured_enabled' ) ) {
return true;
}
if (
! empty( $settings['enable_images'] ) &&
$auto_label &&
in_array( $this->attachment_media_status( (int) get_post_thumbnail_id( $post->ID ) ), array( 'generated', 'modified' ), true )
) {
return true;
}
$content = (string) $post->post_content;
$has_relevant_block = ( ! empty( $settings['enable_images'] ) && has_block( 'core/image', $content ) )
|| ( ! empty( $settings['enable_videos'] ) && has_block( 'core/video', $content ) );
if ( $has_relevant_block && $this->blocks_contain_disclosure( parse_blocks( $content ), $auto_label ) ) {
return true;
}
}
return false;
}
/**
* Recursively inspect parsed blocks for a marked core/image or core/video block.
*
* @param array $blocks Parsed blocks.
* @param bool $auto_label Whether Media Library automatic labelling is active.
* @return bool
*/
private function blocks_contain_disclosure( $blocks, $auto_label = false ) {
$settings = $this->settings();
foreach ( (array) $blocks as $block ) {
if (
isset( $block['blockName'], $block['attrs'] ) &&
in_array( $block['blockName'], array( 'core/image', 'core/video' ), true ) &&
is_array( $block['attrs'] ) &&
( ( 'core/image' === $block['blockName'] && ! empty( $settings['enable_images'] ) ) || ( 'core/video' === $block['blockName'] && ! empty( $settings['enable_videos'] ) ) )
) {
if ( ! empty( $block['attrs']['gdAiLabel'] ) ) {
return true;
}
if (
$auto_label &&
! empty( $block['attrs']['id'] ) &&
in_array( $this->attachment_media_status( (int) $block['attrs']['id'] ), array( 'generated', 'modified' ), true )
) {
return true;
}
}
if ( ! empty( $block['innerBlocks'] ) && $this->blocks_contain_disclosure( $block['innerBlocks'], $auto_label ) ) {
return true;
}
}
return false;
}
/**
* Enqueue the front-end assets and prepare the JavaScript configuration.
*
* @return void
*/
private function activate_frontend_assets() {
$this->register_frontend_assets();
if ( ! wp_style_is( 'gdaiidl-frontend', 'enqueued' ) ) {
wp_enqueue_style( 'gdaiidl-frontend' );
wp_add_inline_style( 'gdaiidl-frontend', $this->dynamic_badge_css( false ) );
}
if ( $this->frontend_assets_prepared ) {
return;
}
$settings = $this->settings();
$minimum_width = isset( $settings['minimum_image_width'] ) ? (int) $settings['minimum_image_width'] : 0;
$minimum_text_width = isset( $settings['minimum_text_width'] ) ? (int) $settings['minimum_text_width'] : 0;
$needs_size_filter = 0 < $minimum_width || 0 < $minimum_text_width;
$location_rules_enabled = ! empty( $settings['location_rules_enabled'] );
$enable_theme_fallback = false;
$featured_label_text = '';
$featured_attachment_id = 0;
$featured_auto_color = array();
if ( ! empty( $settings['enable_images'] ) && is_singular( $this->post_types() ) ) {
$post_id = get_queried_object_id();
if ( $post_id && $this->get_compatible_post_meta( $post_id, self::META_FEATURED_ENABLED, 'featured_enabled' ) ) {
$custom_text = $this->get_compatible_post_meta( $post_id, self::META_FEATURED_TEXT, 'featured_text' );
$custom_text = is_string( $custom_text ) ? trim( sanitize_text_field( $custom_text ) ) : '';
$featured_attachment_id = (int) get_post_thumbnail_id( $post_id );
$featured_source_type = get_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE, true );
$featured_label_text = '' !== $custom_text
? $custom_text
: $this->default_label_text_for_usage( $featured_source_type, $featured_attachment_id );
$enable_theme_fallback = '' !== $featured_label_text;
if ( $enable_theme_fallback ) {
$this->register_machine_readable_image(
$featured_attachment_id,
$featured_source_type
);
}
}
}
$auto_color = isset( $settings['background_color_mode'] ) && 'auto' === $settings['background_color_mode'];
if ( $auto_color && $featured_attachment_id > 0 ) {
$featured_auto_color = $this->auto_color_data( $featured_attachment_id );
}
$touch_compact_mode = ! empty( $settings['touch_compact_mode'] );
if ( ! $needs_size_filter && ! $enable_theme_fallback && ! $auto_color && ! $location_rules_enabled && ! $touch_compact_mode ) {
$this->frontend_assets_prepared = true;
return;
}
wp_enqueue_script( 'gdaiidl-frontend' );
wp_localize_script(
'gdaiidl-frontend',
'gdaiidlFrontendConfig',
array(
'labelText' => $featured_label_text,
'preset' => $settings['preset'],
'minimumImageWidth' => $minimum_width,
'minimumTextWidth' => $minimum_text_width,
'smallImageMode' => isset( $settings['small_image_mode'] ) ? $settings['small_image_mode'] : 'icon',
'touchCompactMode' => ! empty( $settings['touch_compact_mode'] ),
'featuredFallback' => $enable_theme_fallback,
'iconHtml' => $this->icon_markup( isset( $settings['icon_style'] ) ? $settings['icon_style'] : 'monogram' ),
'featuredSelectors' => $this->featured_selectors(),
'locationRulesEnabled' => $location_rules_enabled,
'iconOnlySelectors' => $this->selector_lines( isset( $settings['icon_only_selectors'] ) ? $settings['icon_only_selectors'] : '' ),
'hiddenSelectors' => $this->selector_lines( isset( $settings['hidden_selectors'] ) ? $settings['hidden_selectors'] : '' ),
'iconSizeValue' => isset( $settings['icon_size_value'] ) ? (float) $settings['icon_size_value'] : 16,
'iconSizeUnit' => isset( $settings['icon_size_unit'] ) ? $settings['icon_size_unit'] : 'px',
'tooltipEnabled' => ! empty( $settings['icon_tooltip_enabled'] ),
'autoColor' => $auto_color,
'backgroundOpacity' => isset( $settings['background_opacity'] ) ? max( 0, min( 100, (int) $settings['background_opacity'] ) ) : 78,
'featuredAutoColor' => $featured_auto_color,
'tooltipButtonLabel' => '' !== $featured_label_text
/* translators: %s: the disclosure label text, e.g. "AI-generated". */
? sprintf( __( 'Show disclosure: %s', 'ai-image-disclosure-labels' ), $featured_label_text )
: __( 'Show AI disclosure', 'ai-image-disclosure-labels' ),
)
);
$this->frontend_assets_prepared = true;
}
/**
* Print a stylesheet that was first requested after wp_head had completed.
*
* @return void
*/
public function print_late_frontend_styles() {
if ( wp_style_is( 'gdaiidl-frontend', 'enqueued' ) && ! wp_style_is( 'gdaiidl-frontend', 'done' ) ) {
wp_print_styles( 'gdaiidl-frontend' );
}
}
/**
* Add the badge to explicitly marked core/image blocks.
*
* @param string $block_content Rendered block HTML.
* @param array $block Parsed block.
* @return string
*/
public function render_image_block_label( $block_content, $block ) {
if ( empty( $this->settings()['enable_images'] ) ) {
return $block_content;
}
$attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
if ( false !== strpos( $block_content, 'gd-ai-image-label' ) ) {
return $block_content;
}
$explicit = ! empty( $attributes['gdAiLabel'] );
$attachment_id = isset( $attributes['id'] ) ? (int) $attributes['id'] : 0;
$custom_text = isset( $attributes['gdAiLabelText'] ) ? $attributes['gdAiLabelText'] : '';
$source_type = isset( $attributes['gdAiSourceType'] ) ? $attributes['gdAiSourceType'] : '';
/*
* When the block is not explicitly marked, the optional Media Library
* automatic labelling can still add a disclosure if the attachment is
* marked in the Library. The badge text then follows the marked type.
*/
if ( ! $explicit ) {
$media_status = $this->attachment_media_status( $attachment_id );
$media_source_type = $this->attachment_source_type( $attachment_id );
if ( ! in_array( $media_status, array( 'generated', 'modified' ), true ) ) {
return $block_content;
}
/* Precise legacy provenance is emitted when it is known. */
if ( '' !== $media_source_type ) {
$this->register_machine_readable_image(
$attachment_id,
$media_source_type,
$this->extract_image_url( $block_content )
);
}
if ( ! $this->media_auto_label_enabled() ) {
return $block_content;
}
$custom_text = $this->label_text_for_media_status( $media_status );
$source_type = '';
} elseif ( '' === trim( sanitize_text_field( (string) $custom_text ) ) ) {
$custom_text = $this->default_label_text_for_usage( $source_type, $attachment_id );
}
$label_html = $this->label_html( $custom_text, $attachment_id );
if ( '' === $label_html ) {
return $block_content;
}
$this->register_machine_readable_image(
$attachment_id,
$source_type,
$this->extract_image_url( $block_content )
);
$updated = $block_content;
if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
$processor = new WP_HTML_Tag_Processor( $updated );
if ( $processor->next_tag( 'figure' ) ) {
$processor->add_class( 'gd-ai-image-frame' );
$updated = $processor->get_updated_html();
}
} else {
$updated = preg_replace(
'/]*)class=(["\'])([^"\']*)\2/i',
'\s*$/i', $updated ) ) {
return preg_replace( '/<\/figure>\s*$/i', $label_html . '', $updated, 1 );
}
return '' . $updated . $label_html . '';
}
/**
* Add a disclosure directly below a marked core/video block.
*
* Unlike image labels, video disclosures deliberately stay outside the video
* surface so they never obscure native playback controls or moving content.
* Media Library inheritance follows the same opt-in rules used for images.
*
* @param string $block_content Rendered block HTML.
* @param array $block Parsed block.
* @return string
*/
public function render_video_block_label( $block_content, $block ) {
if ( empty( $this->settings()['enable_videos'] ) ) {
return $block_content;
}
$attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
if ( false !== strpos( $block_content, 'gd-ai-video-disclosure-row' ) ) {
return $block_content;
}
$explicit = ! empty( $attributes['gdAiLabel'] );
$attachment_id = isset( $attributes['id'] ) ? (int) $attributes['id'] : 0;
$custom_text = isset( $attributes['gdAiLabelText'] ) ? $attributes['gdAiLabelText'] : '';
$source_type = isset( $attributes['gdAiSourceType'] ) ? $attributes['gdAiSourceType'] : '';
$video_url = $this->extract_video_url( $block_content );
if ( ! $explicit ) {
$media_status = $this->attachment_media_status( $attachment_id );
$media_source_type = $this->attachment_source_type( $attachment_id );
if ( ! in_array( $media_status, array( 'generated', 'modified' ), true ) ) {
return $block_content;
}
if ( '' !== $media_source_type ) {
$this->register_machine_readable_video( $attachment_id, $media_source_type, $video_url );
}
if ( ! $this->media_auto_label_enabled() ) {
return $block_content;
}
$custom_text = $this->label_text_for_media_status( $media_status, 'video' );
$source_type = '';
} elseif ( '' === trim( sanitize_text_field( (string) $custom_text ) ) ) {
$custom_text = $this->default_label_text_for_usage( $source_type, $attachment_id, 'video' );
}
/* Video files are not sampled for automatic image-based badge colors. */
$label_html = $this->label_html( $custom_text, 0, 'video' );
if ( '' === $label_html ) {
return $block_content;
}
$this->register_machine_readable_video( $attachment_id, $source_type, $video_url );
$settings = $this->settings();
$alignment = ! empty( $settings['video_separate_design'] ) && isset( $settings['video_alignment'] )
? (string) $settings['video_alignment']
: ( false !== strpos( (string) $settings['position'], 'left' ) ? 'left' : 'right' );
if ( ! in_array( $alignment, array( 'left', 'center', 'right' ), true ) ) {
$alignment = 'right';
}
$row_html = sprintf(
'%2$s
',
esc_attr( $alignment ),
$label_html
);
/* Core/video puts the caption after ; insert before that caption. */
if ( preg_match( '/<\/video>/i', $block_content ) ) {
return preg_replace( '/<\/video>/i', '' . $row_html, $block_content, 1 );
}
if ( preg_match( '/<\/figure>\s*$/i', $block_content ) ) {
return preg_replace( '/<\/figure>\s*$/i', $row_html . '', $block_content, 1 );
}
return $block_content . $row_html;
}
/**
* Add the badge when a theme renders the featured attachment directly.
*
* Some themes call wp_get_attachment_image() instead of the regular
* post-thumbnail template functions. The wp-post-image class prevents the
* same attachment from being labelled when it is used as a normal content image.
*
* @param string $html Image HTML.
* @param int $attachment_id Attachment ID.
* @param string|array $size Requested image size.
* @param bool $icon Whether the image is an icon.
* @param array $attr Image attributes.
* @return string
*/
public function render_featured_attachment_label( $html, $attachment_id, $size, $icon, $attr ) {
if ( empty( $this->settings()['enable_images'] ) ) {
return $html;
}
unset( $size, $icon, $attr );
if ( '' === $html || is_admin() || wp_doing_ajax() || is_feed() ) {
return $html;
}
$library_source_type = $this->attachment_source_type( (int) $attachment_id );
if ( '' !== $library_source_type ) {
$this->register_machine_readable_image(
(int) $attachment_id,
$library_source_type,
$this->extract_image_url( $html )
);
}
if (
false !== strpos( $html, 'gd-ai-featured-wrap' ) ||
false === strpos( $html, 'wp-post-image' )
) {
return $html;
}
/*
* On singular views the queried object is authoritative. In post loops,
* query blocks and archive cards, get_the_ID() identifies the post whose
* image is currently being rendered. This lets themes that call
* wp_get_attachment_image() directly receive the same disclosure as
* themes that use get_the_post_thumbnail().
*/
$post_id = is_singular( $this->post_types() ) ? get_queried_object_id() : get_the_ID();
if (
! $post_id ||
! in_array( get_post_type( $post_id ), $this->post_types(), true ) ||
(int) $attachment_id !== (int) get_post_thumbnail_id( $post_id )
) {
return $html;
}
$plan = $this->featured_label_plan( $post_id, (int) $attachment_id );
if ( false === $plan ) {
return $html;
}
$label_html = $this->label_html( $plan['text'], (int) $attachment_id );
if ( '' === $label_html ) {
return $html;
}
$this->register_machine_readable_image(
(int) $attachment_id,
$plan['source_type'],
$this->extract_image_url( $html )
);
return '' . $html . $label_html . '';
}
/**
* Add the badge to explicitly marked featured images.
*
* @param string $html Featured image HTML.
* @param int $post_id Post ID.
* @param int $post_thumbnail_id Attachment ID.
* @param string|array $size Image size.
* @param string|array $attr Image attributes.
* @return string
*/
public function render_featured_image_label( $html, $post_id, $post_thumbnail_id, $size, $attr ) {
if ( empty( $this->settings()['enable_images'] ) ) {
return $html;
}
unset( $size, $attr );
if (
'' === $html ||
is_admin() ||
wp_doing_ajax() ||
is_feed() ||
false !== strpos( $html, 'gd-ai-featured-wrap' )
) {
return $html;
}
$library_source_type = $this->attachment_source_type( (int) $post_thumbnail_id );
if ( '' !== $library_source_type ) {
$this->register_machine_readable_image(
(int) $post_thumbnail_id,
$library_source_type,
$this->extract_image_url( $html )
);
}
$plan = $this->featured_label_plan( (int) $post_id, (int) $post_thumbnail_id );
if ( false === $plan ) {
return $html;
}
$label_html = $this->label_html( $plan['text'], (int) $post_thumbnail_id );
if ( '' === $label_html ) {
return $html;
}
$this->register_machine_readable_image(
(int) $post_thumbnail_id,
$plan['source_type'],
$this->extract_image_url( $html )
);
return '' . $html . $label_html . '';
}
/**
* Decide whether and how a featured image should be labelled.
*
* Explicit per-post marking takes precedence and keeps its custom text and
* source-type override. When the post is not explicitly marked, optional
* Media Library automatic labelling applies if the featured attachment is
* marked in the Library.
*
* @param int $post_id Post ID.
* @param int $attachment_id Featured attachment ID.
* @return array|false Array with text and source_type keys, or false.
*/
private function featured_label_plan( $post_id, $attachment_id ) {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) {
return false;
}
if ( $this->get_compatible_post_meta( $post_id, self::META_FEATURED_ENABLED, 'featured_enabled' ) ) {
$custom_text = $this->get_compatible_post_meta( $post_id, self::META_FEATURED_TEXT, 'featured_text' );
$custom_text = is_string( $custom_text ) ? trim( sanitize_text_field( $custom_text ) ) : '';
$source_type = get_post_meta( $post_id, self::META_FEATURED_SOURCE_TYPE, true );
return array(
'text' => '' !== $custom_text ? $custom_text : $this->default_label_text_for_usage( $source_type, $attachment_id ),
'source_type' => $source_type,
);
}
if ( ! $this->media_auto_label_enabled() ) {
return false;
}
$media_status = $this->attachment_media_status( $attachment_id );
if ( ! in_array( $media_status, array( 'generated', 'modified' ), true ) ) {
return false;
}
return array(
'text' => $this->label_text_for_media_status( $media_status ),
'source_type' => '',
);
}
/**
* Extract the first image URL from rendered markup.
*
* @param string $html Rendered image markup.
* @return string
*/
private function extract_image_url( $html ) {
if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
$processor = new WP_HTML_Tag_Processor( (string) $html );
if ( $processor->next_tag( 'img' ) ) {
return esc_url_raw( (string) $processor->get_attribute( 'src' ) );
}
}
if ( preg_match( '/
]*\bsrc=(?:"([^"]+)"|\'([^\']+)\')/i', (string) $html, $matches ) ) {
return esc_url_raw( html_entity_decode( ! empty( $matches[1] ) ? $matches[1] : $matches[2], ENT_QUOTES, 'UTF-8' ) );
}
return '';
}
/**
* Extract the primary video URL from rendered core/video markup.
*
* @param string $html Rendered video markup.
* @return string
*/
private function extract_video_url( $html ) {
if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
$processor = new WP_HTML_Tag_Processor( (string) $html );
if ( $processor->next_tag( 'video' ) ) {
$src = esc_url_raw( (string) $processor->get_attribute( 'src' ) );
if ( '' !== $src ) {
return $src;
}
}
$processor = new WP_HTML_Tag_Processor( (string) $html );
if ( $processor->next_tag( 'source' ) ) {
return esc_url_raw( (string) $processor->get_attribute( 'src' ) );
}
}
if ( preg_match( '/<(?:video|source)\b[^>]*\bsrc=(?:"([^"]+)"|\'([^\']+)\')/i', (string) $html, $matches ) ) {
return esc_url_raw( html_entity_decode( ! empty( $matches[1] ) ? $matches[1] : $matches[2], ENT_QUOTES, 'UTF-8' ) );
}
return '';
}
/**
* Return a stable identity URL for an external image.
*
* Cloudflare Images appends a delivery-variant segment after the immutable
* image ID. Different crops and widths are still renditions of the same
* underlying asset, so the final variant segment is ignored for the
* deduplication key. Other URL structures are left untouched.
*
* @param string $url Image URL.
* @return string
*/
private function machine_readable_identity_url( $url ) {
$url = esc_url_raw( (string) $url );
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) {
return $url;
}
if ( ! preg_match( '~^(/cdn-cgi/imagedelivery/[^/]+/[^/]+)(?:/[^/?#]+)?$~', $parts['path'], $matches ) ) {
return $url;
}
$scheme = ! empty( $parts['scheme'] ) ? $parts['scheme'] : 'https';
$port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $parts['host'] . $port . $matches[1];
}
/**
* Register one image for the page-level machine-readable JSON-LD graph.
*
* WordPress attachment IDs are used as the primary identity. This keeps a
* full-size image, responsive thumbnail, crop or CDN rendition together as
* one ImageObject without changing any visible image markup. External
* images fall back to a conservative normalized-URL identity.
*
* @param int $attachment_id WordPress attachment ID when available.
* @param mixed $source_type Optional per-image source type override.
* @param string $fallback_url Image URL for external or unregistered images.
* @return void
*/
private function register_machine_readable_image( $attachment_id, $source_type = '', $fallback_url = '' ) {
if ( is_admin() || wp_doing_ajax() || is_feed() ) {
return;
}
$attachment_id = (int) $attachment_id;
/*
* Resolution order: an explicit per-usage source type wins; otherwise
* the attachment's Media Library mark is used; otherwise the global
* default from resolved_digital_source_type().
*/
$source_type = $this->sanitize_digital_source_type( $source_type, '', true );
if ( '' === $source_type && $attachment_id > 0 ) {
$source_type = $this->attachment_source_type( $attachment_id );
/*
* A generic AI-modified Library status deliberately does not guess
* whether the standardized source type was generative editing or
* algorithmic enhancement. An explicit no-AI declaration likewise
* suppresses an inherited global AI source type.
*/
if ( '' === $source_type && in_array( $this->attachment_media_status( $attachment_id ), array( 'modified', 'no-ai' ), true ) ) {
return;
}
}
$source_type = $this->resolved_digital_source_type( $source_type );
$types = $this->digital_source_types();
if ( '' === $source_type || ! isset( $types[ $source_type ] ) ) {
return;
}
$attachment_url = $attachment_id > 0 ? wp_get_attachment_url( $attachment_id ) : '';
$url = '' !== (string) $attachment_url ? $attachment_url : $fallback_url;
$url = esc_url_raw( (string) $url );
if ( '' === $url ) {
return;
}
if ( $attachment_id > 0 ) {
$key = 'attachment:' . $attachment_id;
$identity_url = $url;
} else {
$identity_url = $this->machine_readable_identity_url( $url );
$key = 'url:' . md5( $identity_url );
}
$this->machine_readable_media[ $key ] = array(
'@type' => 'ImageObject',
'@id' => $identity_url . '#gdaiidl-source',
'contentUrl' => $url,
'digitalSourceType' => $types[ $source_type ]['uri'],
);
}
/**
* Register one video for the page-level machine-readable JSON-LD graph.
*
* @param int $attachment_id WordPress attachment ID when available.
* @param mixed $source_type Optional per-video source type override.
* @param string $fallback_url Video URL for external or unregistered videos.
* @return void
*/
private function register_machine_readable_video( $attachment_id, $source_type = '', $fallback_url = '' ) {
if ( is_admin() || wp_doing_ajax() || is_feed() ) {
return;
}
$attachment_id = (int) $attachment_id;
$source_type = $this->sanitize_digital_source_type( $source_type, '', true );
if ( '' === $source_type && $attachment_id > 0 ) {
$source_type = $this->attachment_source_type( $attachment_id );
if ( '' === $source_type && in_array( $this->attachment_media_status( $attachment_id ), array( 'modified', 'no-ai' ), true ) ) {
return;
}
}
$source_type = $this->resolved_digital_source_type( $source_type );
$types = $this->digital_source_types();
if ( '' === $source_type || ! isset( $types[ $source_type ] ) ) {
return;
}
$attachment_url = $attachment_id > 0 ? wp_get_attachment_url( $attachment_id ) : '';
$url = '' !== (string) $attachment_url ? $attachment_url : $fallback_url;
$url = esc_url_raw( (string) $url );
if ( '' === $url ) {
return;
}
$key = $attachment_id > 0 ? 'video-attachment:' . $attachment_id : 'video-url:' . md5( $url );
$this->machine_readable_media[ $key ] = array(
'@type' => 'VideoObject',
'@id' => $url . '#gdaiidl-source',
'contentUrl' => $url,
'digitalSourceType' => $types[ $source_type ]['uri'],
);
}
/**
* Print one deduplicated Schema.org JSON-LD graph for marked images/videos.
*
* @return void
*/
public function render_machine_readable_source_data() {
if ( empty( $this->machine_readable_media ) ) {
return;
}
$data = array(
'@context' => 'https://schema.org',
'@graph' => array_values( $this->machine_readable_media ),
);
$json = wp_json_encode(
$data,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
if ( false === $json ) {
return;
}
wp_print_inline_script_tag(
$json,
array(
'type' => 'application/ld+json',
'class' => 'gdaiidl-machine-readable-source',
)
);
$this->machine_readable_media = array();
}
/**
* Average color of an attachment, cached in attachment meta.
*
* The color is computed once per attachment from its thumbnail file by
* resampling it to a single pixel with GD, then stored, so the front
* end never pays the image-processing cost again.
*
* @param int $attachment_id Attachment ID.
* @return array|null Array with r, g, b keys or null when unavailable.
*/
private function attachment_average_color( $attachment_id ) {
$attachment_id = (int) $attachment_id;
if ( $attachment_id <= 0 ) {
return null;
}
$cached = $this->get_compatible_post_meta( $attachment_id, self::META_AVERAGE_COLOR, 'average_color' );
if ( is_string( $cached ) && preg_match( '/^\d{1,3},\d{1,3},\d{1,3}$/', $cached ) ) {
$parts = array_map( 'intval', explode( ',', $cached ) );
return array(
'r' => min( 255, $parts[0] ),
'g' => min( 255, $parts[1] ),
'b' => min( 255, $parts[2] ),
);
}
if ( 'none' === $cached ) {
return null;
}
if ( ! function_exists( 'imagecreatefromstring' ) ) {
return null;
}
$file = null;
$src = image_get_intermediate_size( $attachment_id, 'thumbnail' );
if ( $src && ! empty( $src['path'] ) ) {
$uploads = wp_get_upload_dir();
$file = trailingslashit( $uploads['basedir'] ) . $src['path'];
}
if ( ! $file || ! is_readable( $file ) ) {
$file = get_attached_file( $attachment_id );
}
if ( ! $file || ! is_readable( $file ) ) {
update_post_meta( $attachment_id, self::META_AVERAGE_COLOR, 'none' );
return null;
}
$file_size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- local file can disappear between checks; handled below.
if ( false === $file_size || $file_size <= 0 || $file_size > 5 * MB_IN_BYTES ) {
update_post_meta( $attachment_id, self::META_AVERAGE_COLOR, 'none' );
return null;
}
$contents = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- bounded local attachment file.
if ( false === $contents || '' === $contents || strlen( $contents ) > 5 * MB_IN_BYTES ) {
update_post_meta( $attachment_id, self::META_AVERAGE_COLOR, 'none' );
return null;
}
$image = @imagecreatefromstring( $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- GD emits warnings for unsupported formats; handled via the false return.
if ( ! $image ) {
update_post_meta( $attachment_id, self::META_AVERAGE_COLOR, 'none' );
return null;
}
$pixel = imagecreatetruecolor( 1, 1 );
imagecopyresampled( $pixel, $image, 0, 0, 0, 0, 1, 1, imagesx( $image ), imagesy( $image ) );
$rgb = imagecolorat( $pixel, 0, 0 );
imagedestroy( $image );
imagedestroy( $pixel );
$color = array(
'r' => ( $rgb >> 16 ) & 0xFF,
'g' => ( $rgb >> 8 ) & 0xFF,
'b' => $rgb & 0xFF,
);
update_post_meta( $attachment_id, self::META_AVERAGE_COLOR, $color['r'] . ',' . $color['g'] . ',' . $color['b'] );
return $color;
}
/**
* Convert an 8-bit sRGB channel to linear-light space.
*
* @param int|float $value Channel value from 0 to 255.
* @return float
*/
private function linearize_srgb_channel( $value ) {
$channel = max( 0, min( 255, (float) $value ) ) / 255;
return $channel <= 0.04045
? $channel / 12.92
: pow( ( $channel + 0.055 ) / 1.055, 2.4 );
}
/**
* Calculate sRGB relative luminance.
*
* @param array $color RGB color array.
* @return float
*/
private function relative_luminance( $color ) {
return 0.2126 * $this->linearize_srgb_channel( $color['r'] )
+ 0.7152 * $this->linearize_srgb_channel( $color['g'] )
+ 0.0722 * $this->linearize_srgb_channel( $color['b'] );
}
/**
* Calculate the contrast ratio between two luminance values.
*
* @param float $first First luminance.
* @param float $second Second luminance.
* @return float
*/
private function contrast_ratio( $first, $second ) {
$lighter = max( $first, $second );
$darker = min( $first, $second );
return ( $lighter + 0.05 ) / ( $darker + 0.05 );
}
/**
* Choose the better contrasting text color for a sampled background.
*
* @param array $color RGB color array.
* @return string
*/
private function readable_text_color( $color ) {
$background = $this->relative_luminance( $color );
$dark = $this->relative_luminance( array( 'r' => 17, 'g' => 17, 'b' => 17 ) );
$light = 1.0;
return $this->contrast_ratio( $background, $dark ) >= $this->contrast_ratio( $background, $light )
? '#111111'
: '#ffffff';
}
/**
* Return automatic badge colors for an attachment.
*
* The server-side sample is also passed to the JavaScript-created featured
* image fallback. This avoids relying on canvas access to a cross-origin CDN
* image, which browsers may block even though the image itself is visible.
*
* @param int $attachment_id Attachment ID.
* @return array Empty array when automatic colors are unavailable.
*/
private function auto_color_data( $attachment_id ) {
$settings = $this->settings();
if ( empty( $settings['background_color_mode'] ) || 'auto' !== $settings['background_color_mode'] ) {
return array();
}
$color = $this->attachment_average_color( $attachment_id );
if ( null === $color ) {
return array();
}
$alpha = round( max( 0, min( 100, (int) $settings['background_opacity'] ) ) / 100, 2 );
$rgb = $color['r'] . ',' . $color['g'] . ',' . $color['b'];
$rgba = 'rgba(' . $rgb . ',' . $alpha . ')';
return array(
'background' => $rgba,
'border' => $rgba,
'text' => $this->readable_text_color( $color ),
);
}
/**
* Build inline CSS variables for automatic badge colors.
*
* @param int $attachment_id Attachment ID.
* @return string Empty string when automatic colors are unavailable.
*/
private function auto_color_style( $attachment_id ) {
$colors = $this->auto_color_data( $attachment_id );
if ( empty( $colors ) ) {
return '';
}
return sprintf(
'--gd-ai-label-bg:%1$s;--gd-ai-label-color:%2$s;--gd-ai-label-border-color:%3$s;',
$colors['background'],
$colors['text'],
$colors['border']
);
}
/**
* Build badge HTML.
*
* @param string $custom_text Optional per-image text.
* @param int $attachment_id Attachment ID used for automatic color sampling.
* @return string
*/
private function label_html( $custom_text = '', $attachment_id = 0, $media = 'image' ) {
$text = $this->sanitize_label_text( $custom_text );
$settings = $this->settings();
if ( '' === $text ) {
return '';
}
if ( ! is_admin() && ! wp_doing_ajax() && ! is_feed() ) {
$this->activate_frontend_assets();
}
$preset_class = in_array( $settings['preset'], array( 'subtle', 'light', 'pill' ), true )
? ' gd-ai-preset-' . $settings['preset']
: ' gd-ai-preset-custom';
$media_class = 'video' === $media ? ' gd-ai-media-video' : ' gd-ai-media-image';
$tooltip_enabled = ! empty( $settings['icon_tooltip_enabled'] );
$tooltip_class = $tooltip_enabled ? ' gd-ai-tooltip-enabled' : '';
$touch_class = ! empty( $settings['touch_compact_mode'] ) ? ' gd-ai-touch-compact' : '';
$icon_markup = $this->icon_html();
$tooltip_markup = '';
if ( $tooltip_enabled ) {
$tooltip_id = wp_unique_id( 'gd-ai-label-tooltip-' );
$icon_markup = sprintf(
'%3$s',
esc_attr( $tooltip_id ),
/* translators: %s: the disclosure label text, e.g. "AI-generated". */
esc_attr( sprintf( __( 'Show disclosure: %s', 'ai-image-disclosure-labels' ), $text ) ),
$icon_markup
);
$tooltip_markup = sprintf(
'%2$s',
esc_attr( $tooltip_id ),
esc_html( $text )
);
}
$auto_style = $this->auto_color_style( $attachment_id );
$style_attr = '' !== $auto_style ? ' style="' . esc_attr( $auto_style ) . '"' : '';
return sprintf(
'%5$s%6$s%7$s',
esc_attr( $preset_class ),
esc_attr( $tooltip_class ),
esc_attr( $touch_class ),
esc_attr( $text ),
$icon_markup,
esc_html( $text ),
$tooltip_markup,
$style_attr,
esc_attr( $media_class )
);
}
/**
* Return available icon choices.
*
* @return array
*/
private function icon_choices() {
return array(
'monogram' => __( 'AI Monogram', 'ai-image-disclosure-labels' ),
'sparkle' => __( 'Sparkle', 'ai-image-disclosure-labels' ),
'chip' => __( 'Chip', 'ai-image-disclosure-labels' ),
'custom' => __( 'Custom symbol', 'ai-image-disclosure-labels' ),
);
}
/**
* Return validated custom-icon attachment data.
*
* @param int|null $attachment_id Attachment ID, or null for the saved setting.
* @return array|null
*/
private function custom_icon_data( $attachment_id = null ) {
if ( null === $attachment_id ) {
$settings = $this->settings();
$attachment_id = isset( $settings['custom_icon_id'] ) ? absint( $settings['custom_icon_id'] ) : 0;
}
$attachment_id = absint( $attachment_id );
if ( ! $attachment_id ) {
return null;
}
$mime = (string) get_post_mime_type( $attachment_id );
if ( ! in_array( $mime, array( 'image/png', 'image/svg+xml' ), true ) ) {
return null;
}
$url = wp_get_attachment_url( $attachment_id );
if ( ! $url ) {
return null;
}
return array(
'id' => $attachment_id,
'url' => esc_url_raw( $url ),
'mime' => $mime,
);
}
/**
* Build the icon wrapper HTML.
*
* @return string
*/
private function icon_html() {
$settings = $this->settings();
$style = isset( $settings['icon_style'] ) ? $settings['icon_style'] : 'monogram';
return '' . $this->icon_markup( $style ) . '';
}
/**
* Build built-in or custom icon markup.
*
* @param string $style Icon style.
* @return string
*/
private function icon_markup( $style ) {
if ( 'custom' === $style ) {
$custom = $this->custom_icon_data();
if ( $custom ) {
return sprintf(
'
',
esc_url( $custom['url'] )
);
}
$style = 'monogram';
}
return $this->icon_svg_markup( $style );
}
/**
* Allowed HTML for icon output, used with wp_kses().
*
* @return array
*/
private function allowed_icon_html() {
return array(
'svg' => array(
'class' => true,
'viewbox' => true,
'aria-hidden' => true,
'focusable' => true,
'xmlns' => true,
),
'path' => array( 'd' => true ),
'rect' => array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'rx' => true,
),
'circle' => array(
'cx' => true,
'cy' => true,
'r' => true,
),
'text' => array(
'x' => true,
'y' => true,
'text-anchor' => true,
),
'img' => array(
'class' => true,
'src' => true,
'alt' => true,
'decoding' => true,
),
);
}
/**
* Build an inline SVG icon.
*
* @param string $style Icon style.
* @return string
*/
private function icon_svg_markup( $style ) {
switch ( $style ) {
case 'sparkle':
return '';
case 'chip':
return '';
case 'monogram':
default:
return '';
}
}
/**
* Invalidate the cached sampled color when an attachment changes.
*
* @param array $metadata Updated attachment metadata.
* @param int $attachment_id Attachment ID.
* @return array
*/
public function clear_attachment_color_cache( $metadata, $attachment_id ) {
$this->delete_compatible_post_meta( (int) $attachment_id, self::META_AVERAGE_COLOR, 'average_color' );
return $metadata;
}
/**
* Register plugin settings.
*
* @return void
*/
public function register_settings() {
register_setting(
'gdaiidl_settings_group',
self::OPTION_KEY,
array(
'type' => 'array',
'default' => self::defaults(),
'sanitize_callback' => array( $this, 'sanitize_settings' ),
)
);
}
/**
* Sanitize settings.
*
* @param mixed $input Raw settings.
* @return array
*/
public function sanitize_settings( $input ) {
$defaults = self::defaults();
$input = is_array( $input ) ? $input : array();
$output = array();
$output['enable_images'] = ! empty( $input['enable_images'] );
$output['enable_videos'] = ! empty( $input['enable_videos'] );
$output['label_text'] = isset( $input['label_text'] )
? $this->sanitize_label_text( $input['label_text'] )
: $defaults['label_text'];
$output['label_text_modified'] = isset( $input['label_text_modified'] )
? $this->sanitize_label_text( $input['label_text_modified'] )
: $defaults['label_text_modified'];
$output['machine_readable_enabled'] = ! empty( $input['machine_readable_enabled'] );
$output['media_library_auto_label'] = ! empty( $input['media_library_auto_label'] );
$output['load_assets_only_when_needed'] = ! empty( $input['load_assets_only_when_needed'] );
$output['digital_source_type'] = $this->sanitize_digital_source_type(
isset( $input['digital_source_type'] ) ? $input['digital_source_type'] : $defaults['digital_source_type']
);
$positions = array( 'top-left', 'top-right', 'bottom-left', 'bottom-right' );
$output['position'] = isset( $input['position'] ) && in_array( $input['position'], $positions, true )
? $input['position']
: $defaults['position'];
$allowed_presets = array( 'subtle', 'light', 'pill', 'custom' );
$output['preset'] = isset( $input['preset'] ) && in_array( $input['preset'], $allowed_presets, true )
? $input['preset']
: $defaults['preset'];
/*
* A named preset always saves its canonical values. This makes preset
* selection work even if admin JavaScript is cached or blocked.
*/
$presets = $this->presets();
if ( isset( $presets[ $output['preset'] ] ) ) {
$output = array_merge( $output, $presets[ $output['preset'] ] );
} else {
foreach ( array( 'background_color', 'text_color', 'border_color' ) as $color_key ) {
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( $input[ $color_key ] ) : '';
$output[ $color_key ] = $color ? $color : $defaults[ $color_key ];
}
$output['background_opacity'] = $this->clamp_int( $input, 'background_opacity', 0, 100, $defaults['background_opacity'] );
$output['border_width'] = $this->clamp_int( $input, 'border_width', 0, 5, $defaults['border_width'] );
$output['border_radius'] = $this->clamp_int( $input, 'border_radius', 0, 999, $defaults['border_radius'] );
$output['font_size'] = $this->clamp_int( $input, 'font_size', 8, 24, $defaults['font_size'] );
$output['padding_vertical'] = $this->clamp_int( $input, 'padding_vertical', 0, 20, $defaults['padding_vertical'] );
$output['padding_horizontal'] = $this->clamp_int( $input, 'padding_horizontal', 0, 30, $defaults['padding_horizontal'] );
$font_weights = array( 400, 500, 600, 700 );
$font_weight = isset( $input['font_weight'] ) ? (int) $input['font_weight'] : $defaults['font_weight'];
$output['font_weight'] = in_array( $font_weight, $font_weights, true ) ? $font_weight : $defaults['font_weight'];
}
/* Position offsets and text transformation remain editable in all modes. */
$output['offset_vertical'] = $this->clamp_int( $input, 'offset_vertical', 0, 60, $defaults['offset_vertical'] );
$output['offset_horizontal'] = $this->clamp_int( $input, 'offset_horizontal', 0, 60, $defaults['offset_horizontal'] );
$text_transforms = array( 'none', 'uppercase' );
$output['text_transform'] = isset( $input['text_transform'] ) && in_array( $input['text_transform'], $text_transforms, true )
? $input['text_transform']
: $defaults['text_transform'];
/* 0 disables the small-image filter and always shows marked labels. */
$output['minimum_image_width'] = $this->clamp_int(
$input,
'minimum_image_width',
0,
1200,
$defaults['minimum_image_width']
);
$output['minimum_text_width'] = $this->clamp_int(
$input,
'minimum_text_width',
0,
1600,
$defaults['minimum_text_width']
);
$small_image_modes = array( 'icon', 'hide' );
$output['small_image_mode'] = isset( $input['small_image_mode'] ) && in_array( $input['small_image_mode'], $small_image_modes, true )
? $input['small_image_mode']
: $defaults['small_image_mode'];
$output['touch_compact_mode'] = ! empty( $input['touch_compact_mode'] );
$icon_choices = array_keys( $this->icon_choices() );
$output['icon_style'] = isset( $input['icon_style'] ) && in_array( $input['icon_style'], $icon_choices, true )
? $input['icon_style']
: $defaults['icon_style'];
$custom_icon_id = isset( $input['custom_icon_id'] ) ? absint( $input['custom_icon_id'] ) : 0;
$output['custom_icon_id'] = $this->custom_icon_data( $custom_icon_id ) ? $custom_icon_id : 0;
$icon_size_units = array( 'px', 'percent' );
$output['icon_size_unit'] = isset( $input['icon_size_unit'] ) && in_array( $input['icon_size_unit'], $icon_size_units, true )
? $input['icon_size_unit']
: $defaults['icon_size_unit'];
if ( 'percent' === $output['icon_size_unit'] ) {
$output['icon_size_value'] = $this->clamp_float( $input, 'icon_size_value', 1, 30, $defaults['icon_size_value'] );
} else {
$output['icon_size_value'] = $this->clamp_float( $input, 'icon_size_value', 8, 80, $defaults['icon_size_value'] );
}
if ( 'custom' === $output['icon_style'] && ! $output['custom_icon_id'] ) {
$output['icon_style'] = 'monogram';
}
$output['icon_tooltip_enabled'] = ! empty( $input['icon_tooltip_enabled'] );
$output['background_color_mode'] = isset( $input['background_color_mode'] ) && 'auto' === $input['background_color_mode'] ? 'auto' : 'fixed';
$font_modes = array( 'inherit', 'system-sans', 'system-serif', 'system-mono', 'custom' );
$output['font_family_mode'] = isset( $input['font_family_mode'] ) && in_array( $input['font_family_mode'], $font_modes, true )
? $input['font_family_mode']
: 'inherit';
$output['font_family_custom'] = '';
if ( isset( $input['font_family_custom'] ) && is_string( $input['font_family_custom'] ) ) {
/*
* A CSS font-family stack only: letters, digits, spaces, commas,
* hyphens and single quotes. Everything else is stripped so the
* value can never break out of the generated CSS.
*/
$stack = preg_replace( "/[^A-Za-z0-9,\-' ]/", '', $input['font_family_custom'] );
$stack = trim( preg_replace( '/\s+/', ' ', (string) $stack ) );
$output['font_family_custom'] = substr( $stack, 0, 200 );
}
if ( 'custom' === $output['font_family_mode'] && '' === $output['font_family_custom'] ) {
$output['font_family_mode'] = 'inherit';
}
/*
* Theme integration: one CSS selector per line for themes whose
* featured-image wrapper is not covered by the built-in selectors.
*/
$output['custom_selectors'] = '';
if ( isset( $input['custom_selectors'] ) && is_string( $input['custom_selectors'] ) ) {
$lines = array_map( 'sanitize_text_field', explode( "\n", $input['custom_selectors'] ) );
$lines = array_filter( array_map( 'trim', $lines ) );
$lines = array_slice( $lines, 0, 20 );
$lines = array_filter(
$lines,
static function ( $line ) {
return strlen( $line ) <= 200 && false === strpos( $line, '<' ) && false === strpos( $line, '{' );
}
);
$output['custom_selectors'] = implode( "\n", $lines );
}
$output['location_rules_enabled'] = ! empty( $input['location_rules_enabled'] );
foreach ( array( 'icon_only_selectors', 'hidden_selectors' ) as $selector_key ) {
$output[ $selector_key ] = '';
if ( isset( $input[ $selector_key ] ) && is_string( $input[ $selector_key ] ) ) {
$lines = array_map( 'sanitize_text_field', explode( "\n", $input[ $selector_key ] ) );
$lines = array_values( array_unique( array_filter( array_map( 'trim', $lines ) ) ) );
$lines = array_slice( $lines, 0, 30 );
$lines = array_filter(
$lines,
static function ( $line ) {
return strlen( $line ) <= 240 && false === strpos( $line, '<' ) && false === strpos( $line, '{' );
}
);
$output[ $selector_key ] = implode( "\n", $lines );
}
}
$output['video_separate_design'] = ! empty( $input['video_separate_design'] );
$output['video_label_text'] = isset( $input['video_label_text'] ) ? $this->sanitize_label_text( $input['video_label_text'] ) : '';
$output['video_label_text_modified'] = isset( $input['video_label_text_modified'] ) ? $this->sanitize_label_text( $input['video_label_text_modified'] ) : '';
$video_alignments = array( 'left', 'center', 'right' );
$output['video_alignment'] = isset( $input['video_alignment'] ) && in_array( $input['video_alignment'], $video_alignments, true ) ? $input['video_alignment'] : $defaults['video_alignment'];
foreach ( array( 'video_background_color', 'video_text_color', 'video_border_color' ) as $video_color_key ) {
$video_color = isset( $input[ $video_color_key ] ) ? sanitize_hex_color( $input[ $video_color_key ] ) : '';
$output[ $video_color_key ] = $video_color ? $video_color : $defaults[ $video_color_key ];
}
$output['video_background_opacity'] = $this->clamp_int( $input, 'video_background_opacity', 0, 100, $defaults['video_background_opacity'] );
$output['video_border_width'] = $this->clamp_int( $input, 'video_border_width', 0, 5, $defaults['video_border_width'] );
$output['video_border_radius'] = $this->clamp_int( $input, 'video_border_radius', 0, 999, $defaults['video_border_radius'] );
$output['video_font_size'] = $this->clamp_int( $input, 'video_font_size', 8, 24, $defaults['video_font_size'] );
$video_font_weight = isset( $input['video_font_weight'] ) ? (int) $input['video_font_weight'] : $defaults['video_font_weight'];
$output['video_font_weight'] = in_array( $video_font_weight, array( 400, 500, 600, 700 ), true ) ? $video_font_weight : $defaults['video_font_weight'];
$output['video_padding_vertical'] = $this->clamp_int( $input, 'video_padding_vertical', 0, 20, $defaults['video_padding_vertical'] );
$output['video_padding_horizontal'] = $this->clamp_int( $input, 'video_padding_horizontal', 0, 30, $defaults['video_padding_horizontal'] );
$output['video_text_transform'] = isset( $input['video_text_transform'] ) && in_array( $input['video_text_transform'], array( 'none', 'uppercase' ), true ) ? $input['video_text_transform'] : 'none';
if ( $output['minimum_text_width'] > 0 && $output['minimum_text_width'] < $output['minimum_image_width'] ) {
$output['minimum_text_width'] = $output['minimum_image_width'];
}
return $output;
}
/**
* Sanitize one visible disclosure text and enforce the same 80-character
* limit used by the settings and editor controls.
*
* @param mixed $value Candidate text.
* @return string
*/
private function sanitize_label_text( $value ) {
$text = trim( sanitize_text_field( (string) $value ) );
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $text, 0, 80, 'UTF-8' );
}
if ( preg_match_all( '/./us', $text, $characters ) ) {
return implode( '', array_slice( $characters[0], 0, 80 ) );
}
return substr( $text, 0, 80 );
}
/**
* Clamp an integer setting.
*
* @param array $input Input settings.
* @param string $key Setting key.
* @param int $min Minimum.
* @param int $max Maximum.
* @param int $default Default.
* @return int
*/
private function clamp_int( $input, $key, $min, $max, $default ) {
if ( ! isset( $input[ $key ] ) || ! is_numeric( $input[ $key ] ) ) {
return $default;
}
return max( $min, min( $max, (int) $input[ $key ] ) );
}
/**
* Clamp a floating-point setting.
*
* @param array $input Input settings.
* @param string $key Setting key.
* @param float $min Minimum.
* @param float $max Maximum.
* @param float $default Default.
* @return float
*/
private function clamp_float( $input, $key, $min, $max, $default ) {
if ( ! isset( $input[ $key ] ) || ! is_numeric( $input[ $key ] ) ) {
return (float) $default;
}
return max( (float) $min, min( (float) $max, (float) $input[ $key ] ) );
}
/**
* Add settings page.
*
* @return void
*/
public function add_settings_page() {
add_options_page(
__( 'AI Image & Video Disclosure Labels', 'ai-image-disclosure-labels' ),
__( 'AI Image & Video Labels', 'ai-image-disclosure-labels' ),
'manage_options',
'gdaiidl-settings',
array( $this, 'render_settings_page' )
);
}
/**
* Enqueue the small, scoped Media Library attachment UI assets.
*
* This method is also hooked to wp_enqueue_media so attachment-detail
* modals receive the same styling regardless of which admin screen opened
* the WordPress media frame.
*
* @return void
*/
public function enqueue_media_admin_assets() {
if ( ! is_admin() ) {
return;
}
wp_enqueue_style(
'gdaiidl-media-admin',
GDAIIDL_URL . 'assets/media-admin.css',
array(),
$this->asset_version( 'assets/media-admin.css' )
);
wp_enqueue_script(
'gdaiidl-media-admin',
GDAIIDL_URL . 'assets/media-admin.js',
array(),
$this->asset_version( 'assets/media-admin.js' ),
true
);
}
/**
* Build an asset version that also changes when a file changes inside a
* same-version test build. Official releases still retain the plugin version
* prefix, while the file timestamp prevents stale browser/admin caches.
*
* @param string $relative_path Path relative to the plugin directory.
* @return string
*/
private function asset_version( $relative_path ) {
$path = GDAIIDL_DIR . ltrim( (string) $relative_path, '/\\' );
$mtime = is_file( $path ) ? @filemtime( $path ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- asset can disappear during an atomic deploy; version falls back safely.
return false !== $mtime ? GDAIIDL_VERSION . '-' . (string) $mtime : GDAIIDL_VERSION;
}
/**
* Enqueue settings-page assets.
*
* @param string $hook_suffix Current admin hook.
* @return void
*/
public function enqueue_admin_assets( $hook_suffix ) {
/*
* The list view does not necessarily initialize the media modal, so load
* the small scoped assets explicitly there. Other screens are covered by
* the wp_enqueue_media action below whenever WordPress opens a media UI.
*/
if ( 'upload.php' === $hook_suffix || 'media-new.php' === $hook_suffix ) {
$this->enqueue_media_admin_assets();
}
if ( 'settings_page_gdaiidl-settings' !== $hook_suffix ) {
return;
}
wp_enqueue_media();
$admin_css_version = $this->asset_version( 'assets/admin.css' );
$admin_js_version = $this->asset_version( 'assets/admin.js' );
wp_enqueue_style(
'gdaiidl-admin',
GDAIIDL_URL . 'assets/admin.css',
array(),
$admin_css_version
);
wp_enqueue_script(
'gdaiidl-admin',
GDAIIDL_URL . 'assets/admin.js',
array( 'media-editor', 'wp-i18n' ),
$admin_js_version,
true
);
wp_set_script_translations( 'gdaiidl-admin', 'ai-image-disclosure-labels' );
$settings = $this->settings();
$custom_icon = $this->custom_icon_data();
wp_localize_script(
'gdaiidl-admin',
'gdaiidlAdminConfig',
array(
'fontStacks' => self::font_stacks(),
'presets' => $this->presets(),
'icons' => array(
'monogram' => $this->icon_svg_markup( 'monogram' ),
'sparkle' => $this->icon_svg_markup( 'sparkle' ),
'chip' => $this->icon_svg_markup( 'chip' ),
),
'customIcon' => $custom_icon,
'currentIconStyle' => isset( $settings['icon_style'] ) ? $settings['icon_style'] : 'monogram',
'allowedMimeTypes' => array( 'image/png', 'image/svg+xml' ),
)
);
}
/**
* Render settings page.
*
* @return void
*/
public function render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$s = $this->settings();
$custom_icon = $this->custom_icon_data();
?>
detected_cache_systems(); ?>
%1$s',
esc_html( $label ),
esc_attr( str_replace( '_', '-', $key ) ),
esc_attr( self::OPTION_KEY ),
esc_attr( $value )
);
}
/**
* Render a number input.
*
* @param string $key Key.
* @param string $label Label.
* @param int $value Value.
* @param int $min Minimum.
* @param int $max Maximum.
* @param string $unit Unit.
* @return void
*/
private function number_field( $key, $label, $value, $min, $max, $unit ) {
printf(
'',
esc_html( $label ),
esc_attr( str_replace( '_', '-', $key ) ),
esc_attr( self::OPTION_KEY ),
esc_attr( $key ),
(int) $value,
(int) $min,
(int) $max,
esc_html( $unit )
);
}
/**
* Build dynamic front-end or editor CSS.
*
* @param bool $editor Whether this is the editor preview.
* @return string
*/
private function dynamic_badge_css( $editor = false ) {
$s = $this->settings();
$background_color = sanitize_hex_color( isset( $s['background_color'] ) ? $s['background_color'] : '' );
$text_color = sanitize_hex_color( isset( $s['text_color'] ) ? $s['text_color'] : '' );
$border_color = sanitize_hex_color( isset( $s['border_color'] ) ? $s['border_color'] : '' );
$text_transform = isset( $s['text_transform'] ) && in_array( $s['text_transform'], array( 'none', 'uppercase' ), true ) ? $s['text_transform'] : 'none';
$background_color = $background_color ? $background_color : '#171717';
$text_color = $text_color ? $text_color : '#ffffff';
$border_color = $border_color ? $border_color : '#ffffff';
$alpha = round( max( 0, min( 100, (int) $s['background_opacity'] ) ) / 100, 2 );
$bg = $this->hex_to_rgba( $background_color, $alpha );
$top = 0 === strpos( $s['position'], 'top-' );
$left = false !== strpos( $s['position'], '-left' );
$top_value = $top ? (int) $s['offset_vertical'] . 'px' : 'auto';
$bottom_value = $top ? 'auto' : (int) $s['offset_vertical'] . 'px';
$left_value = $left ? (int) $s['offset_horizontal'] . 'px' : 'auto';
$right_value = $left ? 'auto' : (int) $s['offset_horizontal'] . 'px';
$font_stacks = self::font_stacks();
$font_mode = isset( $s['font_family_mode'] ) ? $s['font_family_mode'] : 'inherit';
$font_family = '';
if ( 'custom' === $font_mode && ! empty( $s['font_family_custom'] ) ) {
$font_family = preg_replace( "/[^A-Za-z0-9,\-' ]/", '', $s['font_family_custom'] );
} elseif ( isset( $font_stacks[ $font_mode ] ) ) {
$font_family = $font_stacks[ $font_mode ];
}
$tooltip_top = $top ? 'calc(100% + 6px)' : 'auto';
$tooltip_bottom = $top ? 'auto' : 'calc(100% + 6px)';
$tooltip_left = $left ? '0' : 'auto';
$tooltip_right = $left ? 'auto' : '0';
$icon_size_value = isset( $s['icon_size_value'] ) ? (float) $s['icon_size_value'] : 16;
$icon_size_unit = isset( $s['icon_size_unit'] ) && 'percent' === $s['icon_size_unit'] ? 'percent' : 'px';
$icon_size_css = 'percent' === $icon_size_unit
? rtrim( rtrim( number_format( $icon_size_value, 2, '.', '' ), '0' ), '.' ) . 'cqw'
: rtrim( rtrim( number_format( $icon_size_value, 2, '.', '' ), '0' ), '.' ) . 'px';
$target = $editor
? '.gd-ai-editor-preview, .gd-ai-editor-video-preview'
: 'html body .gd-ai-image-frame, html body .gd-ai-featured-theme-fallback, html body .gd-ai-video-disclosure-row';
$video_separate = ! empty( $s['video_separate_design'] );
$css = sprintf(
'%1$s{--gd-ai-label-bg:%2$s;--gd-ai-label-color:%3$s;--gd-ai-label-border-width:%4$dpx;--gd-ai-label-border-color:%5$s;--gd-ai-label-radius:%6$dpx;--gd-ai-label-font-size:%7$dpx;--gd-ai-label-font-weight:%8$d;--gd-ai-label-padding-y:%9$dpx;--gd-ai-label-padding-x:%10$dpx;--gd-ai-label-transform:%11$s;--gd-ai-label-top:%12$s;--gd-ai-label-right:%13$s;--gd-ai-label-bottom:%14$s;--gd-ai-label-left:%15$s;--gd-ai-label-icon-size:%16$s;--gd-ai-label-icon-padding-y:%17$dpx;--gd-ai-label-icon-padding-x:%18$dpx;--gd-ai-tooltip-top:%19$s;--gd-ai-tooltip-right:%20$s;--gd-ai-tooltip-bottom:%21$s;--gd-ai-tooltip-left:%22$s;%23$s}',
$target,
$bg,
$text_color,
max( 0, min( 5, (int) $s['border_width'] ) ),
$border_color,
max( 0, min( 999, (int) $s['border_radius'] ) ),
max( 8, min( 24, (int) $s['font_size'] ) ),
in_array( (int) $s['font_weight'], array( 400, 500, 600, 700 ), true ) ? (int) $s['font_weight'] : 600,
max( 0, min( 20, (int) $s['padding_vertical'] ) ),
max( 0, min( 30, (int) $s['padding_horizontal'] ) ),
$text_transform,
$top_value,
$right_value,
$bottom_value,
$left_value,
$icon_size_css,
max( 2, (int) $s['padding_vertical'] ),
max( 3, (int) $s['padding_horizontal'] - 1 ),
$tooltip_top,
$tooltip_right,
$tooltip_bottom,
$tooltip_left,
'' !== $font_family ? '--gd-ai-label-font-family:' . $font_family . ';' : ''
);
if ( $video_separate ) {
$video_bg_color = sanitize_hex_color( $s['video_background_color'] );
$video_text_color = sanitize_hex_color( $s['video_text_color'] );
$video_border_color = sanitize_hex_color( $s['video_border_color'] );
$video_alpha = round( max( 0, min( 100, (int) $s['video_background_opacity'] ) ) / 100, 2 );
$video_target = $editor ? '.gd-ai-editor-video-preview' : 'html body .gd-ai-video-disclosure-row';
$css .= sprintf(
'%1$s{--gd-ai-label-bg:%2$s;--gd-ai-label-color:%3$s;--gd-ai-label-border-width:%4$dpx;--gd-ai-label-border-color:%5$s;--gd-ai-label-radius:%6$dpx;--gd-ai-label-font-size:%7$dpx;--gd-ai-label-font-weight:%8$d;--gd-ai-label-padding-y:%9$dpx;--gd-ai-label-padding-x:%10$dpx;--gd-ai-label-transform:%11$s;}',
$video_target,
$this->hex_to_rgba( $video_bg_color ? $video_bg_color : '#171717', $video_alpha ),
$video_text_color ? $video_text_color : '#ffffff',
max( 0, min( 5, (int) $s['video_border_width'] ) ),
$video_border_color ? $video_border_color : '#ffffff',
max( 0, min( 999, (int) $s['video_border_radius'] ) ),
max( 8, min( 24, (int) $s['video_font_size'] ) ),
in_array( (int) $s['video_font_weight'], array( 400, 500, 600, 700 ), true ) ? (int) $s['video_font_weight'] : 600,
max( 0, min( 20, (int) $s['video_padding_vertical'] ) ),
max( 0, min( 30, (int) $s['video_padding_horizontal'] ) ),
in_array( $s['video_text_transform'], array( 'none', 'uppercase' ), true ) ? $s['video_text_transform'] : 'none'
);
}
if ( ! $editor ) {
$minimum_width = isset( $s['minimum_image_width'] ) ? max( 0, (int) $s['minimum_image_width'] ) : 0;
$text_width = isset( $s['minimum_text_width'] ) ? max( 0, (int) $s['minimum_text_width'] ) : 0;
$mode = isset( $s['small_image_mode'] ) && 'hide' === $s['small_image_mode'] ? 'hide' : 'icon';
$label_selector = '.gd-ai-image-label';
/*
* Flash prevention. Server-rendered labels start without a size
* class, which used to make the full text visible for a moment on
* small images inside wrappers that are not query containers,
* until the JavaScript fallback had measured them.
*
* With active width thresholds every label starts hidden. Two
* reveal paths exist:
*
* 1. The @container rule below matches any frame that is a query
* container, so those labels are revealed before first paint
* and the existing container queries decide text vs. symbol.
* 2. Frames without a query container stay hidden until the
* JavaScript fallback has measured the image and applied a
* size class, so the label appears in its correct form
* immediately instead of flashing text first.
*
* The JavaScript file is always enqueued when these thresholds
* are active, so path 2 is guaranteed to run. With thresholds
* disabled no hiding rule is emitted at all.
*/
if ( 0 < $minimum_width || 0 < $text_width ) {
$css .= sprintf( '%s{visibility:hidden;}', $label_selector );
/* Video disclosures are flow content, not image overlays; image-width thresholds do not apply. */
$css .= '.gd-ai-video-disclosure-row > .gd-ai-image-label{visibility:visible;}';
$css .= sprintf(
'@container gd-ai-label-frame (min-width:0px){%s{visibility:visible;}}',
$label_selector
);
$css .= sprintf(
'%1$s.gd-ai-label-size-text,%1$s.gd-ai-label-size-icon{visibility:visible;}',
$label_selector
);
}
if ( 0 < $minimum_width ) {
$css .= sprintf(
'@container gd-ai-label-frame (max-width:%1$dpx){%2$s{display:none;}}',
max( 0, $minimum_width - 1 ),
$label_selector
);
}
if ( $text_width > $minimum_width ) {
$range_start = $minimum_width;
$range_end = max( $range_start, $text_width - 1 );
if ( 'icon' === $mode ) {
$css .= sprintf(
'@container gd-ai-label-frame (min-width:%1$dpx) and (max-width:%2$dpx){%3$s{display:inline-flex;padding:var(--gd-ai-label-icon-padding-y,3px) var(--gd-ai-label-icon-padding-x,4px);min-width:calc(var(--gd-ai-label-icon-size,16px) + (var(--gd-ai-label-icon-padding-x,4px) * 2));}%3$s > .gd-ai-label-icon{display:block;}%3$s > .gd-ai-label-trigger{display:inline-flex;}%3$s .gd-ai-label-trigger .gd-ai-label-icon{display:block;}%3$s > .gd-ai-label-text{display:none;}}',
$range_start,
$range_end,
$label_selector
);
} else {
$css .= sprintf(
'@container gd-ai-label-frame (min-width:%1$dpx) and (max-width:%2$dpx){%3$s{display:none;}}',
$range_start,
$range_end,
$label_selector
);
}
}
}
return $css;
}
/**
* Convert a hex color to rgba().
*
* @param string $hex Hex color.
* @param float $alpha Alpha value.
* @return string
*/
private function hex_to_rgba( $hex, $alpha ) {
$hex = ltrim( (string) $hex, '#' );
if ( 3 === strlen( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( 6 !== strlen( $hex ) ) {
$hex = '171717';
}
return sprintf(
'rgba(%d,%d,%d,%s)',
hexdec( substr( $hex, 0, 2 ) ),
hexdec( substr( $hex, 2, 2 ) ),
hexdec( substr( $hex, 4, 2 ) ),
rtrim( rtrim( number_format( $alpha, 2, '.', '' ), '0' ), '.' )
);
}
/**
* React to settings changes.
*
* @param mixed $old_value Previous value.
* @param mixed $value New value.
* @param string $option Option name.
* @return void
*/
public function handle_settings_update( $old_value, $value, $option ) {
unset( $old_value, $value, $option );
self::$settings_cache = null;
if ( function_exists( 'wp_set_option_autoload' ) ) {
wp_set_option_autoload( self::OPTION_KEY, true );
}
$this->maybe_purge_page_cache();
}
/**
* Keep the small frontend fallback available when WP Rocket delays scripts.
*
* @param array $exclusions Existing exclusions.
* @return array
*/
public function exclude_frontend_script_from_delay( $exclusions ) {
$exclusions = is_array( $exclusions ) ? $exclusions : array();
$script_path = plugin_basename( GDAIIDL_DIR . 'assets/frontend.js' );
/*
* WP Rocket matches Delay JavaScript exclusions against script URLs,
* not WordPress enqueue handles. Build the URL fragment from the actual
* installed plugin directory so renamed development folders and the
* canonical WordPress.org folder are both handled correctly.
*/
$exclusions[] = '/' . ltrim( wp_normalize_path( $script_path ), '/' );
return array_values( array_unique( $exclusions ) );
}
/**
* Add settings shortcut.
*
* @param array $links Plugin action links.
* @return array
*/
public function add_settings_link( $links ) {
array_unshift(
$links,
'' . esc_html__( 'Settings', 'ai-image-disclosure-labels' ) . ''
);
return $links;
}
/**
* Add donation and support links below the plugin description.
*
* @param array $plugin_meta Existing plugin row metadata.
* @param string $plugin_file Plugin basename.
* @return array
*/
public function add_plugin_row_meta( $plugin_meta, $plugin_file ) {
if ( plugin_basename( GDAIIDL_FILE ) !== $plugin_file ) {
return $plugin_meta;
}
$plugin_meta[] = '' . esc_html__( 'Donate', 'ai-image-disclosure-labels' ) . '';
$plugin_meta[] = '' . esc_html__( 'Support', 'ai-image-disclosure-labels' ) . '';
return $plugin_meta;
}
}