Source: includes/data-port/class-sensei-data-port-utilities.php

<?php
/**
 * File containing the Sensei_Data_Port_Utilities.
 *
 * @package sensei
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * A collection of utilies used in data port.
 */
class Sensei_Data_Port_Utilities {

	const CHARS_WHITESPACE_AND_QUOTES = " \"\t\n\r\0\x0B";

	/**
	 * Create a user. If the user exists, the method simply returns the user.
	 *
	 * @param string $username The username.
	 * @param string $email    User's email.
	 * @param string $role     The user's role.
	 *
	 * @return WP_User|WP_Error  WP_User on success, WP_Error on failure.
	 */
	public static function create_user( $username, $email = '', $role = '' ) {
		$user = null;
		if ( $email ) {
			$user = get_user_by( 'email', $email );
		}

		if ( ! $user ) {
			$user = get_user_by( 'login', $username );
		}

		if ( ! $user ) {
			$user_id = wp_create_user( $username, wp_generate_password(), $email );

			if ( is_wp_error( $user_id ) ) {
				return $user_id;
			}

			$user = get_user_by( 'ID', $user_id );

			if ( ! empty( $role ) && $user ) {
				$user->set_role( $role );
			}
		}

		return $user;
	}

	/**
	 * Get an attachment by providing its source. The source can be a URL or a filename from the media library. If the
	 * source is an external URL, it will be retrieved and an appropriate attachment will be created.
	 *
	 * @param string $source             Filename or URL.
	 * @param int    $parent_id          Id of the parent post.
	 * @param array  $allowed_mime_types Allowed mime types.
	 *
	 * @return int|WP_Error  Attachment id on success, WP_Error on failure.
	 */
	public static function get_attachment_from_source( $source, $parent_id = 0, $allowed_mime_types = null ) {
		if ( false === filter_var( $source, FILTER_VALIDATE_URL ) ) {

			$attachments = get_posts(
				[
					'fields'         => 'ids',
					'post_type'      => 'attachment',
					'posts_per_page' => 1,
					'post_status'    => 'any',
					'meta_compare'   => 'REGEXP',
					'meta_key'       => '_wp_attached_file', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- No faster way to search an attachment from its filename.
					'meta_value'     => '(^|/)' . sanitize_file_name( $source ) . '$', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- See above.
				]
			);

			if ( empty( $attachments ) ) {
				return new WP_Error(
					'sensei_data_port_attachment_not_found',
					__( 'No attachment with the specified file name was found.', 'sensei-lms' )
				);
			}

			$attachment_id   = $attachments[0];
			$valid_mime_type = self::validate_file_mime_type_by_attachment_id( $attachment_id, $allowed_mime_types );

			if ( is_wp_error( $valid_mime_type ) ) {
				return $valid_mime_type;
			}
		} else {
			// In case a local URL is provided, try to convert it to the attachment.
			$attachment_id = attachment_url_to_postid( $source );

			if ( ! $attachment_id ) {
				$attachment_id = self::create_attachment_from_url( $source, $parent_id, $allowed_mime_types );
			}
		}

		return $attachment_id;
	}

