PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.0
Tutor LMS – eLearning and online course solution v4.0.0
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 day ago Admin.php 1 day ago Ajax.php 1 day ago Announcements.php 1 day ago Assets.php 1 day ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 1 day ago Container.php 11 months ago Course.php 1 day ago Course_Embed.php 3 years ago Course_Filter.php 1 day ago Course_List.php 1 day ago Course_Settings_Tabs.php 1 day ago Course_Widget.php 1 year ago Custom_Validation.php 1 day ago Dashboard.php 1 day ago Earnings.php 9 months ago FormHandler.php 1 day ago Frontend.php 1 day ago Gutenberg.php 1 year ago Icon.php 1 day ago Input.php 1 day ago Instructor.php 1 day ago Instructors_List.php 1 day ago Lesson.php 1 day ago Options_V2.php 1 day ago Permalink.php 1 day ago Post_types.php 2 days ago Private_Course_Access.php 1 day ago Q_And_A.php 1 day ago Question_Answers_List.php 11 months ago Quiz.php 1 day ago QuizBuilder.php 1 day ago Quiz_Attempts_List.php 1 day ago RestAPI.php 2 years ago Reviews.php 1 day ago Rewrite_Rules.php 2 years ago SampleCourse.php 1 day ago Shortcode.php 1 day ago Singleton.php 1 year ago Student.php 1 day ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 1 day ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 4 weeks ago Tutor.php 1 day ago TutorEDD.php 1 day ago Tutor_Base.php 2 years ago Tutor_Setup.php 1 day ago Upgrader.php 1 day ago User.php 1 day ago UserPreference.php 1 day ago Utils.php 1 day ago Video_Stream.php 3 years ago WhatsNew.php 10 months ago Withdraw.php 1 day ago Withdraw_Requests_List.php 11 months ago WooCommerce.php 1 day ago
QuizBuilder.php
805 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
24 /**
25 * Class QuizBuilder
26 *
27 * @since 1.0.0
28 */
29 class QuizBuilder {
30 use JsonResponse;
31
32 const TRACKING_KEY = '_data_status';
33 const FLAG_NEW = 'new';
34 const FLAG_UPDATE = 'update';
35 const FLAG_NO_CHANGE = 'no_change';
36
37 /**
38 * Register hooks and dependencies.
39 *
40 * @param boolean $register_hooks register hooks or not.
41 */
42 public function __construct( $register_hooks = true ) {
43 if ( ! $register_hooks ) {
44 return;
45 }
46
47 add_action( 'wp_ajax_tutor_quiz_builder_save', array( $this, 'ajax_quiz_builder_save' ) );
48 }
49
50
51 /**
52 * Prepare question data.
53 *
54 * @since 3.0.0
55 *
56 * @param int $quiz_id quiz id.
57 * @param array $input question data.
58 *
59 * @return array
60 */
61 public function prepare_question_data( $quiz_id, $input ) {
62 $question_title = Input::sanitize( wp_slash( $input['question_title'] ), '' );
63 $question_description = Input::sanitize( wp_slash( $input['question_description'] ) ?? '', '', Input::TYPE_KSES_POST );
64 $question_type = Input::sanitize( $input['question_type'], '' );
65 $question_mark = Input::sanitize( $input['question_mark'], 1, Input::TYPE_INT );
66 $question_settings = Input::sanitize_array( $input['question_settings'] );
67
68 $data = array(
69 'quiz_id' => $quiz_id,
70 'question_title' => $question_title,
71 'question_description' => $question_description,
72 'question_type' => $question_type,
73 'question_mark' => $question_mark,
74 'question_settings' => maybe_serialize( $question_settings ),
75 );
76
77 return apply_filters( 'tutor_quiz_question_data', $data, $input );
78 }
79
80 /**
81 * Prepare answer data.
82 *
83 * @param int $question_id question id.
84 * @param string $question_type question type.
85 * @param array $input answer data.
86 * @param string $data_status answer data status.
87 *
88 * @return array
89 */
90 public function prepare_answer_data( $question_id, $question_type, $input, $data_status = self::FLAG_NO_CHANGE ) {
91 $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' );
92 $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT );
93 $image_id = Input::sanitize( $input['image_id'] ?? null );
94 // Let the hook handle special cases (e.g. draw_image, pin_image) and return a normalized value (URL).
95 $answer_two_gap_match_raw = isset( $input['answer_two_gap_match'] ) ? wp_unslash( $input['answer_two_gap_match'] ) : '';
96 $answer_two_gap_match_raw = apply_filters(
97 'tutor_save_quiz_draw_image_mask',
98 $answer_two_gap_match_raw,
99 $question_type,
100 array(
101 'data_status' => $data_status,
102 )
103 );
104 $answer_two_gap_match = Input::sanitize( $answer_two_gap_match_raw ?? '', '' );
105 $answer_view_format = Input::sanitize( $input['answer_view_format'] ?? '' );
106 $answer_settings = null;
107
108 $answer_data = array(
109 'belongs_question_id' => $question_id,
110 'belongs_question_type' => $question_type,
111 'answer_title' => $answer_title,
112 'is_correct' => $is_correct,
113 'image_id' => $image_id,
114 'answer_two_gap_match' => $answer_two_gap_match,
115 'answer_view_format' => $answer_view_format,
116 'answer_settings' => $answer_settings,
117 );
118
119 return $answer_data;
120 }
121
122 /**
123 * Save question answers.
124 *
125 * @since 3.7.0
126 *
127 * @param int $question_id question id.
128 * @param string $question_type question type.
129 * @param array $question_answers question answers.
130 *
131 * @return void
132 */
133 public function save_question_answers( $question_id, $question_type, $question_answers ) {
134 global $wpdb;
135 $answers_table = $wpdb->prefix . 'tutor_quiz_question_answers';
136
137 $answer_order = 0;
138 foreach ( $question_answers as $answer ) {
139 $data_status = isset( $answer[ self::TRACKING_KEY ] ) ? $answer[ self::TRACKING_KEY ] : self::FLAG_NO_CHANGE;
140 $answer_data = $this->prepare_answer_data( $question_id, $question_type, $answer, $data_status );
141
142 // New answer.
143 if ( self::FLAG_NEW === $data_status ) {
144 $wpdb->insert( $answers_table, $answer_data );
145 $answer_id = $wpdb->insert_id;
146 }
147
148 // Update answer.
149 if ( self::FLAG_UPDATE === $data_status ) {
150 $answer_id = $answer['answer_id'];
151 $old_mask = '';
152 if ( $this->is_mask_image_question_type( $question_type ) ) {
153 $old_answer = QueryHelper::get_row( $answers_table, array( 'answer_id' => (int) $answer_id ), 'answer_id' );
154 $old_mask = is_object( $old_answer ) ? (string) ( $old_answer->answer_two_gap_match ?? '' ) : '';
155 }
156 $wpdb->update(
157 $answers_table,
158 $answer_data,
159 array( 'answer_id' => $answer_id )
160 );
161 if ( $this->is_mask_image_question_type( $question_type ) ) {
162 $new_mask = (string) ( $answer_data['answer_two_gap_match'] ?? '' );
163 $this->delete_replaced_mask_file( $old_mask, $new_mask );
164 }
165 }
166
167 if ( self::FLAG_NO_CHANGE === $data_status ) {
168 $answer_id = $answer['answer_id'];
169 }
170
171 // Save sort order.
172 $answer_order++;
173 $wpdb->update(
174 $answers_table,
175 array( 'answer_order' => $answer_order ),
176 array( 'answer_id' => $answer_id )
177 );
178 }
179 }
180
181 /**
182 * Whether question type stores a mask image URL in answer_two_gap_match.
183 *
184 * @since 4.0.0
185 *
186 * @param string $question_type Question type.
187 *
188 * @return bool
189 */
190 private function is_mask_image_question_type( $question_type ) {
191 return in_array( $question_type, array( 'draw_image', 'pin_image' ), true );
192 }
193
194 /**
195 * Delete old mask file if it was replaced with a different mask URL.
196 *
197 * @since 4.0.0
198 *
199 * @param string $old_mask Previous mask URL.
200 * @param string $new_mask New mask URL.
201 *
202 * @return void
203 */
204 private function delete_replaced_mask_file( $old_mask, $new_mask ) {
205 $old_mask = trim( (string) $old_mask );
206 $new_mask = trim( (string) $new_mask );
207
208 if ( '' === $old_mask || $old_mask === $new_mask ) {
209 return;
210 }
211
212 if ( ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
213 return;
214 }
215
216 $upload_dir = wp_upload_dir();
217 if ( ! empty( $upload_dir['error'] ) ) {
218 return;
219 }
220
221 $old_path = QuizImageStorage::quiz_image_stored_value_to_path( $old_mask );
222 if ( '' === $old_path || ! is_file( $old_path ) || ! is_readable( $old_path ) ) {
223 return;
224 }
225
226 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
227 if ( 0 !== strpos( $old_path, $quiz_dir ) ) {
228 return;
229 }
230
231 wp_delete_file( $old_path );
232 }
233
234 /**
235 * Save quiz questions.
236 *
237 * @since 3.0.0
238 *
239 * @param int $quiz_id quiz id.
240 * @param array $questions questions data.
241 *
242 * @return void
243 *
244 * @throws \Exception When saving a draw_image question while Legacy learning mode is enabled.
245 */
246 public function save_questions( $quiz_id, $questions ) {
247 global $wpdb;
248 $questions_table = $wpdb->prefix . 'tutor_quiz_questions';
249
250 $question_order = 0;
251 foreach ( $questions as $question ) {
252 $data_status = isset( $question[ self::TRACKING_KEY ] ) ? $question[ self::TRACKING_KEY ] : self::FLAG_NO_CHANGE;
253 $question_order++;
254 if ( isset( $question['is_cb_question'], $question['cb_action'] ) && 'link' === $question['cb_action'] ) {
255 $question['question_order'] = $question_order;
256 do_action( 'tutor_content_bank_question_linked_to_quiz', $quiz_id, (object) $question );
257 continue;
258 }
259
260 $question_type = Input::sanitize( $question['question_type'] );
261 if ( 'draw_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
262 throw new \Exception( esc_html__( 'Image Marking questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
263 }
264 if ( 'pin_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
265 throw new \Exception( esc_html__( 'Pin questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
266 }
267 if ( 'scale' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
268 throw new \Exception( esc_html__( 'Range questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
269 }
270 if ( 'puzzle' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
271 throw new \Exception( esc_html__( 'Puzzle questions are not available when Legacy learning mode is enabled.', 'tutor' ) );
272 }
273 $question_data = $this->prepare_question_data( $quiz_id, $question );
274 $question_answers = isset( $question['question_answers'] ) ? $question['question_answers'] : array();
275
276 // New question.
277 if ( self::FLAG_NEW === $data_status ) {
278 $wpdb->insert( $questions_table, $question_data );
279 $question_id = $wpdb->insert_id;
280
281 if ( isset( $question['is_cb_question'] ) ) {
282 $question['question_order'] = $question_order;
283 $question['new_question_id'] = $question_id;
284 do_action( 'tutor_content_bank_question_added_to_quiz', $quiz_id, (object) $question );
285 }
286 }
287
288 // Update question.
289 if ( self::FLAG_UPDATE === $data_status ) {
290 $question_id = (int) $question['question_id'];
291 $wpdb->update(
292 $questions_table,
293 $question_data,
294 array( 'question_id' => $question_id )
295 );
296 }
297
298 if ( self::FLAG_NO_CHANGE === $data_status ) {
299 $question_id = $question['question_id'];
300 }
301
302 // Save sort order.
303 $wpdb->update(
304 $questions_table,
305 array( 'question_order' => $question_order ),
306 array( 'question_id' => $question_id )
307 );
308
309 // Save question's answers.
310 $this->save_question_answers( $question_id, $question_type, $question_answers );
311 }
312 }
313
314 /**
315 * Validate payload.
316 *
317 * @since 3.0.0
318 *
319 * @param array $payload payload.
320 *
321 * @return object consist success, errors.
322 */
323 public function validate_payload( $payload ) {
324 $errors = array();
325 $success = true;
326
327 if ( ! is_array( $payload ) ) {
328 $success = false;
329 $errors['payload'] = __( 'Invalid payload', 'tutor' );
330 }
331
332 $rules = array(
333 'post_title' => 'required',
334 'quiz_option' => 'required|is_array',
335 'questions' => 'required|is_array',
336 );
337
338 $validation = ValidationHelper::validate(
339 $rules,
340 $payload
341 );
342
343 if ( ! $validation->success ) {
344 $success = false;
345 $errors = array_merge( $errors, $validation->errors );
346 }
347
348 if ( isset( $payload['ID'] ) && is_numeric( $payload['ID'] ) ) {
349 if ( ! current_user_can( 'edit_post', $payload['ID'] ) ) {
350 $success = false;
351 $errors['permission'][] = __( 'You do not have permission to edit this quiz', 'tutor' );
352 } else {
353 $quiz = get_post( $payload['ID'] );
354 if ( ! $quiz || tutor()->quiz_post_type !== $quiz->post_type ) {
355 $success = false;
356 $errors['ID'][] = __( 'Invalid quiz id provided', 'tutor' );
357 }
358 }
359 }
360
361 foreach ( $payload['questions'] as $question ) {
362 if ( ! isset( $question[ self::TRACKING_KEY ] ) ) {
363 $success = false;
364 // translators: %s is the tracking key required for each question.
365 $errors[ self::TRACKING_KEY ][] = sprintf( __( '%s is required for each question', 'tutor' ), self::TRACKING_KEY );
366 break;
367 }
368
369 if ( ! in_array( $question[ self::TRACKING_KEY ], array( self::FLAG_NEW, self::FLAG_UPDATE, self::FLAG_NO_CHANGE ), true ) ) {
370 $success = false;
371 // translators: %s is the tracking key for which the value is invalid.
372 $errors[ self::TRACKING_KEY ][] = sprintf( __( 'Invalid value for %s', 'tutor' ), self::TRACKING_KEY );
373 break;
374 }
375
376 if ( ! isset( $question['question_settings'] ) || ! is_array( $question['question_settings'] ) ) {
377 $success = false;
378 $errors['question_settings'][] = __( 'Question settings is required with array data', 'tutor' );
379 break;
380 }
381 }
382
383 return (object) array(
384 'success' => $success,
385 'errors' => $errors,
386 );
387 }
388
389 /**
390 * Handle delete questions and answers.
391 *
392 * @since 3.0.0
393 *
394 * @param array $deleted_question_ids question ids.
395 * @param array $deleted_answer_ids answer ids.
396 * @param array $deleted_temp_mask_values unsaved draw/pin/puzzle mask values.
397 *
398 * @return void
399 */
400 public function handle_delete( $deleted_question_ids = array(), $deleted_answer_ids = array(), $deleted_temp_mask_values = array() ) {
401 global $wpdb;
402 $deleted_question_ids = array_filter( $deleted_question_ids, 'is_numeric' );
403 $deleted_answer_ids = array_filter( $deleted_answer_ids, 'is_numeric' );
404 $deleted_temp_mask_values = is_array( $deleted_temp_mask_values ) ? array_values( array_filter( array_map( 'strval', $deleted_temp_mask_values ) ) ) : array();
405 $question_file_paths = array();
406 $mask_question_ids = array();
407
408 if ( count( $deleted_question_ids ) ) {
409 $mask_question_ids = $this->get_deletable_mask_question_ids( $deleted_question_ids );
410
411 if ( count( $mask_question_ids ) ) {
412 $question_file_paths = $this->get_question_file_paths_for_deletion( $mask_question_ids );
413
414 $in_clause = QueryHelper::prepare_in_clause( $mask_question_ids );
415 // Only remove answers automatically for file-based quiz types (draw/pin/puzzle).
416 //phpcs:ignore -- sanitized $in_clause.
417 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN ({$in_clause})" ) );
418 }
419
420 $in_clause = QueryHelper::prepare_in_clause( $deleted_question_ids );
421 // Preserve previous behavior for all non file-based quiz types and linked-content hooks.
422 //phpcs:ignore -- sanitized $in_clause.
423 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_questions WHERE content_id IS NULL AND question_id IN ({$in_clause})" ) );
424 do_action( 'tutor_deleted_quiz_question_ids', $deleted_question_ids );
425 }
426
427 if ( count( $deleted_answer_ids ) ) {
428 $in_clause = QueryHelper::prepare_in_clause( $deleted_answer_ids );
429 //phpcs:ignore -- sanitized $in_clause.
430 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE answer_id IN ({$in_clause})" ) );
431 }
432
433 if ( count( $question_file_paths ) ) {
434 QuizModel::delete_files_by_paths( $question_file_paths );
435 }
436
437 if ( count( $deleted_temp_mask_values ) ) {
438 $temp_file_paths = $this->get_temp_mask_file_paths_for_deletion( $deleted_temp_mask_values );
439 if ( count( $temp_file_paths ) ) {
440 QuizModel::delete_files_by_paths( $temp_file_paths );
441 }
442 }
443 }
444
445 /**
446 * Get deletable question IDs for file-based question types.
447 *
448 * Only draw/pin/puzzle are selected to keep behavior unchanged for other quiz types.
449 *
450 * @since 4.0.0
451 *
452 * @param array $question_ids Question IDs from payload.
453 *
454 * @return int[]
455 */
456 private function get_deletable_mask_question_ids( array $question_ids ) {
457 $question_ids = array_map( 'intval', array_filter( $question_ids, 'is_numeric' ) );
458 if ( empty( $question_ids ) ) {
459 return array();
460 }
461
462 $question_rows = QueryHelper::get_all(
463 QueryHelper::prepare_table_name( 'tutor_quiz_questions' ),
464 array(
465 'question_id' => array( 'IN', $question_ids ),
466 'content_id' => array( 'IS', 'NULL' ),
467 'question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ),
468 ),
469 'question_id',
470 -1
471 );
472
473 if ( empty( $question_rows ) ) {
474 return array();
475 }
476
477 return array_values(
478 array_unique(
479 array_map(
480 static function ( $row ) {
481 return (int) ( $row->question_id ?? 0 );
482 },
483 $question_rows
484 )
485 )
486 );
487 }
488
489 /**
490 * Collect draw/pin/puzzle files linked to question answers before question deletion.
491 *
492 * @since 4.0.0
493 *
494 * @param array $question_ids Question IDs that will be deleted.
495 *
496 * @return string[]
497 */
498 private function get_question_file_paths_for_deletion( array $question_ids ) {
499 $paths = array();
500
501 if ( empty( $question_ids ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
502 return $paths;
503 }
504
505 $answers = QueryHelper::get_all(
506 QueryHelper::prepare_table_name( 'tutor_quiz_question_answers' ),
507 array(
508 'belongs_question_id' => array( 'IN', $question_ids ),
509 'belongs_question_type' => array( 'IN', array( 'draw_image', 'pin_image', 'puzzle' ) ),
510 ),
511 'answer_id',
512 -1
513 );
514
515 if ( empty( $answers ) ) {
516 return $paths;
517 }
518
519 $upload_dir = wp_upload_dir();
520 if ( ! empty( $upload_dir['error'] ) ) {
521 return $paths;
522 }
523
524 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
525
526 foreach ( $answers as $answer ) {
527 $stored = isset( $answer->answer_two_gap_match ) ? trim( (string) $answer->answer_two_gap_match ) : '';
528 if ( '' === $stored ) {
529 continue;
530 }
531
532 $path = $this->resolve_mask_stored_value_to_path( $stored );
533
534 if ( '' === $path && 'puzzle' === ( $answer->belongs_question_type ?? '' ) ) {
535 $payload = json_decode( stripslashes( $stored ), true );
536 if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) {
537 $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] );
538 }
539 }
540
541 if ( '' === $path || ! is_readable( $path ) ) {
542 continue;
543 }
544
545 if ( 0 !== strpos( $path, $quiz_dir ) ) {
546 continue;
547 }
548
549 $paths[] = $path;
550 }
551
552 return array_values( array_unique( $paths ) );
553 }
554
555 /**
556 * Resolve a stored draw/pin/puzzle mask value to local file path.
557 *
558 * Supports basename, uploads-relative path, uploads URL, and absolute uploads path.
559 *
560 * @since 4.0.0
561 *
562 * @param string $stored Stored mask value from answer_two_gap_match.
563 *
564 * @return string
565 */
566 private function resolve_mask_stored_value_to_path( $stored ) {
567 $stored = is_string( $stored ) ? trim( stripslashes( $stored ) ) : '';
568 $stored = trim( $stored, "\"' \t\n\r\0\x0B" );
569 $stored = str_replace( '\\/', '/', $stored );
570
571 if ( '' === $stored ) {
572 return '';
573 }
574
575 $path = QuizImageStorage::quiz_image_stored_value_to_path( $stored );
576 if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) {
577 return $path;
578 }
579
580 $basename = QuizImageStorage::sanitize_quiz_image_filename( wp_basename( str_replace( '\\', '/', $stored ) ) );
581 if ( '' !== $basename ) {
582 $path = QuizImageStorage::quiz_image_stored_value_to_path( $basename );
583 if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) {
584 return $path;
585 }
586 }
587
588 $upload_dir = wp_upload_dir();
589 if ( ! empty( $upload_dir['error'] ) ) {
590 return '';
591 }
592
593 $uploads_base_dir = trailingslashit( str_replace( '\\', '/', (string) $upload_dir['basedir'] ) );
594 $uploads_marker = '/wp-content/uploads/';
595
596 $url_path = wp_parse_url( $stored, PHP_URL_PATH );
597 $url_path = is_string( $url_path ) ? $url_path : '';
598 $marker_pos = '' !== $url_path ? strpos( $url_path, $uploads_marker ) : false;
599 if ( false !== $marker_pos ) {
600 $relative = ltrim( substr( $url_path, $marker_pos + strlen( $uploads_marker ) ), '/' );
601 $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative );
602 if ( '' !== $relative ) {
603 $resolved = $uploads_base_dir . $relative;
604 if ( is_file( $resolved ) && is_readable( $resolved ) ) {
605 return $resolved;
606 }
607 }
608 }
609
610 $is_abs_path = '/' === substr( $stored, 0, 1 );
611 if ( ! $is_abs_path ) {
612 return '';
613 }
614
615 $marker_pos = strpos( $stored, $uploads_marker );
616 if ( false === $marker_pos ) {
617 return '';
618 }
619
620 $relative = ltrim( substr( $stored, $marker_pos + strlen( $uploads_marker ) ), '/' );
621 $relative = QuizImageStorage::normalize_uploads_relative_store_value( $relative );
622 if ( '' === $relative ) {
623 return '';
624 }
625
626 $resolved = $uploads_base_dir . $relative;
627 return ( is_file( $resolved ) && is_readable( $resolved ) ) ? $resolved : '';
628 }
629
630 /**
631 * Collect temp draw/pin/puzzle file paths from unsaved question deletions.
632 *
633 * @since 4.0.0
634 *
635 * @param string[] $stored_values Stored values coming from quiz builder payload.
636 *
637 * @return string[]
638 */
639 private function get_temp_mask_file_paths_for_deletion( array $stored_values ) {
640 $paths = array();
641
642 if ( empty( $stored_values ) || ! class_exists( '\TUTOR_PRO\QuizImageStorage' ) ) {
643 return $paths;
644 }
645
646 $upload_dir = wp_upload_dir();
647 if ( ! empty( $upload_dir['error'] ) ) {
648 return $paths;
649 }
650
651 $quiz_dir = trailingslashit( $upload_dir['basedir'] ) . QuizImageStorage::QUIZ_IMAGES_SUBDIR . '/';
652
653 foreach ( $stored_values as $stored ) {
654 $stored = is_string( $stored ) ? trim( $stored ) : '';
655 if ( '' === $stored ) {
656 continue;
657 }
658
659 $path = $this->resolve_mask_stored_value_to_path( $stored );
660
661 if ( '' === $path && ( false !== strpos( $stored, '{' ) || false !== strpos( $stored, 'playground_snapshot_file' ) ) ) {
662 $payload = json_decode( stripslashes( $stored ), true );
663 if ( is_array( $payload ) && ! empty( $payload['playground_snapshot_file'] ) ) {
664 $path = $this->resolve_mask_stored_value_to_path( (string) $payload['playground_snapshot_file'] );
665 }
666 }
667
668 if ( '' === $path || ! is_readable( $path ) ) {
669 continue;
670 }
671
672 if ( 0 !== strpos( $path, $quiz_dir ) ) {
673 continue;
674 }
675
676 $paths[] = $path;
677 }
678
679 return array_values( array_unique( $paths ) );
680 }
681
682 /**
683 * Create or update quiz.
684 *
685 * @since 3.0.0
686 *
687 * @param int $topic_id topic id.
688 * @param array $payload payload.
689 *
690 * @return object consist success, errors.
691 */
692 public function save_quiz( $topic_id, $payload ) {
693 $success = true;
694 $data = null;
695 $errors = array();
696
697 $validation = $this->validate_payload( $payload );
698 if ( ! tutor_utils()->can_user_manage( 'topic', $topic_id ) ) {
699 $validation->success = false;
700 $validation->errors['topic_id'][] = tutor_utils()->error_message();
701 }
702
703 if ( ! $validation->success ) {
704 return (object) array(
705 'success' => false,
706 'errors' => $validation->errors,
707 );
708 }
709
710 $is_update = isset( $payload['ID'] );
711 $quiz_id = $is_update ? $payload['ID'] : null;
712 $questions = isset( $payload['questions'] ) ? $payload['questions'] : array();
713
714 $menu_order = (int) ( isset( $payload['menu_order'] )
715 ? $payload['menu_order']
716 : tutor_utils()->get_next_course_content_order_id( $topic_id, $quiz_id ) );
717
718 $quiz_data = array(
719 'post_type' => tutor()->quiz_post_type,
720 'post_title' => Input::sanitize( wp_slash( $payload['post_title'] ?? '' ) ),
721 'post_content' => Input::sanitize( wp_slash( $payload['post_content'] ?? '' ) ),
722 'post_status' => 'publish',
723 'post_author' => get_current_user_id(),
724 'post_parent' => $topic_id,
725 'menu_order' => $menu_order,
726 );
727
728 global $wpdb;
729 $wpdb->query( 'START TRANSACTION' );
730
731 try {
732 // Add or update the quiz.
733 if ( $is_update ) {
734 $quiz_data['ID'] = $quiz_id;
735 }
736
737 $quiz_id = wp_insert_post( $quiz_data );
738 do_action( ( $is_update ? 'tutor_quiz_updated' : 'tutor_initial_quiz_created' ), $quiz_id );
739
740 // Save quiz settings.
741 $quiz_option = Input::sanitize_array( $payload['quiz_option'] ?? array() ); //phpcs:ignore
742 update_post_meta( $quiz_id, Quiz::META_QUIZ_OPTION, $quiz_option );
743 do_action( 'tutor_quiz_settings_updated', $quiz_id );
744
745 // Save quiz questions.
746 if ( count( $questions ) ) {
747 $this->save_questions( $quiz_id, $questions );
748 }
749
750 // Delete questions and answers.
751 $deleted_question_ids = Input::post( 'deleted_question_ids', array(), Input::TYPE_ARRAY );
752 $deleted_answer_ids = Input::post( 'deleted_answer_ids', array(), Input::TYPE_ARRAY );
753 $deleted_temp_mask_values = Input::post( 'deleted_temp_mask_values', array(), Input::TYPE_ARRAY );
754 $this->handle_delete( $deleted_question_ids, $deleted_answer_ids, $deleted_temp_mask_values );
755
756 $wpdb->query( 'COMMIT' );
757
758 $data = $quiz_id;
759
760 } catch ( \Throwable $th ) {
761 $wpdb->query( 'ROLLBACK' );
762
763 $success = false;
764 $errors['500'][] = $th->getMessage();
765 }
766
767 return (object) array(
768 'success' => $success,
769 'data' => $data,
770 'errors' => $errors,
771 );
772 }
773
774 /**
775 * Create or update quiz from new course builder.
776 *
777 * @since 3.0.0
778 *
779 * @return void json response.
780 */
781 public function ajax_quiz_builder_save() {
782 tutor_utils()->check_nonce();
783
784 $payload = $_POST['payload'] ?? array(); //phpcs:ignore
785 if ( is_string( $payload ) ) {
786 $payload = json_decode( wp_unslash( $payload ), true );
787 }
788
789 $course_id = Input::post( 'course_id', 0, Input::TYPE_INT );
790 $topic_id = Input::post( 'topic_id', 0, Input::TYPE_INT );
791 $course_cls = new Course( false );
792
793 $course_cls->check_access( $course_id );
794
795 $result = $this->save_quiz( $topic_id, wp_slash( $payload ) );
796 if ( $result->success ) {
797 $quiz_id = $result->data;
798 $quiz_details = QuizModel::get_quiz_details( $quiz_id );
799 $this->json_response( __( 'Quiz saved successfully', 'tutor' ), $quiz_details );
800 } else {
801 $this->json_response( __( 'Error', 'tutor' ), $result->errors, HttpHelper::STATUS_BAD_REQUEST );
802 }
803 }
804 }
805