Source: Helpers.php

<?php

namespace Classifai;

use Classifai\Features\Classification;
use Classifai\Providers\Provider;
use Classifai\Admin\UserProfile;
use Classifai\Providers\Watson\NLU;
use Classifai\Providers\OpenAI\Embeddings;
use Classifai\Services\Service;
use Classifai\Services\ServicesManager;
use WP_Error;

/**
 * Miscellaneous Helper functions to access different parts of the
 * ClassifAI plugin.
 */

/**
 * Returns the ClassifAI plugin's singleton instance
 *
 * @return Plugin
 */
function get_plugin() {
	return Plugin::get_instance();
}

/**
 * Overwrites the ClassifAI plugin's stored settings. Expected format is,
 *
 * [
 *     'post_types' => [ <list of post type names> ]
 *     'features' => [
 *         <feature_name> => <bool>
 *         <feature_threshold> => <int>
 *     ],
 *     'credentials' => [
 *         'watson_username' => <string>
 *         'watson_password' => <string>
 *     ]
 * ]
 *
 * @param array $settings The settings we're saving.
 */
function set_plugin_settings( array $settings ) {
	update_option( 'classifai_settings', $settings );
}

/**
 * Resets the plugin to factory defaults, keeping licensing information only.
 */
function reset_plugin_settings() {

	$options = get_option( 'classifai_settings' );
	if ( $options && isset( $options['registration'] ) ) {
		// This is a legacy option set, so let's update it to the new format.
		$new_settings = [
			'valid_license' => $options['valid_license'],
			'email'         => isset( $options['registration']['email'] ) ? $options['registration']['email'] : '',
			'license_key'   => isset( $options['registration']['license_key'] ) ? $options['registration']['license_key'] : '',
		];
		update_option( 'classifai_settings', $new_settings );
	}

	$services = get_plugin()->services;
	if ( ! isset( $services['service_manager'] ) || ! $services['service_manager']->service_classes ) {
		return;
	}

	$service_classes = $services['service_manager']->service_classes;
	foreach ( $service_classes as $service_class ) {
		if ( ! $service_class instanceof Service || empty( $service_class->provider_classes ) ) {
			continue;
		}

		foreach ( $service_class->provider_classes as $provider_class ) {
			if ( ! $provider_class instanceof Provider || ! method_exists( $provider_class, 'reset_settings' ) ) {
				continue;
			}

			$provider_class->reset_settings();
		}
	}
}

/**
 * Get post types we want to show in the language processing settings
 *
 * @since 1.6.0
 *
 * @return array
 */
function get_post_types_for_language_settings(): array {
	$post_types = get_post_types( [ 'public' => true ], 'objects' );
	$post_types = array_filter( $post_types, 'is_post_type_viewable' );

	// Remove the attachment post type
	unset( $post_types['attachment'] );

	/**
	 * Filter post types shown in language processing settings.
	 *
	 * @since 1.6.0
	 * @hook classifai_language_settings_post_types
	 *
	 * @param {array} $post_types Array of post types to show in language processing settings.
	 *
	 * @return {array} Array of post types.
	 */
	return apply_filters( 'classifai_language_settings_post_types', $post_types );
}

/**
 * Get post types we want to show in the language processing settings
 *
 * @since 1.7.1
 *
 * @return array
 */
function get_post_statuses_for_language_settings(): array {
	$post_statuses = get_all_post_statuses();

	/**
	 * Filter post statuses shown in language processing settings.
	 *
	 * @since 1.7.1
	 * @hook classifai_language_settings_post_statuses
	 *
	 * @param {array} $post_statuses Array of post statuses to show in language processing settings.
	 *
	 * @return {array} Array of post statuses.
	 */
	return apply_filters( 'classifai_language_settings_post_statuses', $post_statuses );
}

/**
 * Provides the max filesize for the Computer Vision service.
 *
 * @since 1.4.0
 *
 * @return int
 */
function computer_vision_max_filesize(): int {
	/**
	 * Filters the Computer Vision maximum allowed filesize.
	 *
	 * @since 1.5.0
	 * @hook classifai_computer_vision_max_filesize
	 *
	 * @param {int} file_size The maximum allowed filesize for Computer Vision in bytes. Default `4 * MB_IN_BYTES`.
	 *
	 * @return {int} Filtered filesize in bytes.
	 */
	return apply_filters( 'classifai_computer_vision_max_filesize', 4 * MB_IN_BYTES ); // 4MB default.
}

