PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.5
Tutor LMS – eLearning and online course solution v4.0.5
4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 3.9.15 3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0 1.2.1 1.2.11 1.2.12 1.2.13 1.2.20 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.15 1.9.16 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.4.0 2.5.0 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.1.0 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 3.9.1 3.9.10 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9
tutor / classes / QuizBuilder.php
tutor / classes Last commit date
Addons.php 1 month ago Admin.php 3 weeks ago Ajax.php 1 month ago Announcements.php 1 month ago Assets.php 2 weeks ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 1 month ago Container.php 1 year ago Course.php 3 days ago Course_Embed.php 3 years ago Course_Filter.php 1 month ago Course_List.php 1 month ago Course_Settings_Tabs.php 1 month ago Course_Widget.php 1 year ago Custom_Validation.php 1 month ago Dashboard.php 1 month ago Earnings.php 10 months ago FormHandler.php 1 month ago Frontend.php 1 month ago Gutenberg.php 1 year ago Icon.php 3 days ago Input.php 1 month ago Instructor.php 1 month ago Instructors_List.php 1 month ago Lesson.php 1 month ago Options_V2.php 1 month ago Permalink.php 1 month ago Post_types.php 1 month ago Private_Course_Access.php 1 month ago Q_And_A.php 1 month ago Question_Answers_List.php 1 year ago Quiz.php 3 days ago QuizBuilder.php 3 days ago Quiz_Attempts_List.php 1 month ago RestAPI.php 1 month ago Reviews.php 1 month ago Rewrite_Rules.php 2 years ago SampleCourse.php 1 month ago Shortcode.php 1 month ago Singleton.php 1 year ago Student.php 1 month ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 1 month ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 2 months ago Tutor.php 3 days ago TutorEDD.php 1 month ago Tutor_Base.php 2 years ago Tutor_Setup.php 1 month ago Upgrader.php 1 month ago User.php 2 weeks ago UserPreference.php 1 month ago Utils.php 3 days ago Video_Stream.php 3 years ago WhatsNew.php 1 month ago Withdraw.php 1 month ago Withdraw_Requests_List.php 1 year ago WooCommerce.php 3 days ago
QuizBuilder.php
959 lines
1 <?php
2 /**
3 * Quiz Builder
4 *
5 * @package Tutor\Classes
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 3.0.0
9 */
10
11 namespace TUTOR;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 use Tutor\Helpers\HttpHelper;
18 use Tutor\Helpers\QueryHelper;
19 use Tutor\Helpers\ValidationHelper;
20 use Tutor\Models\QuizModel;
21 use Tutor\Traits\JsonResponse;
22 use TUTOR_PRO\QuizImageStorage;
23 use WP_Error;
24
25 /**
26 * Class QuizBuilder
27 *
28 * @since 1.0.0
29 */
30 class QuizBuilder {
31
32 /**
33 * Store question ids of current quiz.
34 *
35 * @since 4.0.5
36 *
37 * @var array|null
38 */
39 private $current_quiz_question_ids = null;
40
41 /**
42 * Store answer ids of current quiz.
43 *
44 * @since 4.0.5
45 *
46 * @var array|null
47 */
48 private $current_quiz_answer_ids = null;
49
50 /**
51 * Store current quiz details
52 *
53 * @since 4.0.5
54 *
55 * @var object|null
56 */
57 private $current_quiz = null;
58
59 use JsonResponse;
60
61 const TRACKING_KEY = '_data_status';
62 const FLAG_NEW = 'new';
63 const FLAG_UPDATE = 'update';
64 const FLAG_NO_CHANGE = 'no_change';
65
66 /**
67 * Register hooks and dependencies.
68 *
69 * @param boolean $register_hooks register hooks or not.
70 */
71 public function __construct( $register_hooks = true ) {
72 if ( ! $register_hooks ) {
73 return;
74 }
75
76 add_action( 'wp_ajax_tutor_quiz_builder_save', array( $this, 'ajax_quiz_builder_save' ) );
77 }
78
79
80 /**
81 * Prepare question data.
82 *
83 * @since 3.0.0
84 *
85 * @param int $quiz_id quiz id.
86 * @param array $input question data.
87 *
88 * @return array
89 */
90 public function prepare_question_data( $quiz_id, $input ) {
91 $question_title = Input::sanitize( wp_slash( $input['question_title'] ), '' );
92 $question_description = Input::sanitize( wp_slash( $input['question_description'] ) ?? '', '', Input::TYPE_KSES_POST );
93 $question_type = Input::sanitize( $input['question_type'], '' );
94 $question_mark = Input::sanitize( $input['question_mark'], 1, Input::TYPE_INT );
95 $question_settings = Input::sanitize_array( $input['question_settings'] );
96
97 $data = array(
98 'quiz_id' => $quiz_id,
99 'question_title' => $question_title,
100 'question_description' => $question_description,
101 'question_type' => $question_type,
102 'question_mark' => $question_mark,
103 'question_settings' => maybe_serialize( $question_settings ),
104 );
105
106 return apply_filters( 'tutor_quiz_question_data', $data, $input );
107 }
108
109 /**
110 * Prepare answer data.
111 *
112 * @param int $question_id question id.
113 * @param string $question_type question type.
114 * @param array $input answer data.
115 * @param string $data_status answer data status.
116 *
117 * @return array
118 */
119 public function prepare_answer_data( $question_id, $question_type, $input, $data_status = self::FLAG_NO_CHANGE ) {
120 $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' );
121 $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT );
122 $image_id = Input::sanitize( $input['image_id'] ?? null );
123 // Let the hook handle special cases (e.g. draw_image, pin_image) and return a normalized value (URL).
124 $answer_two_gap_match_raw = isset( $input['answer_two_gap_match'] ) ? wp_unslash( $input['answer_two_gap_match'] ) : '';
125 $answer_two_gap_match_raw = apply_filters(
126 'tutor_save_quiz_draw_image_mask',
127 $answer_two_gap_match_raw,
128 $question_type,
129 array(
130 'data_status' => $data_status,
131 )
132 );
133 $answer_two_gap_match = Input::sanitize( $answer_two_gap_match_raw ?? '', '' );
134 $answer_view_format = Input::sanitize( $input['answer_view_format'] ?? '' );
135 $answer_settings = null;
136
137 $answer_data = array(
138 'belongs_question_id' => $question_id,
139 'belongs_question_type' => $question_type,
140 'answer_title' => $answer_title,
141 'is_correct' => $is_correct,
142 'image_id' => $image_id,
143 'answer_two_gap_match' => $answer_two_gap_match,
144 'answer_view_format' => $answer_view_format,
145 'answer_settings' => $answer_settings,
146 );
147
148 return $answer_data;
149 }
150
151 /**
152 * Save question answers.
153 *
154 * @since 3.7.0
155 *
156 * @param int $question_id question id.
157 * @param string $question_type question type.
158 * @param array $question_answers question answers.
159 *
160 * @return void
161 */
162 public function save_question_answers( $question_id, $question_type, $question_answers ) {
163 global $wpdb;
164 $answers_table = $wpdb->prefix . 'tutor_quiz_question_answers';
165
166 $answer_order = 0;
167 foreach ( $question_answers as $answer ) {
168 $data_status = isset( $answer[ self::TRACKING_KEY ] ) ? $answer[ self::TRACKING_KEY ] : self::FLAG_NO_CHANGE;
169 $answer_data = $this->prepare_answer_data( $question_id, $question_type, $answer, $data_status );
170
171 // New answer.
172 if ( self::FLAG_NEW === $data_status ) {
173 $wpdb->insert( $answers_table, $answer_data );
174 $answer_id = $wpdb->insert_id;
175 }
176
177 // Update answer.
178 if ( self::FLAG_UPDATE === $data_status ) {
179 $answer_id = $answer['answer_id'];
180 $old_mask = '';
181 if ( $this->is_mask_image_question_type( $question_type ) ) {
182 $old_answer = QueryHelper::get_row( $answers_table, array( 'answer_id' => (int) $answer_id ), 'answer_id' );
183 $old_mask = is_object( $old_answer ) ? (string) ( $old_answer->answer_two_gap_match ?? '' ) : '';
184 }
185 $wpdb->update(
186 $answers_table,
187 $answer_data,
188 array( 'answer_id' => $answer_id )
189 );
190 if ( $this->is_mask_image_question_type( $question_type ) ) {
191 $new_mask = (string) ( $answer_data['answer_two_gap_match'] ?? '' );
192 $this->delete_replaced_mask_file( $old_mask, $new_mask );
193 }
194 }
195
196 if ( self::FLAG_NO_CHANGE === $data_status ) {
197 $answer_id = $answer['answer_id'];
198 }
199
200 // Save sort order.
201 ++$answer_order;
202 $wpdb->update(
203 $answers_table,
204 array( 'answer_order' => $answer_order ),
205 array( 'answer_id' => $answer_id )
206 );
207 }
208 }
209
210 /**
211 * Whether question type stores a mask image URL in answer_two_gap_match.
212 *
213 * @since 4.0.0
214 *
215 * @param string $question_type Question type.
216 *
217 * @return bool
218 */
219 private function is_mask_image_question_type( $question_type ) {
220 return in_array( $question_type, array( 'draw_image', 'pin_image' ), true );
221 }
222
223 /**
224 * Delete old mask file if it was replaced with a different mask URL.
225 *
226 * @since 4.0.0
227 *
228 * @param string $old_mask Previous mask URL.
229 * @param string $new_mask New mask URL.
230 *
231 * @return void
232 */
233 private function delete_replaced_mask_file( $old_mask, $new_mask ) {
234 $old_mask = trim( (string) $old_mask );
235 $new_mask = trim( (string) $new_mask );
236
237 if ( '' === $old_mask || $old_mask === $new_mask ) {
238 return;
239 }
240
241 if ( ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
242 return;
243 }
244
245 $upload_dir = wp_upload_dir();
246 if ( ! empty( $upload_dir['error'] ) ) {
247 return;
248 }
249
250 $old_path = QuizImageStorage::quiz_image_stored_value_to_path( $old_mask );
251 if ( '' === $old_path || ! is_file( $old_path ) || ! is_readable( $old_path ) ) {
252 return;
253 }
254
255 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
256 if ( 0 !== strpos( $old_path, $quiz_dir ) ) {
257 return;
258 }
259
260 wp_delete_file( $old_path );
261 }
262
263 /**
264 * Save quiz questions.
265 *
266 * @since 3.0.0
267 *
268 * @param int $quiz_id quiz id.
269 * @param array $questions questions data.
270 *
271 * @return void
272 *
273 * @throws \Exception When saving a draw_image question while Legacy learning mode is enabled.
274 */
275 public function save_questions( $quiz_id, $questions ) {
276 global $wpdb;
277 $questions_table = $wpdb->prefix . 'tutor_quiz_questions';
278
279 $question_order = 0;
280 foreach ( $questions as $question ) {
281 $data_status = isset( $question[ self::TRACKING_KEY ] ) ? $question[ self::TRACKING_KEY ] : self::FLAG_NO_CHANGE;
282 ++$question_order;
283 if ( isset( $question['is_cb_question'], $question['cb_action'] ) && 'link' === $question['cb_action'] ) {
284 $question['question_order'] = $question_order;
285 do_action( 'tutor_content_bank_question_linked_to_quiz', $quiz_id, (object) $question );
286 continue;
287 }
288
289 $question_type = Input::sanitize( $question['question_type'] );
290 if ( 'draw_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
291 throw new \Exception( esc_html__( 'Image Marking questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
292 }
293 if ( 'pin_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
294 throw new \Exception( esc_html__( 'Pin questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
295 }
296 if ( 'scale' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
297 throw new \Exception( esc_html__( 'Range questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
298 }
299 if ( 'puzzle' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
300 throw new \Exception( esc_html__( 'Puzzle questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
301 }
302 $question_data = $this->prepare_question_data( $quiz_id, $question );
303 $question_answers = isset( $question['question_answers'] ) ? $question['question_answers'] : array();
304
305 // New question.
306 if ( self::FLAG_NEW === $data_status ) {
307 $wpdb->insert( $questions_table, $question_data );
308 $question_id = $wpdb->insert_id;
309
310 if ( isset( $question['is_cb_question'] ) ) {
311 $question['question_order'] = $question_order;
312 $question['new_question_id'] = $question_id;
313 do_action( 'tutor_content_bank_question_added_to_quiz', $quiz_id, (object) $question );
314 }
315 }
316
317 // Update question.
318 if ( self::FLAG_UPDATE === $data_status ) {
319 $question_id = (int) $question['question_id'];
320 $wpdb->update(
321 $questions_table,
322 $question_data,
323 array( 'question_id' => $question_id )
324 );
325 }
326
327 if ( self::FLAG_NO_CHANGE === $data_status ) {
328 $question_id = $question['question_id'];
329 }
330
331 // Save sort order.
332 $wpdb->update(
333 $questions_table,
334 array( 'question_order' => $question_order ),
335 array( 'question_id' => $question_id )
336 );
337
338 // Save question's answers.
339 $this->save_question_answers( $question_id, $question_type, $question_answers );
340 }
341 }
342
343 /**
344 * Validate payload.
345 *
346 * @since 3.0.0
347 *
348 * @since 4.0.5 Quiz's question & answer id discrepancy validation added
349 *
350 * @param array $payload payload.
351 *
352 * @return object consist success, errors.
353 */
354 public function validate_payload( $payload ) {
355 $errors = array();
356 $success = true;
357
358 if ( ! is_array( $payload ) ) {
359 $success = false;
360 $errors['payload'] = __( 'Invalid payload', 'tutor' );
361 }
362
363 $quiz_id = (int) $payload['ID'] ?? 0;
364 $questions = $payload['questions'] ?? array();
365 $quiz = get_post( $quiz_id );
366
367 if ( $quiz_id && ( ! $quiz || tutor()->quiz_post_type !== $quiz->post_type ) ) {
368 $success = false;
369 $errors['ID'][] = __( 'Invalid quiz id provided', 'tutor' );
370 }
371
372 if ( $quiz && ! current_user_can( 'edit_post', $quiz_id ) ) {
373 $success = false;
374 $errors['permission'][] = __( 'You do not have permission to edit this quiz', 'tutor' );
375 }
376
377 if ( ! $success ) {
378 return (object) array(
379 'success' => $success,
380 'errors' => $errors,
381 );
382 }
383
384 $rules = array(
385 'post_title' => 'required',
386 'quiz_option' => 'required|is_array',
387 'questions' => 'required|is_array',
388 );
389
390 $validation = ValidationHelper::validate(
391 $rules,
392 $payload
393 );
394
395 if ( ! $validation->success ) {
396 $success = false;
397 $errors = array_merge( $errors, $validation->errors );
398 }
399
400 if ( $quiz && ! empty( $questions ) ) {
401 $this->set_current_quiz_questions_answer_ids( $quiz_id );
402 try {
403 $this->is_valid_quiz_question_answer_payload( $questions );
404 } catch ( \Throwable $th ) {
405 $success = false;
406 $errors['bad_request'][] = $th->getMessage();
407 }
408 }
409
410 $deleted_question_ids = Input::post( 'deleted_question_ids', array(), Input::TYPE_ARRAY );
411 $deleted_answer_ids = Input::post( 'deleted_answer_ids', array(), Input::TYPE_ARRAY );
412
413 // Validate deleted question ids are valid.
414 if ( $deleted_question_ids ) {
415 if ( $quiz ) {
416 $is_diff = array_diff( $deleted_question_ids, $this->current_quiz_question_ids );
417 if ( ! empty( $is_diff ) ) {
418 $success = false;
419 $errors['bad_request'][] = __( 'Invalid deleted question id found', 'tutor' );
420 }
421 } else {
422 $success = false;
423 $errors['bad_request'][] = __( 'Invalid deleted question id found', 'tutor' );
424 }
425 }
426
427 // Validate deleted answer ids are valid.
428 if ( $deleted_answer_ids ) {
429 if ( $quiz ) {
430 $is_diff = array_diff( $deleted_answer_ids, $this->current_quiz_answer_ids );
431 if ( ! empty( $is_diff ) ) {
432 $success = false;
433 $errors['bad_request'][] = __( 'Invalid deleted answer id found', 'tutor' );
434 }
435 } else {
436 $success = false;
437 $errors['bad_request'][] = __( 'Invalid deleted answer id found', 'tutor' );
438 }
439 }
440
441 foreach ( $questions as $question ) {
442 if ( ! isset( $question[ self::TRACKING_KEY ] ) ) {
443 $success = false;
444 // translators: %s is the tracking key required for each question.
445 $errors[ self::TRACKING_KEY ][] = sprintf( __( '%s is required for each question', 'tutor' ), self::TRACKING_KEY );
446 break;
447 }
448
449 if ( ! in_array( $question[ self::TRACKING_KEY ], array( self::FLAG_NEW, self::FLAG_UPDATE, self::FLAG_NO_CHANGE ), true ) ) {
450 $success = false;
451 // translators: %s is the tracking key for which the value is invalid.
452 $errors[ self::TRACKING_KEY ][] = sprintf( __( 'Invalid value for %s', 'tutor' ), self::TRACKING_KEY );
453 break;
454 }
455
456 if ( ! isset( $question['question_settings'] ) || ! is_array( $question['question_settings'] ) ) {
457 $success = false;
458 $errors['question_settings'][] = __( 'Question settings is required with array data', 'tutor' );
459 break;
460 }
461 }
462
463 return (object) array(
464 'success' => $success,
465 'errors' => $errors,
466 );
467 }
468
469 /**
470 * Handle delete questions and answers.
471 *
472 * @since 3.0.0
473 *
474 * @param array $deleted_question_ids question ids.
475 * @param array $deleted_answer_ids answer ids.
476 * @param array $deleted_temp_mask_values unsaved draw/pin/puzzle mask values.
477 *
478 * @return void
479 */
480 public function handle_delete( $deleted_question_ids = array(), $deleted_answer_ids = array(), $deleted_temp_mask_values = array() ) {
481 global $wpdb;
482 $deleted_question_ids = array_filter( $deleted_question_ids, 'is_numeric' );
483 $deleted_answer_ids = array_filter( $deleted_answer_ids, 'is_numeric' );
484 $deleted_temp_mask_values = is_array( $deleted_temp_mask_values ) ? array_values( array_filter( array_map( 'strval', $deleted_temp_mask_values ) ) ) : array();
485 $question_file_paths = array();
486 $mask_question_ids = array();
487
488 if ( count( $deleted_question_ids ) ) {
489 $mask_question_ids = $this->get_deletable_mask_question_ids( $deleted_question_ids );
490
491 if ( count( $mask_question_ids ) ) {
492 $question_file_paths = $this->get_question_file_paths_for_deletion( $mask_question_ids );
493
494 $in_clause = QueryHelper::prepare_in_clause( $mask_question_ids );
495 // Only remove answers automatically for file-based quiz types (draw/pin/puzzle).
496 //phpcs:ignore -- sanitized $in_clause.
497 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN ({$in_clause})" ) );
498 }
499
500 $in_clause = QueryHelper::prepare_in_clause( $deleted_question_ids );
501 // Preserve previous behavior for all non file-based quiz types and linked-content hooks.
502 //phpcs:ignore -- sanitized $in_clause.
503 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_questions WHERE content_id IS NULL AND question_id IN ({$in_clause})" ) );
504 do_action( 'tutor_deleted_quiz_question_ids', $deleted_question_ids );
505 }
506
507 if ( count( $deleted_answer_ids ) ) {
508 $in_clause = QueryHelper::prepare_in_clause( $deleted_answer_ids );
509 //phpcs:ignore -- sanitized $in_clause.
510 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE answer_id IN ({$in_clause})" ) );
511 }
512
513 if ( count( $question_file_paths ) ) {
514 QuizModel::delete_files_by_paths( $question_file_paths );
515 }
516
517 if ( count( $deleted_temp_mask_values ) ) {
518 $temp_file_paths = $this->get_temp_mask_file_paths_for_deletion( $deleted_temp_mask_values );
519 if ( count( $temp_file_paths ) ) {
520 QuizModel::delete_files_by_paths( $temp_file_paths );
521 }
522 }
523 }
524
525 /**
526 * Get deletable question IDs for file-based question types.
527 *
528 * Only draw/pin/puzzle are selected to keep behavior unchanged for other quiz types.
529 *
530 * @since 4.0.0
531 *
532 * @param array $question_ids Question IDs from payload.
533 *
534 * @return int[]
535 */
536 private function get_deletable_mask_question_ids( array $question_ids ) {
537 $question_ids = array_map( 'intval', array_filter( $question_ids, 'is_numeric' ) );
538 if ( empty( $question_ids ) ) {
539 return array();
540 }
541
542 $question_rows = QueryHelper::get_all(
543 QueryHelper::prepare_table_name( 'tutor_quiz_questions' ),
544 array(
545 'question_id' => array( 'IN', $question_ids ),
546 'content_id' => array( 'IS', 'NULL' ),
547 'question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ),
548 ),
549 'question_id',
550 -1
551 );
552
553 if ( empty( $question_rows ) ) {
554 return array();
555 }
556
557 return array_values(
558 array_unique(
559 array_map(
560 static function ( $row ) {
561 return (int) ( $row->question_id ?? 0 );
562 },
563 $question_rows
564 )
565 )
566 );
567 }
568
569 /**
570 * Collect draw/pin/puzzle files linked to question answers before question deletion.
571 *
572 * @since 4.0.0
573 *
574 * @param array $question_ids Question IDs that will be deleted.
575 *
576 * @return string[]
577 */
578 private function get_question_file_paths_for_deletion( array $question_ids ) {
579 $paths = array();
580
581 if ( empty( $question_ids ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
582 return $paths;
583 }
584
585 $answers = QueryHelper::get_all(
586 QueryHelper::prepare_table_name( 'tutor_quiz_question_answers' ),
587 array(
588 'belongs_question_id' => array( 'IN', $question_ids ),
589 'belongs_question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ),
590 ),
591 'answer_id',
592 -1
593 );
594
595 if ( empty( $answers ) ) {
596 return $paths;
597 }
598
599 $upload_dir = wp_upload_dir();
600 if ( ! empty( $upload_dir['error'] ) ) {
601 return $paths;
602 }
603
604 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
605
606 foreach ( $answers as $answer ) {
607 $stored = isset( $answer->answer_two_gap_match ) ? trim( (string) $answer->answer_two_gap_match ) : '';
608 if ( '' === $stored ) {
609 continue;
610 }
611
612 $path = $this->resolve_mask_stored_value_to_path( $stored );
613
614 if ( '' === $path && 'puzzle' === ( $answer->belongs_question_type ?? '' ) ) {
615 $payload = json_decode( stripslashes( $stored ), true );
616 if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) {
617 $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] );
618 }
619 }
620
621 if ( '' === $path || ! is_readable( $path ) ) {
622 continue;
623 }
624
625 if ( 0 !== strpos( $path, $quiz_dir ) ) {
626 continue;
627 }
628
629 $paths[] = $path;
630 }
631
632 return array_values( array_unique( $paths ) );
633 }
634
635 /**
636 * Resolve a stored draw/pin/puzzle mask value to local file path.
637 *
638 * Supports basename, uploads-relative path, uploads URL, and absolute uploads path.
639 *
640 * @since 4.0.0
641 *
642 * @param string $stored Stored mask value from answer_two_gap_match.
643 *
644 * @return string
645 */
646 private function resolve_mask_stored_value_to_path( $stored ) {
647 $stored = is_string( $stored ) ? trim( stripslashes( $stored ) ) : '';
648 $stored = trim( $stored, "\"' \t\n\r\0\x0B" );
649 $stored = str_replace( '\\/', '/', $stored );
650
651 if ( '' === $stored ) {
652 return '';
653 }
654
655 $path = QuizImageStorage::quiz_image_stored_value_to_path( $stored );
656 if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) {
657 return $path;
658 }
659
660 $basename = QuizImageStorage::sanitize_quiz_image_filename( wp_basename( str_replace( '\\', '/', $stored ) ) );
661 if ( '' !== $basename ) {
662 $path = QuizImageStorage::quiz_image_stored_value_to_path( $basename );
663 if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) {
664 return $path;
665 }
666 }
667
668 $upload_dir = wp_upload_dir();
669 if ( ! empty( $upload_dir['error'] ) ) {
670 return '';
671 }
672
673 $uploads_base_dir = trailingslashit( str_replace( '\\', '/', (string) $upload_dir['basedir'] ) );
674 $uploads_marker = '/wp-content/uploads/';
675
676 $url_path = wp_parse_url( $stored, PHP_URL_PATH );
677 $url_path = is_string( $url_path ) ? $url_path : '';
678 $marker_pos = '' !== $url_path ? strpos( $url_path, $uploads_marker ) : false;
679 if ( false !== $marker_pos ) {
680 $relative = ltrim( substr( $url_path, $marker_pos + strlen( $uploads_marker ) ), '/' );
681 $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative );
682 if ( '' !== $relative ) {
683 $resolved = $uploads_base_dir . $relative;
684 if ( is_file( $resolved ) && is_readable( $resolved ) ) {
685 return $resolved;
686 }
687 }
688 }
689
690 $is_abs_path = '/' === substr( $stored, 0, 1 );
691 if ( ! $is_abs_path ) {
692 return '';
693 }
694
695 $marker_pos = strpos( $stored, $uploads_marker );
696 if ( false === $marker_pos ) {
697 return '';
698 }
699
700 $relative = ltrim( substr( $stored, $marker_pos + strlen( $uploads_marker ) ), '/' );
701 $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative );
702 if ( '' === $relative ) {
703 return '';
704 }
705
706 $resolved = $uploads_base_dir . $relative;
707 return ( is_file( $resolved ) && is_readable( $resolved ) ) ? $resolved : '';
708 }
709
710 /**
711 * Collect temp draw/pin/puzzle file paths from unsaved question deletions.
712 *
713 * @since 4.0.0
714 *
715 * @param string[] $stored_values Stored values coming from quiz builder payload.
716 *
717 * @return string[]
718 */
719 private function get_temp_mask_file_paths_for_deletion( array $stored_values ) {
720 $paths = array();
721
722 if ( empty( $stored_values ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
723 return $paths;
724 }
725
726 $upload_dir = wp_upload_dir();
727 if ( ! empty( $upload_dir['error'] ) ) {
728 return $paths;
729 }
730
731 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
732
733 foreach ( $stored_values as $stored ) {
734 $stored = is_string( $stored ) ? trim( $stored ) : '';
735 if ( '' === $stored ) {
736 continue;
737 }
738
739 $path = $this->resolve_mask_stored_value_to_path( $stored );
740
741 if ( '' === $path && ( false !== strpos( $stored, '{' ) || false !== strpos( $stored, 'playground_snapshot_file' ) ) ) {
742 $payload = json_decode( stripslashes( $stored ), true );
743 if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) {
744 $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] );
745 }
746 }
747
748 if ( '' === $path || ! is_readable( $path ) ) {
749 continue;
750 }
751
752 if ( 0 !== strpos( $path, $quiz_dir ) ) {
753 continue;
754 }
755
756 $paths[] = $path;
757 }
758
759 return array_values( array_unique( $paths ) );
760 }
761
762 /**
763 * Create or update quiz.
764 *
765 * @since 3.0.0
766 *
767 * @param int $topic_id topic id.
768 * @param array $payload payload.
769 *
770 * @return object consist success, errors.
771 */
772 public function save_quiz( $topic_id, $payload ) {
773 $success = true;
774 $data = null;
775 $errors = array();
776
777 $validation = $this->validate_payload( $payload );
778 if ( ! tutor_utils()->can_user_manage( 'topic', $topic_id ) ) {
779 $validation->success = false;
780 $validation->errors['topic_id'][] = tutor_utils()->error_message();
781 }
782
783 if ( ! $validation->success ) {
784 return (object) array(
785 'success' => false,
786 'errors' => $validation->errors,
787 );
788 }
789
790 $is_update = isset( $payload['ID'] );
791 $quiz_id = $is_update ? $payload['ID'] : null;
792 $questions = isset( $payload['questions'] ) ? $payload['questions'] : array();
793
794 $menu_order = (int) ( isset( $payload['menu_order'] )
795 ? $payload['menu_order']
796 : tutor_utils()->get_next_course_content_order_id( $topic_id, $quiz_id ) );
797
798 $quiz_data = array(
799 'post_type' => tutor()->quiz_post_type,
800 'post_title' => Input::sanitize( wp_slash( $payload['post_title'] ?? '' ) ),
801 'post_content' => Input::sanitize( wp_slash( $payload['post_content'] ?? '' ) ),
802 'post_status' => 'publish',
803 'post_author' => get_current_user_id(),
804 'post_parent' => $topic_id,
805 'menu_order' => $menu_order,
806 );
807
808 global $wpdb;
809 $wpdb->query( 'START TRANSACTION' );
810
811 try {
812 // Add or update the quiz.
813 if ( $is_update ) {
814 $quiz_data['ID'] = $quiz_id;
815 }
816
817 $quiz_id = wp_insert_post( $quiz_data );
818 do_action( ( $is_update ? 'tutor_quiz_updated' : 'tutor_initial_quiz_created' ), $quiz_id );
819
820 // Save quiz settings.
821 $quiz_option = Input::sanitize_array( $payload['quiz_option'] ?? array() ); //phpcs:ignore
822 update_post_meta( $quiz_id, Quiz::META_QUIZ_OPTION, $quiz_option );
823 do_action( 'tutor_quiz_settings_updated', $quiz_id );
824
825 // Save quiz questions.
826 if ( count( $questions ) ) {
827 $this->save_questions( $quiz_id, $questions );
828 }
829
830 // Delete questions and answers.
831 $deleted_question_ids = Input::post( 'deleted_question_ids', array(), Input::TYPE_ARRAY );
832 $deleted_answer_ids = Input::post( 'deleted_answer_ids', array(), Input::TYPE_ARRAY );
833 $deleted_temp_mask_values = Input::post( 'deleted_temp_mask_values', array(), Input::TYPE_ARRAY );
834
835 $this->handle_delete( $deleted_question_ids, $deleted_answer_ids, $deleted_temp_mask_values );
836
837 $wpdb->query( 'COMMIT' );
838
839 $data = $quiz_id;
840
841 } catch ( \Throwable $th ) {
842 $wpdb->query( 'ROLLBACK' );
843
844 $success = false;
845 $errors['500'][] = $th->getMessage();
846 }
847
848 return (object) array(
849 'success' => $success,
850 'data' => $data,
851 'errors' => $errors,
852 );
853 }
854
855 /**
856 * Create or update quiz from new course builder.
857 *
858 * @since 3.0.0
859 *
860 * @return void json response.
861 */
862 public function ajax_quiz_builder_save() {
863 tutor_utils()->check_nonce();
864
865 $payload = $_POST['payload'] ?? array(); //phpcs:ignore
866 if ( is_string( $payload ) ) {
867 $payload = json_decode( wp_unslash( $payload ), true );
868 }
869
870 $course_id = Input::post( 'course_id', 0, Input::TYPE_INT );
871 $topic_id = Input::post( 'topic_id', 0, Input::TYPE_INT );
872 $course_cls = new Course( false );
873
874 $course_cls->check_access( $course_id );
875
876 $result = $this->save_quiz( $topic_id, wp_slash( $payload ) );
877 if ( $result->success ) {
878 $quiz_id = $result->data;
879 $quiz_details = QuizModel::get_quiz_details( $quiz_id );
880 $this->json_response( __( 'Quiz saved successfully', 'tutor' ), $quiz_details );
881 } else {
882 $this->json_response( __( 'Error', 'tutor' ), $result->errors, HttpHelper::STATUS_BAD_REQUEST );
883 }
884 }
885
886 /**
887 * Set current quiz questions & answer ids
888 *
889 * @since 4.0.5
890 *
891 * @param int $quiz_id Quiz id.
892 *
893 * @return void
894 */
895 private function set_current_quiz_questions_answer_ids( int $quiz_id ) {
896 if ( ! $quiz_id ) {
897 return;
898 }
899
900 if ( is_null( $this->current_quiz ) ) {
901 $quiz_details = QuizModel::get_quiz_details( $quiz_id );
902 if ( $quiz_details ) {
903 $this->current_quiz = $quiz_details;
904 }
905 }
906
907 if ( is_null( $this->current_quiz_question_ids ) && $this->current_quiz ) {
908 $questions = $this->current_quiz->questions;
909 if ( $questions ) {
910 $this->current_quiz_question_ids = wp_list_pluck( $questions, 'question_id' );
911 }
912 }
913
914 if ( is_null( $this->current_quiz_answer_ids ) && $this->current_quiz ) {
915 $quiz_details = $this->current_quiz->questions;
916
917 $quiz_question_answer = wp_list_pluck( $quiz_details, 'question_answers' );
918 $this->current_quiz_answer_ids = wp_list_pluck( array_merge( ...$quiz_question_answer ), 'answer_id' );
919 }
920 }
921
922 /**
923 * Validate all the questions & answers are belongs to the provided quiz
924 *
925 * @since 4.0.5
926 *
927 * @param array $payload Array of question answer payload.
928 *
929 * @throws \Exception If discrepancy found in the payload.
930 *
931 * @return bool true True if everything is fine
932 */
933 public function is_valid_quiz_question_answer_payload( array $payload ): bool {
934 $payload_question_ids = wp_list_pluck( $payload, 'question_id' );
935 $payload_question_answers = wp_list_pluck( $payload, 'question_answers' );
936 $payload_answer_ids = wp_list_pluck( array_merge( ...$payload_question_answers ), 'answer_id' );
937
938 // Remove the hash id.
939 $payload_question_ids = array_filter( $payload_question_ids, fn( $id ) => is_numeric( $id ) );
940 $payload_answer_ids = array_filter( $payload_answer_ids, fn( $id ) => is_numeric( $id ) );
941
942 if ( $this->current_quiz_question_ids ) {
943 $has_question_diff = array_diff( $payload_question_ids, $this->current_quiz_question_ids );
944 if ( $has_question_diff ) {
945 throw new \Exception( esc_html__( 'Invalid question id found', 'tutor' ), 1 );
946 }
947 }
948
949 if ( $this->current_quiz_answer_ids ) {
950 $has_answer_diff = array_diff( $payload_answer_ids, $this->current_quiz_answer_ids );
951 if ( $has_answer_diff ) {
952 throw new \Exception( esc_html__( 'Invalid answer id found', 'tutor' ), 1 );
953 }
954 }
955
956 return true;
957 }
958 }
959