Source: includes/class-sensei-updates.php

<?php
/**
 * File containing Sensei_Updates class.
 *
 * @package sensei-lms
 * @since 1.1.0
 */

use Sensei\Internal\Emails\Email_Seeder_Data;
use Sensei\Internal\Emails\Email_Repository;
use Sensei\Internal\Emails\Email_Seeder;
use Sensei\Internal\Emails\Email_Template_Repository;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Sensei Updates Class
 *
 * Class that contains the updates for Sensei data and structures.
 *
 * @author Automattic
 * @since 1.1.0
 * @since 3.7.0 New constructor signature and single purpose for performing update tasks.
 */
class Sensei_Updates {
	/**
	 * Version that is currently being updated from.
	 *
	 * @var string|null
	 */
	private $current_version;

	/**
	 * Flag if this is a new install.
	 *
	 * @var bool
	 */
	private $is_new_install;

	/**
	 * Flag if this is an upgrade.
	 *
	 * @var bool
	 */
	private $is_upgrade;

	/**
	 * Sensei_Updates constructor.
	 *
	 * Default values for backwards compatibility pre-v3.7.
	 *
	 * @param string $current_version Version that is currently being updated from.
	 * @param bool   $is_new_install  Flag if this is a new install.
	 * @param bool   $is_upgrade      Flag if this is an upgrade.
	 */
	public function __construct( $current_version = null, $is_new_install = false, $is_upgrade = false ) {
		if ( is_object( $current_version ) ) {
			$current_version = null;
		}

		$this->current_version = $current_version;
		$this->is_new_install  = $is_new_install;
		$this->is_upgrade      = $is_upgrade;
	}

	/**
	 * Run the updates (if necessary).
	 *
	 * @since 3.7.0
	 */
	public function run_updates() {
		// Only proceed if we knew the previous version and this was a new install or an upgrade.
		if ( $this->current_version && ! $this->is_new_install && ! $this->is_upgrade ) {
			return;
		}

		if ( $this->is_upgrade ) {
			$this->log_update();
		}

		$this->v3_0_check_legacy_enrolment();
		$this->v3_7_check_rewrite_front();
		$this->v3_7_add_comment_indexes();
		$this->v3_9_fix_question_author();
		$this->v3_9_remove_abandoned_multiple_question();
		$this->v4_10_update_install_time();
		$this->v4_12_create_default_emails();
		$this->v4_19_2_update_legacy_quiz_data();
		$this->v4_24_1_update_capabilities();

		// Flush rewrite cache.
		Sensei()->initiate_rewrite_rules_flush();
	}

	/**
	 * Update capabilities.
	 *
	 * @since 4.24.1
	 */
	private function v4_24_1_update_capabilities() {
		// Add new `manage_question_categories` capabilitiy to teacher.
		Sensei()->teacher->create_role();

		// Update the other roles capabilities.
		Sensei()->add_sensei_admin_caps();
		Sensei()->add_editor_caps();
	}

	/**
	 * Enqueue job to update the legacy quiz data.
	 *
	 * @since 4.19.2
	 */
	private function v4_19_2_update_legacy_quiz_data() {
		$legacy_answers_count = (int) Sensei_Utils::sensei_check_for_activity(
			array(
				'type'   => 'sensei_user_answer',
				'status' => 'log',
			)
		);

		if ( $legacy_answers_count ) {
			Sensei_Scheduler::instance()->schedule_job( new Sensei_Update_Legacy_Quiz_Data() );
		}
	}

	/**
	 * Create default emails.
	 *
	 * @return void
	 */
	private function v4_12_create_default_emails() {
		if ( ! Sensei()->feature_flags->is_enabled( 'email_customization' ) ) {
			return;
		}

		$repository = new Email_Repository();
		if ( $repository->has_emails() ) {
			return;
		}

		$seeder = new Email_Seeder( new Email_Seeder_Data(), $repository );
		$seeder->init();
		$seeder->create_all();
	}

