Source: includes/class-sensei-lesson-quiz-saver.php

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

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

/**
 * Saves a lesson's quiz settings and questions.
 *
 * This is the save path of the lesson quiz block in the editor, shared by the
 * lesson-quiz REST controller and the `sensei/quiz-questions-set` ability.
 *
 * @since $$next-version$$
 */
class Sensei_Lesson_Quiz_Saver {
	use Sensei_REST_API_Question_Helpers_Trait;

	/**
	 * Quiz options stored as a yes/no/unset tri-state.
	 *
	 * @var string[]
	 */
	const TRISTATE_OPTIONS = array( 'failed_indicate_incorrect', 'failed_show_correct_answers', 'failed_show_answer_feedback' );

	/**
	 * Check whether the current user may save the quiz of a lesson.
	 *
	 * @param int $lesson_id Lesson post ID.
	 *
	 * @return bool
	 */
	public static function can_current_user_save( int $lesson_id ): bool {
		// get_post( 0 ) falls back to the global post, which must never stand in for a lesson id.
		if ( $lesson_id <= 0 ) {
			return false;
		}

		$lesson = get_post( $lesson_id );

		if ( ! $lesson instanceof WP_Post || 'lesson' !== $lesson->post_type ) {
			return false;
		}

		$post_type_object = get_post_type_object( 'lesson' );

		return $post_type_object && is_user_logged_in() && current_user_can( $post_type_object->cap->edit_post, $lesson->ID );
	}

	/**
	 * Sanitize questions against the schema of their type.
	 *
	 * @param array $questions The questions.
	 *
	 * @return array|WP_Error The sanitized questions.
	 */
	public function sanitize_questions( array $questions ) {
		$sanitized_questions = array();
		foreach ( $questions as $question ) {
			$result = rest_sanitize_value_from_schema( $question, $this->get_question_schema( $question['type'] ) );

			if ( $result instanceof WP_Error ) {
				return $result;
			}

			$sanitized_questions[] = $result;
		}

		return $sanitized_questions;
	}