/**
 * Callback for sorting images by width plus height, descending.
 *
 * @since 1.5.0
 *
 * @param array $size_1 Associative array containing width and height values.
 * @param array $size_2 Associative array containing width and height values.
 * @return int Returns -1 if $size_1 is larger, 1 if $size_2 is larger, and 0 if they are equal.
 */
function sort_images_by_size_cb( array $size_1, array $size_2 ): int {
	$size_1_total = $size_1['width'] + $size_1['height'];
	$size_2_total = $size_2['width'] + $size_2['height'];

	if ( $size_1_total === $size_2_total ) {
		return 0;
	}

	return $size_1_total > $size_2_total ? -1 : 1;
}

/**
 * Retrieves the URL of the largest version of an attachment image lower than a specified max size.
 *
 * @since 1.4.0
 *
 * @param string $full_image The path to the full-sized image source file.
 * @param string $full_url   The URL of the full-sized image.
 * @param array  $sizes      Intermediate size data from attachment meta.
 * @param int    $max        The maximum acceptable size.
 * @return string|null The image URL, or null if no acceptable image found.
 */
function get_largest_acceptable_image_url( string $full_image, string $full_url, array $sizes, int $max = MB_IN_BYTES ) {
	$file_size = @filesize( $full_image ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
	if ( $file_size && $max >= $file_size ) {
		return $full_url;
	}

	usort( $sizes, __NAMESPACE__ . '\sort_images_by_size_cb' );

	foreach ( $sizes as $size ) {
		$sized_file = str_replace( basename( $full_image ), $size['file'], $full_image );
		$file_size  = @filesize( $sized_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged

		if ( $file_size && $max >= $file_size ) {
			return str_replace( basename( $full_url ), $size['file'], $full_url );
		}
	}

	return null;
}

/**
 * Retrieves the URL of the largest image that matches filesize and dimensions.
 *
 * This will check that the filesize of an image matches our requirements and
 * if so, will then check the dimensions match our requirements as well. If
 * neither match, will move on to the next largest image size.
 *
 * @param string $full_image The path to the full-sized image source file.
 * @param string $full_url   The URL of the full-sized image.
 * @param array  $metadata   Attachment metadata, including intermediate sizes.
 * @param array  $width      Array of minimimum and maximum width values. Default 0, 4200.
 * @param array  $height     Array of minimimum and maximum height values. Default 0, 4200.
 * @param int    $max_size   The maximum acceptable filesize. Default 1MB.
 * @return string|null The image URL, or null if no acceptable image found.
 */
function get_largest_size_and_dimensions_image_url( string $full_image, string $full_url, array $metadata, array $width = [ 0, 4200 ], array $height = [ 0, 4200 ], int $max_size = MB_IN_BYTES ) {
	// Check if the full size image meets our filesize and dimension requirements
	$file_size = @filesize( $full_image ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
	if (
		( $file_size && $max_size >= $file_size )
		&& ( $metadata['width'] >= $width[0] && $metadata['width'] <= $width[1] )
		&& ( $metadata['height'] >= $height[0] && $metadata['height'] <= $height[1] )
	) {
		return $full_url;
	}

	// If the full size doesn't match, run the same checks on our resized images
	if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
		usort( $metadata['sizes'], __NAMESPACE__ . '\sort_images_by_size_cb' );

		foreach ( $metadata['sizes'] as $size ) {
			$sized_file = str_replace( basename( $full_image ), $size['file'], $full_image );
			$file_size  = @filesize( $sized_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged

			if (
				( $file_size && $max_size >= $file_size )
				&& ( $size['width'] >= $width[0] && $size['width'] <= $width[1] )
				&& ( $size['height'] >= $height[0] && $size['height'] <= $height[1] )
			) {
				return str_replace( basename( $full_url ), $size['file'], $full_url );
			}
		}
	}

	return null;
}

/**
 * Allows returning modified image URL for a given attachment.
 *
 * @param int $post_id Post ID.
 * @return mixed
 */
function get_modified_image_source_url( int $post_id ) {
	/**
	 * Filter to modify image source URL in order to allow scanning images,
	 * stored on third party storages that cannot be used by
	 * helper function `get_largest_acceptable_image_url()` to determine `filesize()` locally.
	 *
	 * Default is null, return filtered string to allow classifying image on external source.
	 *
	 * @since 1.6.0
	 * @hook classifai_generate_image_alt_tags_source_url
	 *
	 * @param {mixed} $image_url New image path for given attachment ID.
	 * @param {int}   $post_id   The ID of the attachment to be used in classification.
	 *
	 * @return {mixed} NULL or filtered URl for given attachment id.
	 */
	return apply_filters( 'classifai_generate_image_alt_tags_source_url', null, $post_id );
}

/**
 * Check if attachment is PDF document.
 *
 * @param int|\WP_Post $post Post object for the attachment being viewed.
 * @return bool
 */
function attachment_is_pdf( $post ): bool {
	$mime_type          = get_post_mime_type( $post );
	$matched_extensions = explode( '|', array_search( $mime_type, wp_get_mime_types(), true ) );

	if ( in_array( 'pdf', $matched_extensions, true ) ) {
		return true;
	}

	return false;
}

/**
 * Get asset info from extracted asset files.
 *
 * @param string $slug Asset slug as defined in build/webpack configuration.
 * @param string $attribute Optional attribute to get. Can be version or dependencies.
 * @return string|array
 */
function get_asset_info( string $slug, string $attribute = null ) {
	if ( file_exists( CLASSIFAI_PLUGIN_DIR . '/dist/' . $slug . '.asset.php' ) ) {
		$asset = require CLASSIFAI_PLUGIN_DIR . '/dist/' . $slug . '.asset.php';
	} else {
		return null;
	}

	if ( ! empty( $attribute ) && isset( $asset[ $attribute ] ) ) {
		return $asset[ $attribute ];
	}

	return $asset;
}

/**
 * Get the list of registered services.
 *
 * @return array Array of services.
 */
function get_services_menu(): array {
	$services = Plugin::$instance->services;
	if ( empty( $services ) || empty( $services['service_manager'] ) || ! $services['service_manager'] instanceof ServicesManager ) {
		return [];
	}

	/** @var ServicesManager $service_manager Instance of the services manager class. */
	$service_manager = $services['service_manager'];
	$services        = [];

	foreach ( $service_manager->service_classes as $service ) {
		$services[ $service->get_menu_slug() ] = $service->get_display_name();
	}
	return $services;
}

/**
 * Sanitizes and ensures an input variable is set.
 *
 * @param string  $key               $_GET or $_POST array key.
 * @param boolean $is_get            If the request is $_GET. Defaults to false.
 * @param string  $sanitize_callback Sanitize callback. Defaults to `sanitize_text_field`
 * @return string|boolean Sanitized string or `false` as fallback.
 */
function clean_input( string $key = '', bool $is_get = false, string $sanitize_callback = 'sanitize_text_field' ) {
	if ( ! is_callable( $sanitize_callback ) ) {
		$sanitize_callback = 'sanitize_text_field';
	}

	if ( $is_get ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		return isset( $_GET[ $key ] ) ? call_user_func( $sanitize_callback, wp_unslash( $_GET[ $key ] ) ) : '';
	} else {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		return isset( $_POST[ $key ] ) ? call_user_func( $sanitize_callback, wp_unslash( $_POST[ $key ] ) ) : '';
	}

	return false;
}

/**
 * Find the provider class that a service belongs to.
 *
 * @param array  $provider_classes Provider classes to look in.
 * @param string $provider_id      ID of the provider.
 * @return Provider|WP_Error
 */
function find_provider_class( array $provider_classes = [], string $provider_id = '' ) {
	$provider = '';

	foreach ( $provider_classes as $provider_class ) {
		if ( $provider_id === $provider_class::ID ) {
			$provider = $provider_class;
		}
	}

	if ( ! $provider ) {
		return new WP_Error( 'provider_class_required', esc_html__( 'Provider class not found.', 'classifai' ) );
	}

	return $provider;
}

/**
 * Get core and custom post statuses.
 *
 * @return array
 */
function get_all_post_statuses(): array {
	$all_statuses = wp_list_pluck(
		get_post_stati(
			[],
			'objects'
		),
		'label'
	);

	/*
	 * We unset the following because we want to limit the post
	 * statuses to the ones returned by `get_post_statuses()` and
	 * any custom post statuses registered using `register_post_status()`
	 */
	unset(
		$all_statuses['future'],
		$all_statuses['trash'],
		$all_statuses['auto-draft'],
		$all_statuses['inherit'],
		$all_statuses['request-pending'],
		$all_statuses['request-confirmed'],
		$all_statuses['request-failed'],
		$all_statuses['request-completed']
	);

	/*
	 * There is a minor difference in the label for 'pending' status between
	 * `get_post_statuses()` and `get_post_stati()`.
	 *
	 * `get_post_stati()` has the label as `Pending` whereas
	 * `get_post_statuses()` has the label as `Pending Review`.
	 *
	 * So we normalise it here.
	 */
	if ( isset( $all_statuses['pending'] ) ) {
		$all_statuses['pending'] = esc_html__( 'Pending Review', 'classifai' );
	}

	/**
	 * Hook to filter post statuses.
	 *
	 * @since 2.2.2
	 * @hook classifai_all_post_statuses
	 *
	 * @param {array} $all_statuses Array of post statuses.
	 *
	 * @return {array} Array of post statuses.
	 */
	return apply_filters( 'classifai_all_post_statuses', $all_statuses );
}

/**
 * Check if the current user has permission to create and assign terms.
 *
 * @param string $tax Taxonomy name.
 * @return bool|WP_Error
 */
function check_term_permissions( string $tax = '' ) {
	$taxonomy = get_taxonomy( $tax );

	if ( empty( $taxonomy ) || empty( $taxonomy->show_in_rest ) ) {
		return new WP_Error( 'invalid_taxonomy', esc_html__( 'Taxonomy not found. Double check your settings.', 'classifai' ) );
	}

	$create_cap = is_taxonomy_hierarchical( $taxonomy->name ) ? $taxonomy->cap->edit_terms : $taxonomy->cap->assign_terms;

	if ( ! current_user_can( $create_cap ) || ! current_user_can( $taxonomy->cap->assign_terms ) ) {
		return new WP_Error( 'rest_cannot_assign_term', esc_html__( 'Sorry, you are not alllowed to create or assign to this taxonomy.', 'classifai' ) );
	}

	return true;
}

/**
 * Renders a link to disable a specific feature.
 *
 * @since 2.5.0
 *
 * @param string $feature Feature key.
 */
function render_disable_feature_link( string $feature ) {
	$user_profile     = new UserProfile();
	$allowed_features = $user_profile->get_allowed_features( get_current_user_id() );
	$profile_url      = get_edit_profile_url( get_current_user_id() ) . '#classifai-profile-features-section';

	if ( ! empty( $allowed_features ) && isset( $allowed_features[ $feature ] ) ) {
		?>
		<a href="<?php echo esc_url( $profile_url ); ?>" target="_blank" rel="noopener noreferrer" class="classifai-disable-feature-link" aria-label="<?php esc_attr_e( 'Opt out of using this ClassifAI feature', 'classifai' ); ?>">
			<?php esc_html_e( 'Disable this ClassifAI feature', 'classifai' ); ?>
		</a>
		<?php
	}
}

/**
 * Sanitize the prompt data.
 * This is used for the repeater field.
 *
 * @since 2.4.0
 *
 * @param array $prompt_key Prompt key.
 * @param array $new_settings   Settings data.
 * @return array Sanitized prompt data.
 */
function sanitize_prompts( $prompt_key = '', array $new_settings = [] ): array {
	if ( isset( $new_settings[ $prompt_key ] ) && is_array( $new_settings[ $prompt_key ] ) ) {

		$prompts = $new_settings[ $prompt_key ];

		// Remove any prompts that don't have a title and prompt.
		$prompts = array_filter(
			$prompts,
			function ( $prompt ) {
				return ! empty( $prompt['title'] ) && ! empty( $prompt['prompt'] );
			}
		);

		// Sanitize the prompts and make sure only one prompt is marked as default.
		$has_default = false;

		$prompts = array_map(
			function ( $prompt ) use ( &$has_default ) {
				$default = isset( $prompt['default'] ) && $prompt['default'] && ! $has_default;

				if ( $default ) {
					$has_default = true;
				}

				return array(
					'title'    => sanitize_text_field( $prompt['title'] ),
					'prompt'   => sanitize_textarea_field( $prompt['prompt'] ),
					'default'  => absint( $default ),
					'original' => absint( $prompt['original'] ),
				);
			},
			$prompts
		);

		// If there is no default, use the first prompt.
		if ( false === $has_default && ! empty( $prompts ) ) {
			$prompts[0]['default'] = 1;
		}

		return $prompts;
	}

	return array();
}

/**
 * Get the default prompt for use.
 *
 * @since 2.4.0
 *
 * @param array $prompts Prompt data.
 * @return string|null Default prompt.
 */
function get_default_prompt( array $prompts ): ?string {
	$default_prompt = null;

	if ( ! empty( $prompts ) ) {
		$prompt_data = array_filter(
			$prompts,
			function ( $prompt ) {
				return isset( $prompt['default'] ) && $prompt['default'] && ! $prompt['original'];
			}
		);

		if ( ! empty( $prompt_data ) ) {
			$default_prompt = current( $prompt_data )['prompt'];
		} elseif ( ! empty( $prompts[0]['prompt'] ) && ! $prompts[0]['original'] ) {
			// If there is no default, use the first prompt, unless it's the original prompt.
			$default_prompt = $prompts[0]['prompt'];
		}
	}

	return $default_prompt;
}

/**
 * Sanitisation callback for number of responses.
 *
 * @param string $key The key of the value we are sanitizing.
 * @param array  $new_settings The settings array.
 * @param array  $settings     Current array.
 * @return int
 */
function sanitize_number_of_responses_field( string $key, array $new_settings, array $settings ): int {
	return absint( $new_settings[ $key ] ?? $settings[ $key ] ?? '' );
}

/**
 * Returns a bool based on whether the specified classification feature is enabled.
 *
 * @param string $classify_by Feature to check.
 * @return bool
 */
function get_classification_feature_enabled( string $classify_by ): bool {
	$settings = ( new Classification() )->get_settings();

	return filter_var(
		$settings[ $classify_by ],
		FILTER_VALIDATE_BOOLEAN
	);
}

/**
 * Returns the Taxonomy for the specified NLU feature.
 *
 * Returns defaults in config.php if options have not been configured.
 *
 * @param string $classify_by NLU feature name.
 * @return string
 */
function get_classification_feature_taxonomy( string $classify_by = '' ): string {
	$taxonomy = '';
	$settings = ( new Classification() )->get_settings();

	if ( ! empty( $settings[ $classify_by . '_taxonomy' ] ) ) {
		$taxonomy = $settings[ $classify_by . '_taxonomy' ];
	}

	if ( Embeddings::ID === $settings['provider'] ) {
		$taxonomy = $classify_by;
	}

	if ( empty( $taxonomy ) ) {
		$constant = 'WATSON_' . strtoupper( $classify_by ) . '_TAXONOMY';

		if ( defined( $constant ) ) {
			$taxonomy = constant( $constant );
		}
	}

	/**
	 * Filter the Taxonomy for the specified NLU feature.
	 *
	 * @since 3.0.0
	 * @hook classifai_feature_classification_taxonomy_for_feature
	 *
	 * @param {string} $taxonomy The slug of the taxonomy to use.
	 * @param {string} $classify_by The NLU feature this taxonomy is for.
	 *
	 * @return {string} The filtered taxonomy slug.
	 */
	return apply_filters( 'classifai_feature_classification_taxonomy_for_feature', $taxonomy, $classify_by );
}

/**
 * Get Classification mode.
 *
 * @since 2.5.0
 *
 * @return string
 */
function get_classification_mode(): string {
	$feature  = new Classification();
	$settings = $feature->get_settings();
	$value    = $settings['classification_mode'] ?? '';

	if ( $feature->is_feature_enabled() ) {
		if ( empty( $value ) ) {
			// existing users
			// default: automatic_classification
			return 'automatic_classification';
		}
	} else {
		// new users
		// default: manual_review
		return 'manual_review';
	}

	return $value;
}