	/**
	 * Enqueue job to remove the abandoned `multiple_question`.
	 */
	private function v3_9_remove_abandoned_multiple_question() {
		// Only run this if we're upgrading and the current version (before upgrade) is less than 3.9.0.
		if ( ! $this->is_upgrade || version_compare( $this->current_version, '3.9.0', '>=' ) ) {
			return;
		}

		Sensei_Scheduler::instance()->schedule_job( new Sensei_Update_Remove_Abandoned_Multiple_Question() );
	}

	/**
	 * Set new option to save when Sensei was installed/updated.
	 */
	private function v4_10_update_install_time() {
		add_option( 'sensei_installed_at', time() );
	}

	/**
	 * Enqueue job to fix question post authors from previous course teacher changes.
	 */
	private function v3_9_fix_question_author() {
		// Only run this if we're upgrading and the current version (before upgrade) is less than 3.9.0.
		if ( ! $this->is_upgrade || version_compare( $this->current_version, '3.9.0', '>=' ) ) {
			return;
		}

		Sensei_Scheduler::instance()->schedule_job( new Sensei_Update_Fix_Question_Author() );
	}

	/**
	 * Add comment table indexes.
	 *
	 * @since 3.7.0
	 */
	private function v3_7_add_comment_indexes() {
		global $wpdb;

		/**
		 * Filter to disable attempts at adding the comment indexes.
		 *
		 * @since 3.7.0
		 *
		 * @hook sensei_add_comment_indexes
		 *
		 * @param {bool} $do_add_indexes True if indexes should be added to comment table.
		 * @return {bool} Filtered value.
		 */
		if ( ! apply_filters( 'sensei_add_comment_indexes', true ) ) {
			return;
		}

		$indexes = [
			'woo_idx_comment_type'        => [ 'comment_type' ],
			'sensei_comment_type_user_id' => [ 'comment_type', 'user_id' ],
		];

		$current_indexes = array_map(
			function( $arr ) {
				return implode( ',', $arr['columns'] );
			},
			$this->get_table_indexes( $wpdb->comments )
		);

		foreach ( $indexes as $name => $columns ) {
			if ( isset( $current_indexes[ $name ] ) ) {
				continue;
			}

			sort( $columns );
			if ( in_array( implode( ',', $columns ), $current_indexes, true ) ) {
				continue;
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Safe schema change.
			$wpdb->query( "ALTER TABLE {$wpdb->comments} ADD INDEX {$name} (`" . implode( '`,`', $columns ) . '`)' );
		}
	}

	/**
	 * Get indexes for a table.
	 *
	 * @param string $table Table to get indexes for.
	 *
	 * @return array
	 */
	private function get_table_indexes( $table ) {
		global $wpdb;

		$indexes = [];
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Safe direct sql.
		$results = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A );
		if ( ! $results ) {
			return [];
		}

		foreach ( $results as $row ) {
			if ( ! isset( $indexes[ $row['Key_name'] ] ) ) {
				$indexes[ $row['Key_name'] ] = [
					'unique'  => 0 === (int) $row['Non_unique'],
					'columns' => [],
				];
			}
			$indexes[ $row['Key_name'] ]['columns'][] = $row['Column_name'];
		}

		foreach ( $indexes as $index => $config ) {
			sort( $indexes[ $index ]['columns'] );
		}

		return $indexes;
	}

	/**
	 * Check for rewrite front and set legacy flag if needed.
	 *
	 * @since 3.7.0
	 */
	private function v3_7_check_rewrite_front() {
		global $wp_rewrite;

		// Set up legacy `with_front` on CPT rewrite options.
		if (
			$this->is_upgrade
			&& version_compare( '3.7.0', $this->current_version, '>' )
			&& '' !== trim( $wp_rewrite->front, '/' )
		) {
			Sensei()->set_legacy_flag( Sensei_Main::LEGACY_FLAG_WITH_FRONT, true );
		}
	}