	/**
	 * Validate questions against the schema of their type.
	 *
	 * @param array $questions The questions.
	 *
	 * @return true|WP_Error True on success, error otherwise.
	 */
	public function validate_questions( array $questions ) {
		foreach ( $questions as $question ) {
			$result = rest_validate_value_from_schema( $question, $this->get_question_schema( $question['type'] ) );

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

		return true;
	}

	/**
	 * Save the quiz options and questions of a lesson, creating the quiz when the lesson has none.
	 *
	 * Questions without an id are created; questions left out are removed from the quiz.
	 * Options not present in `$params['options']` are reset, as the editor always sends all of them;
	 * see get_options_for_update() to merge over the stored options instead.
	 *
	 * Questions are written before the quiz, and the questions this save created are deleted again
	 * when a later write fails, so a failed save leaves no quiz behind and no question outside one.
	 *
	 * @param WP_Post $lesson                       The lesson.
	 * @param array   $params                       Quiz data: `options` and `questions`, as validated and sanitized against the schemas.
	 * @param bool    $reject_unavailable_questions Whether a question id that names no question the user may edit, and is
	 *                                              not already in the quiz, fails the save. The editor relies on such
	 *                                              questions being dropped; the ability reports them.
	 *
	 * @return int|WP_Error The quiz ID, or an error. Errors carry no HTTP status.
	 */
	public function save( WP_Post $lesson, array $params, bool $reject_unavailable_questions = false ) {
		if ( 'auto-draft' === $lesson->post_status ) {
			return new WP_Error(
				'sensei_lesson_quiz_lesson_auto_draft',
				__( 'Cannot update the quiz of an Auto Draft lesson.', 'sensei-lms' )
			);
		}

		$options   = is_array( $params['options'] ?? null ) ? $params['options'] : array();
		$questions = is_array( $params['questions'] ?? null ) ? $params['questions'] : array();

		$quiz_id = Sensei()->lesson->lesson_quizzes( $lesson->ID );
		$is_new  = null === $quiz_id;

		$existing_question_ids = $is_new ? array() : array_map( 'intval', wp_list_pluck( Sensei()->quiz->get_questions( (int) $quiz_id ), 'ID' ) );

		$resolved_questions = $this->resolve_questions( $questions, $existing_question_ids );
		if ( is_wp_error( $resolved_questions ) ) {
			return $resolved_questions;
		}

		if ( $reject_unavailable_questions ) {
			foreach ( $resolved_questions as $resolved ) {
				if ( 'drop' === $resolved['action'] ) {
					return new WP_Error(
						'sensei_lesson_quiz_question_not_available',
						// translators: Placeholder is the question ID.
						sprintf( __( 'Question with id "%d" was not found or cannot be edited.', 'sensei-lms' ), (int) $resolved['id'] ),
						$resolved['id']
					);
				}
			}
		}

		$question_ids         = array();
		$created_question_ids = array();
		foreach ( $resolved_questions as $resolved ) {
			if ( 'drop' === $resolved['action'] ) {
				continue;
			}

			if ( 'keep' === $resolved['action'] ) {
				$question_ids[] = $resolved['id'];
				continue;
			}

			$question_id = $resolved['is_category']
				? $this->save_category_question( $resolved['question'] )
				: $this->save_question( $resolved['question'] );

			// The helpers return 0, not a WP_Error, when WordPress refuses the post.
			if ( is_wp_error( $question_id ) || ! $question_id ) {
				$this->delete_questions( $created_question_ids );

				return is_wp_error( $question_id )
					? $question_id
					: new WP_Error( 'sensei_lesson_quiz_question_save_failed', __( 'The question could not be saved.', 'sensei-lms' ) );
			}

			if ( 'create' === $resolved['action'] ) {
				$created_question_ids[] = (int) $question_id;
			}

			$question_ids[] = (int) $question_id;
		}

		$quiz_id = wp_insert_post(
			wp_slash(
				array(
					'ID'           => $quiz_id,
					'post_content' => '',
					'post_status'  => $lesson->post_status,
					'post_title'   => $lesson->post_title,
					'post_type'    => 'quiz',
					'post_parent'  => $lesson->ID,
					'meta_input'   => $this->get_quiz_meta( $options, $lesson ),
				)
			),
			true
		);

		if ( is_wp_error( $quiz_id ) ) {
			$this->delete_questions( $created_question_ids );

			return $quiz_id;
		}

		if ( $is_new ) {
			update_post_meta( (int) $lesson->ID, '_lesson_quiz', (int) $quiz_id );
			wp_set_post_terms( $quiz_id, array( 'multiple-choice' ), 'quiz-type' );

			/**
			 * Fires after a quiz is created via the REST API.
			 *
			 * @since 4.22.0
			 *
			 * @hook sensei_quiz_create
			 *
			 * @param {int} $quiz_id   Quiz post ID.
			 * @param {int} $lesson_id Course post ID.
			 */
			do_action( 'sensei_quiz_create', (int) $quiz_id, (int) $lesson->ID );
		}

		Sensei()->quiz->set_questions( $quiz_id, $question_ids );

		/**
		 * Fires when the questions and options of a lesson quiz have been saved, by the
		 * lesson-quiz REST controller or the `sensei/quiz-questions-set` ability.
		 *
		 * @hook sensei_rest_api_lesson_quiz_update
		 *
		 * @param {WP_Post} $lesson         The lesson.
		 * @param {array}   $request_params The quiz data that was saved: `options` and `questions`.
		 */
		do_action( 'sensei_rest_api_lesson_quiz_update', $lesson, $params );

		return (int) $quiz_id;
	}

	/**
	 * Resolve what saving each question does, before anything is written.
	 *
	 * A question without an id is created. A question whose id names a question of the right
	 * type that the user may edit is updated. Any other id is kept unchanged when the question is
	 * already in the quiz and dropped otherwise, as the editor sends back shared questions the
	 * user may not edit. Questions to create or update are validated here, so an invalid one
	 * fails the save before the quiz or any earlier question is written.
	 *
	 * @param array $questions             The questions.
	 * @param int[] $existing_question_ids IDs of the questions currently in the quiz.
	 *
	 * @return array<int, array{action: string, id: int|null, is_category: bool, question: array}>|WP_Error One entry per
	 *                   question: `action` is `create`, `update`, `keep` or `drop`; `id` the question post ID, when given;
	 *                   `is_category` whether it is a category-question placeholder; `question` the question as given.
	 */
	private function resolve_questions( array $questions, array $existing_question_ids ) {
		$resolved = array();
		$seen_ids = array();

		foreach ( $questions as $question ) {
			if ( ! is_array( $question ) ) {
				continue;
			}

			$question_id = (int) ( $question['id'] ?? 0 );
			$question_id = $question_id > 0 ? $question_id : null;
			$is_category = isset( $question['type'] ) && 'category-question' === $question['type'];
			$post_type   = $is_category ? 'multiple_question' : 'question';
			$entry       = array(
				'action'      => $question_id ? 'update' : 'create',
				'id'          => $question_id,
				'is_category' => $is_category,
				'question'    => $question,
			);

			if ( $question_id ) {
				if ( in_array( $question_id, $seen_ids, true ) ) {
					return new WP_Error(
						'sensei_lesson_quiz_duplicate_question',
						__( 'A question cannot appear more than once in the same quiz.', 'sensei-lms' ),
						$question_id
					);
				}
				$seen_ids[] = $question_id;

				$post_type_object = get_post_type_object( $post_type );
				if (
					get_post_type( $question_id ) !== $post_type
					|| ! $post_type_object
					|| ! current_user_can( $post_type_object->cap->edit_post, $question_id )
				) {
					$entry['action'] = in_array( $question_id, $existing_question_ids, true ) ? 'keep' : 'drop';
					$resolved[]      = $entry;
					continue;
				}
			}

			if ( $is_category ) {
				$category_id = (int) ( $question['options']['category'] ?? 0 );
				if ( ! $category_id || ! get_term( $category_id, 'question-category' ) instanceof WP_Term ) {
					return new WP_Error( 'sensei_lesson_quiz_question_invalid_category', esc_html__( 'Invalid question category selected.', 'sensei-lms' ), $question_id );
				}
			} elseif ( empty( $question['title'] ) ) {
				return new WP_Error(
					'sensei_lesson_quiz_question_missing_title',
					__( 'Please ensure all questions have a title before saving.', 'sensei-lms' )
				);
			}

			$resolved[] = $entry;
		}

		return $resolved;
	}

	/**
	 * Delete the questions a failed save created, so a retry does not leave them behind.
	 *
	 * @param int[] $question_ids Question post IDs.
	 */
	private function delete_questions( array $question_ids ): void {
		foreach ( $question_ids as $question_id ) {
			wp_delete_post( $question_id, true );
		}
	}

	/**
	 * Helper method to translate input to quiz meta.
	 *
	 * @param array   $quiz_options The quiz options.
	 * @param WP_Post $lesson       The parent lesson.
	 *
	 * @return array The meta.
	 */
	private function get_quiz_meta( array $quiz_options, WP_Post $lesson ): array {
		$meta_input = array( '_quiz_lesson' => $lesson->ID );

		if ( isset( $quiz_options['pass_required'] ) ) {
			$meta_input['_pass_required'] = true === $quiz_options['pass_required'] ? 'on' : '';
		}

		if ( isset( $quiz_options['quiz_passmark'] ) ) {
			$meta_input['_quiz_passmark'] = empty( $quiz_options['quiz_passmark'] ) ? 0 : $quiz_options['quiz_passmark'];
		}

		if ( isset( $quiz_options['auto_grade'] ) ) {
			$meta_input['_quiz_grade_type'] = true === $quiz_options['auto_grade'] ? 'auto' : 'manual';
		}

		if ( isset( $quiz_options['allow_retakes'] ) ) {
			$meta_input['_enable_quiz_reset'] = true === $quiz_options['allow_retakes'] ? 'on' : '';
		}

		if ( array_key_exists( 'show_questions', $quiz_options ) ) {
			$meta_input['_show_questions'] = empty( $quiz_options['show_questions'] ) ? '' : $quiz_options['show_questions'];
		}

		if ( isset( $quiz_options['random_question_order'] ) ) {
			$meta_input['_random_question_order'] = true === $quiz_options['random_question_order'] ? 'yes' : 'no';
		}

		foreach ( self::TRISTATE_OPTIONS as $option ) {
			if ( isset( $quiz_options[ $option ] ) ) {
				$meta_input[ '_' . $option ] = $quiz_options[ $option ] ? 'yes' : 'no';
			} else {
				$meta_input[ '_' . $option ] = null;
			}
		}

		$meta_input['_button_text_color']       = $quiz_options['button_text_color'] ?? null;
		$meta_input['_button_background_color'] = $quiz_options['button_background_color'] ?? null;

		if ( isset( $quiz_options['pagination'] ) ) {
			$meta_input['_pagination'] = wp_json_encode( $quiz_options['pagination'] );
		}

		return $meta_input;
	}

	/**
	 * Build the options for a partial update: the given options over the stored ones, or over the
	 * schema defaults when the lesson has no quiz yet.
	 *
	 * @param WP_Post|null $quiz    The quiz, or null when the lesson has none.
	 * @param array        $options Options to change.
	 *
	 * @return array Complete options to pass to save().
	 */
	public function get_options_for_update( ?WP_Post $quiz, array $options ): array {
		$current = $quiz ? $this->get_stored_options( $quiz ) : $this->get_default_options();

		$pagination = array_merge(
			is_array( $current['pagination'] ?? null ) ? $current['pagination'] : array(),
			is_array( $options['pagination'] ?? null ) ? $options['pagination'] : array()
		);

		$merged               = array_merge( $current, $options );
		$merged['pagination'] = $pagination;

		return $merged;
	}

	/**
	 * Get the options schema defaults.
	 *
	 * @return array
	 */
	public function get_default_options(): array {
		$defaults = array();

		foreach ( $this->get_options_schema()['properties'] as $key => $property ) {
			if ( 'pagination' === $key ) {
				$defaults[ $key ] = array();
				foreach ( $property['properties'] as $pagination_key => $pagination_property ) {
					$defaults[ $key ][ $pagination_key ] = $pagination_property['default'] ?? null;
				}
				continue;
			}

			$defaults[ $key ] = $property['default'] ?? null;
		}

		return $defaults;
	}

	/**
	 * Get the options stored on a quiz, with unset tri-state options as null rather than their
	 * displayed default, so that saving them back leaves them unset.
	 *
	 * @param WP_Post $quiz The quiz.
	 *
	 * @return array
	 */
	public function get_stored_options( WP_Post $quiz ): array {
		$options = $this->get_quiz_options( $quiz );

		foreach ( self::TRISTATE_OPTIONS as $option ) {
			$meta               = get_post_meta( $quiz->ID, '_' . $option, true );
			$options[ $option ] = '' === $meta || null === $meta ? null : 'yes' === $meta;
		}

		return $options;
	}

	/**
	 * Helper method which retrieves quiz options and questions.
	 *
	 * @param WP_Post $quiz   The quiz post.
	 * @param WP_Post $lesson The lesson post.
	 *
	 * @return array
	 */
	public function get_quiz_data( WP_Post $quiz, WP_Post $lesson ): array {
		$quiz_data = array(
			'options'       => $this->get_quiz_options( $quiz ),
			'questions'     => $this->get_quiz_questions( $quiz ),
			'lesson_title'  => $lesson->post_title,
			'lesson_status' => $lesson->post_status,
		);

		/**
		 * Filters the response of lesson-quiz requests.
		 *
		 * @hook sensei_rest_api_lesson_quiz_response
		 *
		 * @param {array}   $quiz_data The response data.
		 * @param {WP_Post} $quiz      The quiz post.default interval.
		 * @return {array} $quiz_data The modified response data.
		 */
		return apply_filters( 'sensei_rest_api_lesson_quiz_response', $quiz_data, $quiz );
	}

	/**
	 * Read the quiz options from the quiz meta.
	 *
	 * @param WP_Post $quiz The quiz post.
	 *
	 * @return array
	 */
	private function get_quiz_options( WP_Post $quiz ): array {
		$post_meta = get_post_meta( $quiz->ID );

		$allow_retakes           = ! empty( $post_meta['_enable_quiz_reset'][0] ) && 'on' === $post_meta['_enable_quiz_reset'][0];
		$failed_feedback_default = ! $allow_retakes;

		$pagination_defaults = array(
			'pagination_number'       => null,
			'show_progress_bar'       => false,
			'progress_bar_radius'     => 6,
			'progress_bar_height'     => 12,
			'progress_bar_color'      => null,
			'progress_bar_background' => null,
		);

		if ( empty( $post_meta['_pagination'][0] ) ) {
			$pagination = $pagination_defaults;
		} else {
			$pagination = json_decode( $post_meta['_pagination'][0], true );
			$pagination = $pagination ? $pagination : $pagination_defaults;
		}

		return array(
			'pass_required'               => ! empty( $post_meta['_pass_required'][0] ) && 'on' === $post_meta['_pass_required'][0],
			'quiz_passmark'               => empty( $post_meta['_quiz_passmark'][0] ) ? 0 : (int) $post_meta['_quiz_passmark'][0],
			'auto_grade'                  => ! empty( $post_meta['_quiz_grade_type'][0] ) && 'auto' === $post_meta['_quiz_grade_type'][0],
			'allow_retakes'               => $allow_retakes,
			'show_questions'              => empty( $post_meta['_show_questions'][0] ) ? null : (int) $post_meta['_show_questions'][0],
			'random_question_order'       => ! empty( $post_meta['_random_question_order'][0] ) && 'yes' === $post_meta['_random_question_order'][0],
			'failed_indicate_incorrect'   => empty( $post_meta['_failed_indicate_incorrect'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_indicate_incorrect'][0],
			'failed_show_correct_answers' => empty( $post_meta['_failed_show_correct_answers'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_show_correct_answers'][0],
			'failed_show_answer_feedback' => empty( $post_meta['_failed_show_answer_feedback'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_show_answer_feedback'][0],
			'button_text_color'           => ! empty( $post_meta['_button_text_color'][0] ) ? $post_meta['_button_text_color'][0] : null,
			'button_background_color'     => ! empty( $post_meta['_button_background_color'][0] ) ? $post_meta['_button_background_color'][0] : null,
			'pagination'                  => $pagination,
		);
	}

	/**
	 * Returns all the questions of a quiz.
	 *
	 * @param WP_Post $quiz The quiz.
	 *
	 * @return array The array of the questions as defined by the schema.
	 */
	private function get_quiz_questions( WP_Post $quiz ): array {
		$questions = Sensei()->quiz->get_questions( $quiz->ID );

		if ( empty( $questions ) ) {
			return array();
		}

		$quiz_questions = array();
		foreach ( $questions as $question ) {
			if ( 'multiple_question' === $question->post_type ) {
				$quiz_questions[] = $this->get_category_question( $question );
			} else {
				$quiz_questions[] = $this->get_question( $question );
			}
		}

		return $quiz_questions;
	}

	/**
	 * Schema of the quiz options.
	 *
	 * @return array Schema object for the `options` property.
	 */
	public function get_options_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'pass_required'               => array(
					'type'        => 'boolean',
					'description' => 'Pass required to complete lesson',
					'default'     => false,
				),
				'quiz_passmark'               => array(
					'type'        => 'integer',
					'description' => 'Score grade between 0 and 100 required to pass the quiz',
					'default'     => 100,
				),
				'auto_grade'                  => array(
					'type'        => 'boolean',
					'description' => 'Whether auto-grading should take place',
					'default'     => true,
				),
				'allow_retakes'               => array(
					'type'        => 'boolean',
					'description' => 'Allow quizzes to be taken again',
					'default'     => true,
				),
				'show_questions'              => array(
					'type'        => array( 'integer', 'null' ),
					'description' => 'Number of questions to show randomly',
					'default'     => null,
				),
				'random_question_order'       => array(
					'type'        => 'boolean',
					'description' => 'Show questions in a random order',
					'default'     => false,
				),
				'failed_indicate_incorrect'   => array(
					'type'        => array( 'boolean', 'null' ),
					'description' => 'Indicate which questions are incorrect',
					'default'     => null,
				),
				'failed_show_correct_answers' => array(
					'type'        => array( 'boolean', 'null' ),
					'description' => 'Show correct answers',
					'default'     => null,
				),
				'failed_show_answer_feedback' => array(
					'type'        => array( 'boolean', 'null' ),
					'description' => 'Show answer feedback text',
					'default'     => null,
				),
				'button_text_color'           => array(
					'type'        => array( 'string', 'null' ),
					'description' => 'Button text color',
					'default'     => null,
				),
				'button_background_color'     => array(
					'type'        => array( 'string', 'null' ),
					'description' => 'Button background color',
					'default'     => null,
				),
				'pagination'                  => array(
					'type'       => 'object',
					'required'   => true,
					'properties' => array(
						'pagination_number'       => array(
							'type'        => array( 'integer', 'null' ),
							'description' => 'Number of questions per page',
							'default'     => null,
						),
						'show_progress_bar'       => array(
							'type'        => 'boolean',
							'description' => 'Deprecated. Retained for compatibility; no longer controls automatic quiz progress bar rendering.',
							'default'     => false,
						),
						'progress_bar_radius'     => array(
							'type'        => 'integer',
							'description' => 'Deprecated. Retained for compatibility; no longer controls the quiz progress bar radius.',
							'default'     => 6,
						),
						'progress_bar_height'     => array(
							'type'        => 'integer',
							'description' => 'Deprecated. Retained for compatibility; no longer controls the quiz progress bar height.',
							'default'     => 12,
						),
						'progress_bar_background' => array(
							'type'        => array( 'string', 'null' ),
							'description' => 'Deprecated. Retained for compatibility; no longer controls the quiz progress bar background color.',
							'default'     => null,
						),
					),
				),
			),
		);
	}
}