	/**
	 * This method retrieves a file from an external url, creates an attachment and links the attachment with the
	 * downloaded file. If the file has been already downloaded an linked to an attachment, it returns the existing
	 * attachment instead.
	 *
	 * @param string $external_url       The external url.
	 * @param int    $parent_id          The attachment's parent id.
	 * @param array  $allowed_mime_types Allowed mime types.
	 *
	 * @return int|WP_Error  The attachment id or an error.
	 */
	public static function create_attachment_from_url( $external_url, $parent_id = 0, $allowed_mime_types = null ) {
		require_once ABSPATH . 'wp-admin/includes/image.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/media.php';

		$existing_attachment = get_posts(
			[
				'fields'         => 'ids',
				'post_type'      => 'attachment',
				'posts_per_page' => 1,
				'post_status'    => 'inherit',
				'post_parent'    => $parent_id,
				'meta_key'       => '_sensei_attachment_source_key', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Only attachments are checked.
				'meta_value'     => md5( $external_url ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- See above.
			]
		);

		if ( ! empty( $existing_attachment ) ) {
			$attachment_id   = $existing_attachment[0];
			$valid_mime_type = self::validate_file_mime_type_by_attachment_id( $attachment_id, $allowed_mime_types );

			if ( is_wp_error( $valid_mime_type ) ) {
				return $valid_mime_type;
			}

			return $attachment_id;
		}

		/**
		 * Filters the timeout value for the HTTP request which retrieves an external attachment.
		 *
		 * Increase this value in case big attachments are imported and the request to get them
		 * times out.
		 *
		 * @since 3.3.0
		 *
		 * @hook sensei_import_attachment_request_timeout
		 *
		 * @param {float} $timeout Time in seconds until a request times out. Default 10.
		 * @return {float} Filtered timeout value.
		 */
		$timeout  = apply_filters( 'sensei_import_attachment_request_timeout', 10 );
		$response = wp_safe_remote_get( $external_url, [ 'timeout' => $timeout ] );

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return new WP_Error(
				'sensei_data_port_attachment_failure',
				__( 'Error encountered while retrieving the attachment from the provided URL.', 'sensei-lms' )
			);
		}

		$upload_result = wp_upload_bits( basename( $external_url ), null, wp_remote_retrieve_body( $response ) );

		if ( ! empty( $upload_result['error'] ) ) {
			return new WP_Error( 'sensei_data_port_storing_file_failure', $upload_result['error'] );
		}

		$file_path = $upload_result['file'];
		$file_url  = $upload_result['url'];

		$wp_filetype     = wp_check_filetype_and_ext( $file_path, basename( $file_path ) );
		$valid_mime_type = self::validate_file_mime_type( $wp_filetype['type'], $allowed_mime_types, $file_path );

		if ( is_wp_error( $valid_mime_type ) ) {
			return $valid_mime_type;
		}

		$attachment_args = [
			'post_content'   => '',
			'post_title'     => basename( $file_path ),
			'post_mime_type' => $wp_filetype['type'],
			'guid'           => $file_url,
			'post_status'    => 'inherit',
		];

		$attachment_id = wp_insert_attachment( $attachment_args, $file_path, $parent_id );
		update_post_meta( $attachment_id, '_sensei_attachment_source_key', md5( $external_url ) );

		if ( is_wp_error( $attachment_id ) ) {
			return $attachment_id;
		}

		if ( 0 === $attachment_id ) {
			return new WP_Error(
				'sensei_data_port_attachment_failure',
				__( 'Attachment insertion failed.', 'sensei-lms' )
			);
		}

		wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file_path ) );

		return $attachment_id;
	}

	/**
	 * Validate file mime type by attachment ID.
	 *
	 * @param int   $attachment_id      Attachment ID.
	 * @param array $allowed_mime_types Allowed mime types.
	 *
	 * @return true|WP_Error
	 */
	private static function validate_file_mime_type_by_attachment_id( $attachment_id, $allowed_mime_types = null ) {
		$file_path   = get_attached_file( $attachment_id );
		$wp_filetype = wp_check_filetype_and_ext( $file_path, basename( $file_path ) );

		return self::validate_file_mime_type( $wp_filetype['type'], $allowed_mime_types, $file_path );
	}

	/**
	 * Validate file mime type.
	 *
	 * @param string $mime_type          File mime type.
	 * @param array  $allowed_mime_types Allowed mime types.
	 * @param string $file_name          File name to validate by extension, as fallback for administrators.
	 *
	 * @return true|WP_Error
	 */
	public static function validate_file_mime_type( $mime_type, $allowed_mime_types = null, $file_name = null ) {
		if ( null === $allowed_mime_types ) {
			return true;
		}

		$valid_mime_type  = $mime_type && in_array( $mime_type, $allowed_mime_types, true );
		$valid_extensions = self::mime_types_extensions( $allowed_mime_types );

		// If we cannot determine the type, allow check based on extension for administrators.
		if ( ! $mime_type && current_user_can( 'unfiltered_upload' ) && null !== $file_name ) {
			$valid_mime_type = in_array( pathinfo( $file_name, PATHINFO_EXTENSION ), $valid_extensions, true );
		}

		if ( ! $valid_mime_type ) {
			return new WP_Error(
				'sensei_data_port_unexpected_file_type',
				// translators: Placeholder is list of file extensions.
				sprintf( __( 'File type is not supported. Must be one of the following: %s.', 'sensei-lms' ), implode( ', ', $valid_extensions ) )
			);
		}

		return true;
	}

	/**
	 * Get an array of extensions.
	 *
	 * @param array $mime_types Array of mime types.
	 *
	 * @return array Array of valid extensions.
	 */
	private static function mime_types_extensions( $mime_types ) {
		$extensions = [];
		foreach ( array_keys( $mime_types ) as $ext_list ) {
			$extensions = array_merge( $extensions, explode( '|', $ext_list ) );
		}

		return array_unique( $extensions );
	}

	/**
	 * Get a term based on human readable string and create it if needed. If the taxonomy is hierarchical,
	 * this method processes that as well and returns the \WP_Term object for the last in their hierarchy.
	 *
	 * @param string $term_name_path  Term name with optional hierarchy path, separated by " > ".
	 * @param string $taxonomy_name   Name of the taxonomy.
	 * @param int    $teacher_user_id User ID for the teacher (only needed for modules).
	 *
	 * @return WP_Term|false
	 */
	public static function get_term( $term_name_path, $taxonomy_name, $teacher_user_id = null ) {
		if ( ! $term_name_path ) {
			return false;
		}

		$taxonomy = get_taxonomy( $taxonomy_name );
		if ( ! $taxonomy ) {
			return false;
		}

		if ( $taxonomy->hierarchical ) {
			$term_path = preg_split( '/ ?> ?/', $term_name_path );
		} else {
			$term_path = [ $term_name_path ];
		}

		/**
		 * Last term object.
		 *
		 * @var WP_Term $last_term
		 */
		$last_term = null;

		foreach ( $term_path as $term_name ) {
			$term_name = trim( $term_name );
			$parent_id = isset( $last_term ) ? $last_term->term_id : 0;

			$term_query = new WP_Term_Query( self::get_term_query_args( $term_name, $taxonomy_name, $teacher_user_id, $parent_id ) );
			$terms      = $term_query->get_terms();

			if ( ! empty( $terms ) ) {
				$last_term = array_shift( $terms );
			} else {
				$last_term = self::create_term( $term_name, $taxonomy_name, $teacher_user_id, $parent_id );
			}

			if ( ! $last_term ) {
				return false;
			}
		}

		return $last_term;
	}

	/**
	 * Generate the term slug.
	 *
	 * @param string $term_name       Term name.
	 * @param string $taxonomy_name   Name of the taxonomy.
	 * @param int    $teacher_user_id User ID for the teacher.
	 *
	 * @return string
	 */
	private static function get_term_slug( $term_name, $taxonomy_name, $teacher_user_id ) {
		if ( 'module' === $taxonomy_name && ! user_can( $teacher_user_id, 'manage_options' ) ) {
			return intval( $teacher_user_id ) . '-' . sanitize_title( $term_name );
		}

		return sanitize_title( $term_name );
	}

	/**
	 * Generate the arguments for the term query.
	 *
	 * @param string $term_name       Term name.
	 * @param string $taxonomy_name   Name of the taxonomy.
	 * @param int    $teacher_user_id User ID for the teacher.
	 * @param int    $parent_id       Parent ID (optional).
	 *
	 * @return array
	 */
	private static function get_term_query_args( $term_name, $taxonomy_name, $teacher_user_id, $parent_id = 0 ) {
		$args               = [];
		$args['number']     = 1;
		$args['taxonomy']   = $taxonomy_name;
		$args['hide_empty'] = false;
		$args['parent']     = $parent_id;

		if ( 'module' === $taxonomy_name ) {
			$args['slug'] = self::get_term_slug( $term_name, $taxonomy_name, $teacher_user_id );
		} else {
			$args['name'] = $term_name;
		}

		return $args;
	}

	/**
	 * Create a new term.
	 *
	 * @param string $term_name       Term name.
	 * @param string $taxonomy_name   Name of the taxonomy.
	 * @param int    $teacher_user_id User ID for the teacher.
	 * @param int    $parent_id       Parent ID (optional).
	 *
	 * @return WP_Term|false
	 */
	private static function create_term( $term_name, $taxonomy_name, $teacher_user_id, $parent_id = null ) {
		$args         = [];
		$args['slug'] = self::get_term_slug( $term_name, $taxonomy_name, $teacher_user_id );

		if ( $parent_id ) {
			$args['parent'] = $parent_id;
		}

		$term_arr = wp_insert_term( $term_name, $taxonomy_name, $args );
		if ( is_wp_error( $term_arr ) ) {
			return false;
		}

		return get_term_by( 'id', $term_arr['term_id'], $taxonomy_name );
	}

	/**
	 * Split a list and ignore commas enclosed in quotes. Legitimate quotes should be HTML escaped.
	 *
	 * @param string $str_list      List in string form, separated by commas.
	 * @param bool   $remove_quotes Remove the surrounding quotes.
	 *
	 * @return array|string[]
	 */
	public static function split_list_safely( string $str_list, bool $remove_quotes = false ) {
		if ( empty( trim( $str_list ) ) ) {
			return [];
		}

		$str_list = self::replace_curly_quotes( $str_list );
		$list     = preg_split( '/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/', $str_list );

		if ( $remove_quotes ) {
			$list = array_map(
				function( $value ) {
					return trim( $value, self::CHARS_WHITESPACE_AND_QUOTES );
				},
				$list
			);
		} else {
			$list = array_map( 'trim', $list );
		}

		return $list;
	}

	/**
	 * Replace the curly quotes with straight quotes in the string.
	 *
	 * @param string $string String that possibly has curly quotes.
	 *
	 * @return string
	 */
	public static function replace_curly_quotes( $string ) {
		return str_replace( [ '“', '”' ], '"', $string );
	}

	/**
	 * Serialize a list of terms into comma-separated list.
	 * Adds quotes if name contains commas.
	 *
	 * @param WP_Term[] $terms The terms array.
	 *
	 * @return string
	 */
	public static function serialize_term_list( $terms ) {
		$names = array_map( 'Sensei_Data_Port_Utilities::serialize_term', $terms );
		return implode( ',', $names );
	}

	/**
	 * Return term name and hierarchy representation, in the format of 'Parent > Child'.
	 *
	 * @param WP_Term $term The term object.
	 *
	 * @return string
	 */
	public static function serialize_term( WP_Term $term ) {

		$name = self::escape_list_item( $term->name );
		if ( ! empty( $term->parent ) ) {
			$parent_term = get_term( $term->parent, $term->taxonomy );
			$parent_str  = self::serialize_term( $parent_term );
			return $parent_str . ' > ' . $name;
		}
		return $name;
	}

	/**
	 * Serialize a list into comma-separated list.
	 * Wrap values in quotes if they contain a comma.
	 *
	 * @deprecated 3.5.2
	 *
	 * @param string[] $values The values array.
	 *
	 * @return string
	 */
	public static function serialize_list( $values = [] ) {
		_deprecated_function( __METHOD__, '3.5.2' );

		return ! empty( $values )
			? implode( ',', array_map( 'Sensei_Data_Port_Utilities::escape_list_item', $values ) )
			: '';
	}

	/**
	 * Wrap value in quotes if it contains a comma.
	 * Escape quotes if wrapped.
	 *
	 * @param string $value The value.
	 *
	 * @return string
	 */
	public static function escape_list_item( $value ) {
		if ( false !== strpos( $value, ',' ) ) {
			$value = '"' . str_replace( '"', '\"', $value ) . '"';
		}
		return $value;
	}

	/**
	 * Serialize ID field.
	 *
	 * @param int|int[] $ids ID or IDs array to format.
	 *
	 * @return string Serialized ID field.
	 */
	public static function serialize_id_field( $ids ) {
		if ( empty( $ids ) ) {
			return '';
		}

		return 'id:' . implode( ',id:', (array) $ids );
	}

	/**
	 * Helper method which gets a module by name and checks if the module can be applied to a course's lesson.
	 *
	 * @param string $module_name  The module name.
	 * @param int    $course_id    Course ID.
	 *
	 * @return WP_Error|WP_Term  WP_Error when the module can't be applied to the lesson, WP_Term otherwise.
	 */
	public static function get_module_for_course( $module_name, $course_id ) {
		$module = get_term_by( 'name', $module_name, 'module' );

		if ( ! $module ) {
			return new WP_Error(
				'sensei_data_port_module_not_found',
				// translators: Placeholder is the term which errored.
				sprintf( __( 'Module does not exist: %s.', 'sensei-lms' ), $module_name )
			);
		}

		$course_modules = wp_list_pluck( wp_get_post_terms( $course_id, 'module' ), 'term_id' );

		if ( ! in_array( $module->term_id, $course_modules, true ) ) {
			return new WP_Error(
				'sensei_data_port_module_not_part_of_course',
				// translators: First placeholder is the term which errored, second is the course id.
				sprintf( __( 'Module %1$s is not part of course %2$s.', 'sensei-lms' ), $module_name, $course_id )
			);
		}

		return $module;
	}

	/**
	 * Get the ID of the imported demo course.
	 *
	 * @internal
	 *
	 * @since 4.10.0
	 *
	 * @return int|null
	 */
	public static function get_demo_course_id(): ?int {
		$query = new WP_Query(
			[
				'posts_per_page' => 1,
				'post_type'      => 'course',
				'post_status'    => 'any',
				'fields'         => 'ids',
				'name'           => Sensei_Data_Port_Manager::SAMPLE_COURSE_SLUG,
			]
		);

		if ( ! $query->found_posts ) {
			return null;
		}

		return $query->get_posts()[0];
	}
}