	/**
	 * Check for legacy enrolment data and set flag if needed.
	 *
	 * @since 3.0.0
	 */
	private function v3_0_check_legacy_enrolment() {
		// Mark site as having enrolment data from legacy instances.
		if (
			// If the version is known and the previous version was pre-3.0.0.
			(
				$this->is_upgrade
				&& version_compare( '3.0.0', $this->current_version, '>' )
			)

			// If there wasn't a current version set and this isn't a new install, double check to make sure there wasn't any enrolment.
			|| (
				! $this->current_version
				&& ! $this->is_new_install
				&& $this->course_progress_exists()
			)
		) {
			update_option( 'sensei_enrolment_legacy', time() );
		}
	}

	/**
	 * Helper function to check to see if any course progress exists in the database.
	 *
	 * @return bool
	 */
	private function course_progress_exists() {
		$activity_args = [
			'type'   => 'sensei_course_status',
			'number' => 1,
			'status' => 'any',
		];

		$activity_sample = Sensei_Utils::sensei_check_for_activity( $activity_args, true );

		return ! empty( $activity_sample );
	}

	/**
	 * Get an array of quiz post IDs.
	 *
	 * @return int[]
	 */
	private function get_quiz_ids() {
		$query = new WP_Query(
			[
				'post_type'        => 'quiz',
				'fields'           => 'ids',
				'post_status'      => [ 'draft', 'publish' ],
				'posts_per_page'   => -1,
				'no_found_rows'    => true,
				'suppress_filters' => 1,
			]
		);

		return array_map( 'intval', $query->posts );
	}

	/**
	 * Logs the system update.
	 */
	private function log_update() {
		wp_schedule_single_event(
			time(),
			'sensei_log_update',
			[
				[
					'from_version'       => $this->current_version,
					'to_version'         => Sensei()->version,
					'days_since_release' => $this->get_days_since_release(),
				],
			]
		);
	}

	/**
	 * Get the days since release.
	 *
	 * @return int|null
	 */
	private function get_days_since_release() {
		$releases = $this->get_changelog_release_dates( Sensei()->version );

		if ( ! isset( $releases[ Sensei()->version ] ) ) {
			return null;
		}

		$today = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->setTime( 0, 0, 0 );
		$diff  = $releases[ Sensei()->version ]->diff( $today );
		$days  = false !== $diff->d ? (int) $diff->d : null;

		return $days;
	}

	/**
	 * Get the release dates from the changelog.
	 *
	 * @param string $version Filter to just include a single version (Optional).
	 *
	 * @return DateTimeImmutable[]
	 */
	private function get_changelog_release_dates( $version = null ) {
		$releases  = [];
		$changelog = $this->get_changelog();
		if ( ! $changelog ) {
			return $releases;
		}

		$version_match = '[\d\.\-a-z]+';
		if ( $version ) {
			$version_match = preg_quote( $version, '/' );
		}

		preg_match_all(
			"/## (?'version'{$version_match}) - ((?'year'\d{4})[\-\.](?'month'\d{1,2})[\-\.](?'day'\d{1,2}))[^\S]/",
			$changelog,
			$releases_raw,
			PREG_SET_ORDER
		);

		foreach ( $releases_raw as $release ) {
			if ( empty( $release['version'] ) || empty( $release['year'] ) || empty( $release['month'] ) || empty( $release['day'] ) ) {
				continue;
			}

			$releases[ $release['version'] ] = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', sprintf( '%04d-%02d-%02d 00:00:00', $release['year'], $release['month'], $release['day'] ), new DateTimeZone( 'UTC' ) );
		}

		return $releases;
	}

