'1', 'b' => '0']. * * @param mixed $value The stored or posted value. * * @return string[] */ public static function get_checked_keys( mixed $value ): array { if ( ! is_array( $value ) ) { return []; } $is_flags = ! empty( array_filter( array_keys( $value ), 'is_string' ) ); $keys = []; foreach ( $value as $key => $item ) { $candidate = $is_flags ? $key : $item; if ( $is_flags && ! in_array( is_string( $item ) ? strtolower( $item ) : $item, [ true, 1, '1', 'true', 'on' ], true ) ) { continue; } if ( ( is_string( $candidate ) || is_int( $candidate ) ) && '' !== $candidate ) { $keys[] = (string) $candidate; } } return array_values( array_unique( $keys ) ); } /** * An entity's custom code (Pro feature): empty on Free, kses'd unless its author may post unfiltered HTML. * * @param Entity $entity * @param string $meta_key 'custom_ad_code' or 'custom_placement_code'. * * @return string */ public static function get_custom_code( Entity $entity, string $meta_key ): string { if ( ! self::is_pro_active() ) { return ''; } $code = (string) $entity->get_meta( $meta_key, '' ); if ( $code !== '' && ! self::can_author_unfiltered_html( $entity->get_post() ) ) { $code = wp_kses_post( $code ); } return $code; } /** * Checks if the author of a given post can use unfiltered_html. * * @param $post * * @return bool */ public static function can_author_unfiltered_html( $post ): bool { if ( defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML ) { return false; } // Virtual entities have no post and therefore no author. $author_id = 0; if ( is_object( $post ) ) { $author_id = (int) ( $post->post_author ?? 0 ); } elseif ( is_numeric( $post ) && (int) $post > 0 ) { $author_id = (int) get_post_field( 'post_author', (int) $post ); } return (bool) apply_filters( 'adpresso_user_can_unfiltered_html', author_can( $post, 'unfiltered_html' ), $author_id ); } /** * Whether a user is trusted with unfiltered HTML - WordPress' own capability, extended by Pro (multisite role list). * * @param int $user_id The user. * * @return bool */ public static function user_can_unfiltered_html( int $user_id ): bool { if ( defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML ) { return false; } return (bool) apply_filters( 'adpresso_user_can_unfiltered_html', user_can( $user_id, 'unfiltered_html' ), $user_id ); } /** * Whether Pro code actually booted this request. Default gate for Pro-only behaviour in Free code. * Only reliable after plugins_loaded; license state alone (can_use_premium_code()) doesn't prove pro/ runs. * * @return bool */ public static function is_pro_active(): bool { return did_action( 'adpresso_pro_booted' ) > 0; } /** * Pre-boot only (activation, license activation): whether a Pro class can be referenced before Pro booted. * After plugins_loaded use is_pro_active(). * * @param string $class_name Fully-qualified Pro class name, e.g. Ad_Rendered_Tracker::class. * * @return bool */ public static function pro_class_available( string $class_name ): bool { // pro-gate: pre-boot return adpresso_fs()->is_premium() && adpresso_fs()->can_use_premium_code() && class_exists( $class_name ); } /** * Whether the fast-track.php SHORTINIT tracking endpoint should be used * instead of the adpresso/v1/track REST route. * * @return bool */ public static function is_fast_tracking_enabled(): bool { return ( defined( 'ADPRESSO_FAST_TRACKING' ) && ADPRESSO_FAST_TRACKING ) || apply_filters( 'adpresso_enable_fast_tracking', false ); } /** * Checks if the current request is a REST request. * * @return bool */ public static function is_rest_request(): bool { $request = Params::server()->getString( 'REQUEST_URI' ); if ( empty( $request ) ) { return false; } // Plain permalinks address REST as /?rest_route=/wp/v2/... return str_contains( $request, trailingslashit( rest_get_url_prefix() ) ) || str_contains( $request, 'rest_route=' ); } /** * Get an effective setting that is either the ad-specific setting or the global setting. * * @param Entity $entity Entity-Object with get_meta() * @param string $meta_key Key in Ad-Meta, e.g. 'ad_label' * @param string|null $setting_key Key in Settings-Store, e.g. 'label_override'. * If null, we use $meta_key. * @param string|null $group Optional Group-Key in Setting, e.g. 'add_link_attributes' * @param string|null $group_key Key in the group, e.g. 'add_sponsored'. * If null and $group is set, use $setting_key. * * @return mixed */ public static function get_effective_setting( Entity $entity, string $meta_key, ?string $setting_key = null, ?string $group = null, ?string $group_key = null ): mixed { // Fallback if ( $setting_key === null ) { $setting_key = $meta_key; } if ( $group !== null && $group_key === null ) { $group_key = $setting_key; } // Helper for 'has useful value' $has_meaningful_value = static function( $value ): bool { return $value !== null && $value !== '' && $value !== 'default'; }; // Check entity meta $meta_value = $entity->get_meta( $meta_key ); if ( $has_meaningful_value( $meta_value ) ) { return $meta_value; } // check settings group (e.g. Settings::get_setting('add_link_attributes')['add_sponsored']) if ( $group !== null ) { $settings_group = Settings::get_setting( $group ); if ( is_array( $settings_group ) && array_key_exists( $group_key, $settings_group ) ) { $group_value = $settings_group[ $group_key ]; if ( $has_meaningful_value( $group_value ) ) { return $group_value; } } } // global setting as a fallback. return Settings::get_setting( $setting_key ); } /** * Returns the image sizes. * * @return array */ public static function get_image_sizes(): array { return [ 'thumbnail' => __( 'Thumbnail', 'adpresso' ), 'medium' => __( 'Medium', 'adpresso' ), 'large' => __( 'Large', 'adpresso' ), 'full' => __( 'Full Size', 'adpresso' ), ]; } /** * Get the field registry based on the post-type. * * @param Entity $entity * * @return AbstractFieldRegistry|null */ public static function get_field_registry( Entity $entity ): ?AbstractFieldRegistry { $post = $entity->get_post(); if ( $entity instanceof Ad || ( $post && $post->post_type === Constants::POST_TYPE_AD ) ) { return AdFieldRegistry::get(); } elseif ( $entity instanceof Placement || ( $post && $post->post_type === Constants::POST_TYPE_PLACEMENT ) ) { return PlacementFieldRegistry::get(); } elseif ( $entity instanceof Group || ( $post && $post->post_type === Constants::POST_TYPE_GROUP ) ) { return GroupFieldRegistry::get(); } return null; } /** * Creates an AdPresso entity object based on the WP_Post object or a Post-ID. * * This is the central factory method, to load entities from posts. * * @param int|WP_Post $post The post-ID or the WP_Post object. * * @return Entity|null The entity object or null in case of error. */ public static function get_entity_from_post( int|WP_Post $post, $process_data = true ): ?Entity { if ( is_int( $post ) ) { $post = get_post( $post ); } if ( ! $post instanceof WP_Post ) { return null; } return match ( $post->post_type ) { Constants::POST_TYPE_AD => adpresso_get_ad( $post->ID, $process_data ), Constants::POST_TYPE_GROUP => adpresso_get_group_repository()->get( $post->ID, $process_data ), Constants::POST_TYPE_PLACEMENT => adpresso_get_placement( $post->ID, $process_data ), default => null, }; } /** * Creates a random ID of a given length. * Used to generate unique IDs for virtual placements. * * @param int $chars * * @return string */ public static function create_random_id( int $chars ): string { $length = ( $chars > 0 ) ? $chars : 16; try { $byteLength = (int) ceil( $length / 2 ); return bin2hex( random_bytes( $byteLength ) ); } catch ( Exception ) { $fallbackId = hash( 'sha256', uniqid( (string) wp_rand(), true ) ); return substr( $fallbackId, 0, $length ); } } /** * Post statuses the admin list counts: everything except trash (and auto-draft). */ public const LISTED_POST_STATUSES = [ 'publish', 'draft', 'pending', 'private', 'future' ]; /** * Ad/Group/Placement item counts for the admin list table tabs - total excludes trash. * * @return array */ public static function get_entity_status_counts(): array { $post_types = [ 'ads' => Constants::POST_TYPE_AD, 'groups' => Constants::POST_TYPE_GROUP, 'placements' => Constants::POST_TYPE_PLACEMENT, ]; $counts = []; foreach ( $post_types as $key => $post_type ) { $post_type_counts = wp_count_posts( $post_type ); $total = 0; foreach ( self::LISTED_POST_STATUSES as $status ) { $total += (int) ( $post_type_counts->$status ?? 0 ); } $counts[ $key ] = [ 'total' => $total, 'unpublished' => $total - (int) ( $post_type_counts->publish ?? 0 ), 'trash' => (int) ( $post_type_counts->trash ?? 0 ), ]; } return $counts; } /** * Raw code is only kept for users with unfiltered_html; everyone else gets wp_kses_post(). */ public static function sanitize_custom_code( string $value ): string { return self::can_user_unfiltered_html() ? wp_kses_no_null( $value ) : wp_kses_post( $value ); } /** * @return string 'ads', 'groups', 'placements' or '' for foreign post types. */ public static function get_entity_type_slug( string $post_type ): string { return match ( $post_type ) { Constants::POST_TYPE_AD => 'ads', Constants::POST_TYPE_GROUP => 'groups', Constants::POST_TYPE_PLACEMENT => 'placements', default => '', }; } /** * Modal edit URL for any of our entity posts, '' for foreign post types. */ public static function get_post_edit_url( int $post_id ): string { $slug = self::get_entity_type_slug( (string) get_post_type( $post_id ) ); return $slug ? self::get_entity_edit_url( $slug, $post_id, $slug ) : ''; } /** * URL of the settings page, optionally on one tab. * * @param string $tab Settings tab ID. * * @return string */ public static function get_settings_url( string $tab = '' ): string { $args = [ 'page' => Constants::SETTINGS_SLUG ]; if ( '' !== $tab ) { $args['tab'] = $tab; } return add_query_arg( $args, admin_url( 'admin.php' ) ); } /** * Get the modal entity URL. * * @param string $list_slug * @param int $entity_id * @param string $tab * * @return string */ public static function get_entity_edit_url( string $list_slug, int $entity_id, string $tab = '' ): string { $list_table_page = 'adpresso-list-tables'; $base = admin_url( 'admin.php' ); $args = [ 'page' => $list_table_page, 'action' => 'edit', 'entity_type' => $list_slug, 'entity_id' => $entity_id, ]; if ( $tab !== '' ) { $args['tab'] = $tab; } elseif ( Params::get()->getKey( 'page' ) === $list_table_page ) { // Keep tab if possible. $current_tab = Params::get()->getKey( 'tab' ); if ( $current_tab !== '' ) { $args['tab'] = $current_tab; } } else { // If we are not on the list table. // Default: Set tab to the specific list-slug (e.g. 'ads'). $args['tab'] = $list_slug; } return add_query_arg( $args, $base ); } /** * Checks if the current admin screen belongs to AdPresso. * * @return bool */ public static function is_adpresso_admin_screen(): bool { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); if ( ! $screen ) { return false; } // CPT list tables and edit screens. $adpresso_post_types = [ Constants::POST_TYPE_AD, Constants::POST_TYPE_GROUP, Constants::POST_TYPE_PLACEMENT, ]; if ( in_array( $screen->post_type, $adpresso_post_types, true ) ) { return true; } // Custom admin pages (like Settings, Tools, or list table). if ( str_contains( $screen->id, 'adpresso' ) ) { return true; } return false; } /** * Format a number into a localized short form (k, M, B). * * Examples (de_DE with precision = 1): * 950 -> 950 * 1200 -> 1,2k * 3000 -> 3,0k * 3500 -> 3,5k * 15000 -> 15,0k * 1200000 -> 1,2M * * @param float|int|string $number * @param int $precision * * @return string */ public static function format_number_short_localized( float|int|string $number, int $precision = 1 ): string { if ( ! \is_numeric( $number ) ) { return '0'; } $number = (float) $number; $abs = \abs( $number ); $sign = $number < 0 ? '-' : ''; if ( $abs >= 1000000000 ) { $value = $abs / 1000000000; $suffix = 'B'; } elseif ( $abs >= 1000000 ) { $value = $abs / 1000000; $suffix = 'M'; } elseif ( $abs >= 1000 ) { $value = $abs / 1000; $suffix = 'k'; if ( $abs >= 100000 ) { $precision = 0; } } else { // No decimal places under 1000, WP localises without suffix. return $sign . \number_format_i18n( $abs, 0 ); } // number_format_i18n is using automatically the WP language settings for comma, points. $formatted = \number_format_i18n( $value, $precision ); return $sign . $formatted . $suffix; } /** * Generates a manual link to wpadpresso.com * * @param string $url * @param string $text * * @return string */ public static function create_manual_link( string $url, string $text = '' ): string { $text = $text ?: __( 'Learn more.', 'adpresso' ); return sprintf( '%s', esc_url( $url ), esc_html( $text ), '' //esc_html( $text ) ); } /** * Round smart and show one decimal place when the value is lower 10. * * @param float $number * * @return string */ public static function round_smart_i18n( float $number ): string { $rounded = round( $number, 1 ); if ( abs( $rounded ) >= 10 ) { return number_format_i18n( (int) round( $rounded, 0 ), 0 ); } return number_format_i18n( $rounded, 1 ); } /** * Formats a given date string or timestamp to the WordPress site's default format. * * @param int|string $date The date string (e.g. 'Y-m-d H:i:s') or timestamp. * @param bool $include_time Whether to include the time format. * * @return string */ public static function format_date_localized( int|string $date, bool $include_time = false ): string { if ( empty( $date ) ) { return ''; } $timestamp = is_numeric( $date ) ? (int) $date : strtotime( $date ); $format = get_option( 'date_format' ); if ( $include_time ) { $format .= ' ' . get_option( 'time_format' ); } return wp_date( $format, $timestamp ); } /** * Generates a secure frontend prefix. * * @param int $len * * @return string */ public static function build_frontend_prefix( int $len = 5 ): string { $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( str_starts_with( $host, 'www.' ) ) { $host = substr( $host, 4 ); } // Define domain label with some exclusions for 2-part suffixes. $labels = $host ? explode( '.', $host ) : []; $two_part_suffixes = [ 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'com.au', 'net.au', 'com.br', 'com.ar', 'co.jp', 'co.kr', 'co.nz', 'co.in', 'com.sg', 'com.tr', 'com.mx', 'com.cn' ]; $baseLabel = 'site'; if ( $labels ) { $last2 = implode( '.', array_slice( $labels, -2 ) ); if ( count( $labels ) >= 3 && in_array( $last2, $two_part_suffixes, true ) ) { $baseLabel = $labels[ count( $labels ) - 3 ]; } else { $baseLabel = $labels[ max( 0, count( $labels ) - 2 ) ]; } } // Only allow a-z and 0-9 and remove multiple '-'. $baseLabel = preg_replace( '/[^a-z0-9-]+/', '-', $baseLabel ); $baseLabel = trim( preg_replace( '/-+/', '-', $baseLabel ), '-' ); if ( $baseLabel === '' ) { $baseLabel = 'site'; } // Create tokens. $parts = array_values( array_filter( explode( '-', $baseLabel ) ) ); $code = ''; foreach ( $parts as $p ) { if ( strlen( $code ) < $len && $p !== '' ) { $code .= $p[0]; } } // If it's too short, fill with more letters. if ( strlen( $code ) < $len ) { $fillFrom = $parts[0] ?? $baseLabel; $code .= substr( $fillFrom, 1, max( 0, $len - strlen( $code ) ) ); } // Force to start with a letter. if ( $code === '' || ! ctype_alpha( $code[0] ) ) { $code = 'x' . $code; } $code = substr( $code, 0, $len ); if ( strlen( $code ) < $len ) { $code = str_pad( $code, $len, 'x' ); } return $code . '-'; } /** * Tries to load the global fallback entity. * * @param string $fallback_key * @param int $current_item_id The ID of the item that couldn't be loaded. * * @return Entity|null */ public static function get_global_fallback_entity( string $fallback_key = 'general_fallback', int $current_item_id = 0 ): ?Entity { if ( ! ModuleManager::is_active( 'fallback' ) ) { return null; } $fallback_id = absint( Settings::get_setting( $fallback_key, 0 ) ); if ( $fallback_id === 0 ) { return null; } if ( $current_item_id > 0 && $fallback_id === $current_item_id ) { NotificationManager::get()->register( 'adpresso-fallback-misconfiguration', sprintf( /* translators: %s is the title of the ad or group */ __( 'The fallback item you registered for %s is the same as the item that could not be loaded.', 'adpresso' ), esc_html( get_the_title( $current_item_id ) ) ), [ 'location' => 'dashboard', 'context' => 'adpresso-conditional-root', ] ); return null; } $entity = Util::get_entity_from_post( $fallback_id ); if ( ! $entity || ! $entity->is_backend_valid ) { return null; } return $entity; } /** * Resolves a placement's effective fallback entity * * @param Placement $placement * * @return Entity|null */ public static function resolve_placement_fallback_entity( Placement $placement ): ?Entity { return self::get_placement_privacy_fallback_entity( $placement ) ?? self::get_placement_general_fallback_entity( $placement ); } /** * The placement's privacy fallback (shown when consent is rejected), if the Privacy module is active. * * @param Placement $placement * * @return Entity|null */ public static function get_placement_privacy_fallback_entity( Placement $placement ): ?Entity { if ( ! apply_filters( 'adpresso_privacy_module_active', ModuleManager::is_active( 'privacy' ) ) ) { return null; } return self::get_valid_entity_from_meta( $placement, 'privacy_fallback' ); } /** * The placement's general fallback (shown when the assigned item produces nothing). * * @param Placement $placement * * @return Entity|null */ public static function get_placement_general_fallback_entity( Placement $placement ): ?Entity { return self::get_valid_entity_from_meta( $placement, 'general_fallback' ); } /** * The general fallback shown when the item produces nothing: the placement's own, else the global one. * * @param Placement $placement The placement. * @param bool $with_global Whether to fall through to the global general fallback. * * @return Entity|null */ public static function get_effective_general_fallback_entity( Placement $placement, bool $with_global = true ): ?Entity { $fallback = self::get_placement_general_fallback_entity( $placement ); return $fallback ?? ( $with_global ? self::get_global_fallback_entity( 'general_fallback' ) : null ); } /** * @param Placement $placement * @param string $meta_key * * @return Entity|null */ private static function get_valid_entity_from_meta( Placement $placement, string $meta_key ): ?Entity { $entity_id = (int) $placement->get_meta( $meta_key, 0 ); $entity = $entity_id > 0 ? self::get_entity_from_post( $entity_id ) : null; return ( $entity && $entity->is_backend_valid ) ? $entity : null; } /** * JS config of a fallback entity. Carries its own browser-side conditions, like the primary item does. * * @param Entity $entity * @param Placement|null $placement Its placement, when the placement's own custom code goes out with it. * @param bool $general General fallback: shown regardless of consent. * * @return array */ public static function get_fallback_frontend_config( Entity $entity, ?Placement $placement = null, bool $general = false ): array { $config = $entity->frontend_config_data; $config['type_slug'] = ( $entity instanceof Group ) ? 'group' : 'ad'; if ( ConditionHelper::tree_contains_frontend_conditions( $entity->conditions_json ) ) { $config['item_conditions_json'] = $entity->get_frontend_conditions_json(); } return $general ? ConsentPolicy::add_general_fallback_js_flag( $config ) : ConsentPolicy::add_js_flag( $config, $entity, $placement ); } }