From 28e75ce6ff48ef6b83ff049028e9182481c372b5 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 4 May 2026 15:53:03 +0600 Subject: [PATCH 1/2] fix: Update QuizBuilder and QuizModel to enhance quiz image handling and answer filtering --- classes/QuizBuilder.php | 18 +++++++++--------- models/QuizModel.php | 10 +++++++++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index 71b565f952..3c2168b973 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -19,6 +19,7 @@ use Tutor\Helpers\ValidationHelper; use Tutor\Models\QuizModel; use Tutor\Traits\JsonResponse; +use TUTOR_PRO\QuizImageStorage; /** * Class QuizBuilder @@ -200,7 +201,7 @@ private function delete_replaced_mask_file( $old_mask, $new_mask ) { return; } - if ( false === wp_http_validate_url( $old_mask ) ) { + if ( ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) { return; } @@ -209,18 +210,17 @@ private function delete_replaced_mask_file( $old_mask, $new_mask ) { return; } - $uploads_base_url = trailingslashit( $upload_dir['baseurl'] ); - $uploads_base_dir = trailingslashit( $upload_dir['basedir'] ); - $quiz_image_url = $uploads_base_url . 'tutor/quiz-images/'; - - if ( 0 !== strpos( $old_mask, $quiz_image_url ) ) { + $old_path = QuizImageStorage::quiz_image_stored_value_to_path( $old_mask ); + if ( '' === $old_path || ! is_file( $old_path ) || ! is_readable( $old_path ) ) { return; } - $old_path = str_replace( $uploads_base_url, $uploads_base_dir, $old_mask ); - if ( is_string( $old_path ) && '' !== $old_path && is_file( $old_path ) && is_readable( $old_path ) ) { - wp_delete_file( $old_path ); + $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/'; + if ( 0 !== strpos( $old_path, $quiz_dir ) ) { + return; } + + wp_delete_file( $old_path ); } /** diff --git a/models/QuizModel.php b/models/QuizModel.php index 87c8a229ad..adab588e05 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -1326,7 +1326,15 @@ public static function get_question_answers( $question_id, $question_type = null $answer->image_url = wp_get_attachment_url( $answer->image_id ); } } - return $answers; + + /** + * Filter question answers after loading (e.g. expand stored mask paths for REST/API). + * + * @param array $answers Answer rows. + * @param int $question_id Question id. + * @param string|null $question_type Question type when known. + */ + return apply_filters( 'tutor_quiz_question_answers', $answers, $question_id, $question_type ); } /** From 430f3dc8288e3c9ef6ad12a044090915e4a5d873 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 5 May 2026 13:23:42 +0600 Subject: [PATCH 2/2] feat: Enhance quiz question deletion handling by tracking unsaved mask values for draw, pin, and puzzle types --- .../components/curriculum/QuestionList.tsx | 21 ++ .../entries/course-builder/services/quiz.ts | 4 + assets/src/js/v3/shared/utils/quiz.ts | 23 +- classes/QuizBuilder.php | 295 +++++++++++++++++- 4 files changed, 323 insertions(+), 20 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index 6fffdb0fe9..8c9102f192 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -425,6 +425,27 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { }; const handleDeleteQuestion = (index: number, question: QuizQuestion) => { + if (question._data_status === QuizDataStatus.NEW) { + const isMaskQuestionType = + question.question_type === 'draw_image' || + question.question_type === 'pin_image' || + question.question_type === 'puzzle'; + + if (isMaskQuestionType) { + const tempMaskValues = (question.question_answers || []) + .flatMap((answer) => [answer.answer_two_gap_match, answer.image_url]) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + + if (tempMaskValues.length > 0) { + form.setValue('deleted_temp_mask_values', [ + ...(form.getValues('deleted_temp_mask_values') || []), + ...tempMaskValues, + ]); + } + } + } + removeQuestion(index); if (activeQuestionIndex === index) { diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index 76e4eb224b..89080fa66c 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -67,6 +67,7 @@ interface QuizPayload { payload: QuizResponseWithStatus; deleted_question_ids?: ID[]; deleted_answer_ids?: ID[]; + deleted_temp_mask_values?: string[]; 'content_drip_settings[unlock_date]'?: string; 'content_drip_settings[after_xdays_of_enroll]'?: number; 'content_drip_settings[prerequisites]'?: ID[] | string; @@ -148,6 +149,7 @@ export interface QuizForm { questions: QuizQuestion[]; deleted_question_ids: ID[]; deleted_answer_ids: ID[]; + deleted_temp_mask_values: string[]; } interface QuizUpdateQuestionPayload { @@ -213,6 +215,7 @@ export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse, slotFie questions: (quiz.questions || []).map((question) => convertedQuestion(question)), deleted_question_ids: [], deleted_answer_ids: [], + deleted_temp_mask_values: [], ...Object.fromEntries(slotFields.map((key) => [key, quiz[key as keyof QuizDetailsResponse]])), }; }; @@ -362,6 +365,7 @@ export const convertQuizFormDataToPayload = ( }, deleted_question_ids: formData.deleted_question_ids, deleted_answer_ids: deletedAnswerIds, + deleted_temp_mask_values: formData.deleted_temp_mask_values, ...(isAddonEnabled(Addons.CONTENT_DRIP) && contentDripType === 'unlock_by_date' && { 'content_drip_settings[unlock_date]': formData.quiz_option.content_drip_settings.unlock_date, diff --git a/assets/src/js/v3/shared/utils/quiz.ts b/assets/src/js/v3/shared/utils/quiz.ts index f61f19f8f1..e8e8f3d20a 100644 --- a/assets/src/js/v3/shared/utils/quiz.ts +++ b/assets/src/js/v3/shared/utils/quiz.ts @@ -183,18 +183,23 @@ export const validateQuizQuestion = ( } } - if ( - currentQuestionType === 'draw_image' || - currentQuestionType === 'pin_image' || - currentQuestionType === 'puzzle' - ) { + if (currentQuestionType === 'draw_image' || currentQuestionType === 'pin_image') { const hasMarkedArea = answers.some((answer) => Boolean(answer.answer_two_gap_match)); if (!hasMarkedArea) { return { - message: - currentQuestionType === 'puzzle' - ? __('Please upload a valid puzzle image.', __TUTOR_TEXT_DOMAIN__) - : __('Please mark a valid area on the image.', __TUTOR_TEXT_DOMAIN__), + message: __('Please mark a valid area on the image.', __TUTOR_TEXT_DOMAIN__), + type: 'question', + }; + } + } + + if (currentQuestionType === 'puzzle') { + const hasPuzzleImage = answers.some( + (answer) => Boolean(answer.answer_two_gap_match) || Boolean(answer.image_url), + ); + if (!hasPuzzleImage) { + return { + message: __('Please upload a valid puzzle image.', __TUTOR_TEXT_DOMAIN__), type: 'question', }; } diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index 3c2168b973..b0ec92b23d 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -83,16 +83,24 @@ public function prepare_question_data( $quiz_id, $input ) { * @param int $question_id question id. * @param string $question_type question type. * @param array $input answer data. + * @param string $data_status answer data status. * * @return array */ - public function prepare_answer_data( $question_id, $question_type, $input ) { + public function prepare_answer_data( $question_id, $question_type, $input, $data_status = self::FLAG_NO_CHANGE ) { $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' ); $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT ); $image_id = Input::sanitize( $input['image_id'] ?? null ); // Let the hook handle special cases (e.g. draw_image, pin_image) and return a normalized value (URL). $answer_two_gap_match_raw = isset( $input['answer_two_gap_match'] ) ? wp_unslash( $input['answer_two_gap_match'] ) : ''; - $answer_two_gap_match_raw = apply_filters( 'tutor_save_quiz_draw_image_mask', $answer_two_gap_match_raw, $question_type ); + $answer_two_gap_match_raw = apply_filters( + 'tutor_save_quiz_draw_image_mask', + $answer_two_gap_match_raw, + $question_type, + array( + 'data_status' => $data_status, + ) + ); $answer_two_gap_match = Input::sanitize( $answer_two_gap_match_raw ?? '', '' ); $answer_view_format = Input::sanitize( $input['answer_view_format'] ?? '' ); $answer_settings = null; @@ -129,7 +137,7 @@ public function save_question_answers( $question_id, $question_type, $question_a $answer_order = 0; foreach ( $question_answers as $answer ) { $data_status = isset( $answer[ self::TRACKING_KEY ] ) ? $answer[ self::TRACKING_KEY ] : self::FLAG_NO_CHANGE; - $answer_data = $this->prepare_answer_data( $question_id, $question_type, $answer ); + $answer_data = $this->prepare_answer_data( $question_id, $question_type, $answer, $data_status ); // New answer. if ( self::FLAG_NEW === $data_status ) { @@ -372,18 +380,34 @@ public function validate_payload( $payload ) { * * @param array $deleted_question_ids question ids. * @param array $deleted_answer_ids answer ids. + * @param array $deleted_temp_mask_values unsaved draw/pin/puzzle mask values. * * @return void */ - public function handle_delete( $deleted_question_ids = array(), $deleted_answer_ids = array() ) { + public function handle_delete( $deleted_question_ids = array(), $deleted_answer_ids = array(), $deleted_temp_mask_values = array() ) { global $wpdb; - $deleted_question_ids = array_filter( $deleted_question_ids, 'is_numeric' ); - $deleted_answer_ids = array_filter( $deleted_answer_ids, 'is_numeric' ); + $deleted_question_ids = array_filter( $deleted_question_ids, 'is_numeric' ); + $deleted_answer_ids = array_filter( $deleted_answer_ids, 'is_numeric' ); + $deleted_temp_mask_values = is_array( $deleted_temp_mask_values ) ? array_values( array_filter( array_map( 'strval', $deleted_temp_mask_values ) ) ) : array(); + $question_file_paths = array(); + $mask_question_ids = array(); if ( count( $deleted_question_ids ) ) { + $mask_question_ids = $this->get_deletable_mask_question_ids( $deleted_question_ids ); + + if ( count( $mask_question_ids ) ) { + $question_file_paths = $this->get_question_file_paths_for_deletion( $mask_question_ids ); + + $in_clause = QueryHelper::prepare_in_clause( $mask_question_ids ); + // Only remove answers automatically for file-based quiz types (draw/pin/puzzle). + //phpcs:ignore -- sanitized $in_clause. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN ({$in_clause})" ) ); + } + $in_clause = QueryHelper::prepare_in_clause( $deleted_question_ids ); - //phpcs:ignore -- sanitized $in_clause. - $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_questions WHERE content_id IS NULL AND question_id IN ({$in_clause})" ) ); + // Preserve previous behavior for all non file-based quiz types and linked-content hooks. + //phpcs:ignore -- sanitized $in_clause. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_questions WHERE content_id IS NULL AND question_id IN ({$in_clause})" ) ); do_action( 'tutor_deleted_quiz_question_ids', $deleted_question_ids ); } @@ -392,6 +416,254 @@ public function handle_delete( $deleted_question_ids = array(), $deleted_answer_ //phpcs:ignore -- sanitized $in_clause. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE answer_id IN ({$in_clause})" ) ); } + + if ( count( $question_file_paths ) ) { + QuizModel::delete_files_by_paths( $question_file_paths ); + } + + if ( count( $deleted_temp_mask_values ) ) { + $temp_file_paths = $this->get_temp_mask_file_paths_for_deletion( $deleted_temp_mask_values ); + if ( count( $temp_file_paths ) ) { + QuizModel::delete_files_by_paths( $temp_file_paths ); + } + } + } + + /** + * Get deletable question IDs for file-based question types. + * + * Only draw/pin/puzzle are selected to keep behavior unchanged for other quiz types. + * + * @since 4.0.0 + * + * @param array $question_ids Question IDs from payload. + * + * @return int[] + */ + private function get_deletable_mask_question_ids( array $question_ids ) { + $question_ids = array_map( 'intval', array_filter( $question_ids, 'is_numeric' ) ); + if ( empty( $question_ids ) ) { + return array(); + } + + $question_rows = QueryHelper::get_all( + QueryHelper::prepare_table_name( 'tutor_quiz_questions' ), + array( + 'question_id' => array( 'IN', $question_ids ), + 'content_id' => array( 'IS', 'NULL' ), + 'question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ), + ), + 'question_id', + -1 + ); + + if ( empty( $question_rows ) ) { + return array(); + } + + return array_values( + array_unique( + array_map( + static function ( $row ) { + return (int) ( $row->question_id ?? 0 ); + }, + $question_rows + ) + ) + ); + } + + /** + * Collect draw/pin/puzzle files linked to question answers before question deletion. + * + * @since 4.0.0 + * + * @param array $question_ids Question IDs that will be deleted. + * + * @return string[] + */ + private function get_question_file_paths_for_deletion( array $question_ids ) { + $paths = array(); + + if ( empty( $question_ids ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) { + return $paths; + } + + $answers = QueryHelper::get_all( + QueryHelper::prepare_table_name( 'tutor_quiz_question_answers' ), + array( + 'belongs_question_id' => array( 'IN', $question_ids ), + 'belongs_question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ), + ), + 'answer_id', + -1 + ); + + if ( empty( $answers ) ) { + return $paths; + } + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return $paths; + } + + $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/'; + + foreach ( $answers as $answer ) { + $stored = isset( $answer->answer_two_gap_match ) ? trim( (string) $answer->answer_two_gap_match ) : ''; + if ( '' === $stored ) { + continue; + } + + $path = $this->resolve_mask_stored_value_to_path( $stored ); + + if ( '' === $path && 'puzzle' === ( $answer->belongs_question_type ?? '' ) ) { + $payload = json_decode( stripslashes( $stored ), true ); + if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) { + $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] ); + } + } + + if ( '' === $path || ! is_readable( $path ) ) { + continue; + } + + if ( 0 !== strpos( $path, $quiz_dir ) ) { + continue; + } + + $paths[] = $path; + } + + return array_values( array_unique( $paths ) ); + } + + /** + * Resolve a stored draw/pin/puzzle mask value to local file path. + * + * Supports basename, uploads-relative path, uploads URL, and absolute uploads path. + * + * @since 4.0.0 + * + * @param string $stored Stored mask value from answer_two_gap_match. + * + * @return string + */ + private function resolve_mask_stored_value_to_path( $stored ) { + $stored = is_string( $stored ) ? trim( stripslashes( $stored ) ) : ''; + $stored = trim( $stored, "\"' \t\n\r\0\x0B" ); + $stored = str_replace( '\\/', '/', $stored ); + + if ( '' === $stored ) { + return ''; + } + + $path = QuizImageStorage::quiz_image_stored_value_to_path( $stored ); + if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { + return $path; + } + + $basename = QuizImageStorage::sanitize_quiz_image_filename( wp_basename( str_replace( '\\', '/', $stored ) ) ); + if ( '' !== $basename ) { + $path = QuizImageStorage::quiz_image_stored_value_to_path( $basename ); + if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { + return $path; + } + } + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return ''; + } + + $uploads_base_dir = trailingslashit( str_replace( '\\', '/', (string) $upload_dir['basedir'] ) ); + $uploads_marker = '/wp-content/uploads/'; + + $url_path = wp_parse_url( $stored, PHP_URL_PATH ); + $url_path = is_string( $url_path ) ? $url_path : ''; + $marker_pos = '' !== $url_path ? strpos( $url_path, $uploads_marker ) : false; + if ( false !== $marker_pos ) { + $relative = ltrim( substr( $url_path, $marker_pos + strlen( $uploads_marker ) ), '/' ); + $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative ); + if ( '' !== $relative ) { + $resolved = $uploads_base_dir . $relative; + if ( is_file( $resolved ) && is_readable( $resolved ) ) { + return $resolved; + } + } + } + + $is_abs_path = '/' === substr( $stored, 0, 1 ); + if ( ! $is_abs_path ) { + return ''; + } + + $marker_pos = strpos( $stored, $uploads_marker ); + if ( false === $marker_pos ) { + return ''; + } + + $relative = ltrim( substr( $stored, $marker_pos + strlen( $uploads_marker ) ), '/' ); + $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative ); + if ( '' === $relative ) { + return ''; + } + + $resolved = $uploads_base_dir . $relative; + return ( is_file( $resolved ) && is_readable( $resolved ) ) ? $resolved : ''; + } + + /** + * Collect temp draw/pin/puzzle file paths from unsaved question deletions. + * + * @since 4.0.0 + * + * @param string[] $stored_values Stored values coming from quiz builder payload. + * + * @return string[] + */ + private function get_temp_mask_file_paths_for_deletion( array $stored_values ) { + $paths = array(); + + if ( empty( $stored_values ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) { + return $paths; + } + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return $paths; + } + + $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/'; + + foreach ( $stored_values as $stored ) { + $stored = is_string( $stored ) ? trim( $stored ) : ''; + if ( '' === $stored ) { + continue; + } + + $path = $this->resolve_mask_stored_value_to_path( $stored ); + + if ( '' === $path && ( false !== strpos( $stored, '{' ) || false !== strpos( $stored, 'playground_snapshot_file' ) ) ) { + $payload = json_decode( stripslashes( $stored ), true ); + if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) { + $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] ); + } + } + + if ( '' === $path || ! is_readable( $path ) ) { + continue; + } + + if ( 0 !== strpos( $path, $quiz_dir ) ) { + continue; + } + + $paths[] = $path; + } + + return array_values( array_unique( $paths ) ); } /** @@ -459,9 +731,10 @@ public function save_quiz( $topic_id, $payload ) { } // Delete questions and answers. - $deleted_question_ids = Input::post( 'deleted_question_ids', array(), Input::TYPE_ARRAY ); - $deleted_answer_ids = Input::post( 'deleted_answer_ids', array(), Input::TYPE_ARRAY ); - $this->handle_delete( $deleted_question_ids, $deleted_answer_ids ); + $deleted_question_ids = Input::post( 'deleted_question_ids', array(), Input::TYPE_ARRAY ); + $deleted_answer_ids = Input::post( 'deleted_answer_ids', array(), Input::TYPE_ARRAY ); + $deleted_temp_mask_values = Input::post( 'deleted_temp_mask_values', array(), Input::TYPE_ARRAY ); + $this->handle_delete( $deleted_question_ids, $deleted_answer_ids, $deleted_temp_mask_values ); $wpdb->query( 'COMMIT' );