	/**
	 * Get the changelog contents.
	 *
	 * @return false|string
	 */
	protected function get_changelog() {
		$changelog_path = dirname( __DIR__ ) . DIRECTORY_SEPARATOR . 'changelog.txt';
		if ( ! is_readable( $changelog_path ) ) {
			return false;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file usage.
		return file_get_contents( $changelog_path );
	}

	/**
	 * Handles deprecation notices for old methods.
	 *
	 * @param string $name Method name.
	 * @param array  $args Called method arguments.
	 *
	 * @return mixed
	 * @throws BadMethodCallException When method is not known.
	 */
	public function __call( $name, $args ) {
		$methods = [
			'sensei_updates_page'                         => [
				'version' => '3.7.0',
				'default' => null,
			],
			'function_in_whitelist'                       => [
				'version' => '3.7.0',
				'default' => false,
			],
			'update'                                      => [
				'version' => '3.7.0',
				'default' => false,
			],
			'set_default_quiz_grade_type'                 => [
				'version' => '3.7.0',
				'default' => null,
			],
			'set_default_question_type'                   => [
				'version' => '3.7.0',
				'default' => null,
			],
			'update_question_answer_data'                 => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_question_grade_points'                => [
				'version' => '3.7.0',
				'default' => true,
			],
			'convert_essay_paste_questions'               => [
				'version' => '3.7.0',
				'default' => true,
			],
			'set_random_question_order'                   => [
				'version' => '3.7.0',
				'default' => true,
			],
			'set_default_show_question_count'             => [
				'version' => '3.7.0',
				'default' => true,
			],
			'remove_deleted_user_activity'                => [
				'version' => '3.7.0',
				'default' => true,
			],
			'add_teacher_role'                            => [
				'version' => '3.7.0',
				'default' => true,
			],
			'restructure_question_meta'                   => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_quiz_settings'                        => [
				'version' => '3.7.0',
				'default' => true,
			],
			'reset_lesson_order_meta'                     => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_question_gap_fill_separators'         => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_quiz_lesson_relationship'             => [
				'version' => '3.7.0',
				'default' => true,
			],
			'status_changes_fix_lessons'                  => [
				'version' => '3.7.0',
				'default' => true,
			],
			'status_changes_convert_lessons'              => [
				'version' => '3.7.0',
				'default' => true,
			],
			'status_changes_convert_courses'              => [
				'version' => '3.7.0',
				'default' => true,
			],
			'status_changes_repair_course_statuses'       => [
				'version' => '3.7.0',
				'default' => true,
			],
			'status_changes_convert_questions'            => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_legacy_sensei_comments_status'        => [
				'version' => '3.7.0',
				'default' => true,
			],
			'update_comment_course_lesson_comment_counts' => [
				'version' => '3.7.0',
				'default' => true,
			],
			'remove_legacy_comments'                      => [
				'version' => '3.7.0',
				'default' => true,
			],
			'index_comment_status_field'                  => [
				'version' => '3.7.0',
				'default' => true,
			],
			'enhance_teacher_role'                        => [
				'version' => '3.7.0',
				'default' => true,
			],
			'recalculate_enrolment'                       => [
				'version' => '3.7.0',
				'default' => true,
			],
		];

		if ( isset( $methods[ $name ] ) ) {
			_deprecated_function( esc_html( 'Sensei_Updates::' . $name ), esc_html( $methods[ $name ]['version'] ) );

			return isset( $methods[ $name ]['default'] ) ? $methods[ $name ]['default'] : null;
		}

		throw new BadMethodCallException( sprintf( 'Sensei_Updates::%s method does not exist' ) );
	}

	/**
	 * Sets the role capabilities for WordPress users.
	 *
	 * @since 1.1.0
	 * @deprecated 3.7.0
	 */
	public function assign_role_caps() {
		_deprecated_function( __METHOD__, '3.7.0', 'Sensei_Main::assign_role_caps' );

		Sensei()->assign_role_caps();
	}

	/**
	 * Add Sensei Admin Capabilities.
	 *
	 * @deprecated 3.7.0
	 *
	 * @return bool
	 */
	public function add_sensei_caps() {
		_deprecated_function( __METHOD__, '3.7.0', 'Sensei_Main::add_sensei_admin_caps' );

		return Sensei()->add_sensei_admin_caps();
	}

	/**
	 * Add editor role capabilities.
	 *
	 * @deprecated 3.7.0
	 *
	 * @return bool
	 */
	public function add_editor_caps() {
		_deprecated_function( __METHOD__, '3.7.0', 'Sensei_Main::add_editor_caps' );

		return Sensei()->add_editor_caps();
	}
}

/**
 * Class WooThemes_Sensei_Updates
 *
 * @ignore only for backward compatibility
 * @since 1.9.0
 */
class WooThemes_Sensei_Updates extends Sensei_Updates {} // phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound