PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.9
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.9
4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 All 140 releases
learnpress / inc / Ajax / CourseBuilder / CourseBuilderAjax.php

CourseBuilderAjax.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.9, at inc/Ajax/CourseBuilder/CourseBuilderAjax.php

2,232 lines 73.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * class CourseBuilderAjax
4 *
5 * @since 4.3
6 * @version 1.0.0
7 */
8
9 namespace LearnPress\Ajax\CourseBuilder;
10
11 use DateTimeImmutable;
12 use DateTimeZone;
13 use Exception;
14 use LearnPress\Ajax\AbstractAjax;
15 use LearnPress\CourseBuilder\CourseBuilder;
16 use LearnPress\CourseBuilder\CourseBuilderAccessPolicy;
17 use LearnPress\Helpers\Response;
18 use LearnPress\Helpers\Template;
19 use LearnPress\Models\CourseModel;
20 use LearnPress\Models\CoursePostModel;
21 use LearnPress\Models\CourseSectionItemModel;
22 use LearnPress\Models\LessonPostModel;
23 use LearnPress\Models\Question\QuestionPostModel;
24 use LearnPress\Models\QuizPostModel;
25 use LearnPress\TemplateHooks\Course\AdminEditCurriculumTemplate;
26 use LearnPress\TemplateHooks\CourseBuilder\Course\BuilderEditCourseTemplate;
27 use LearnPress\TemplateHooks\CourseBuilder\Course\BuilderListCoursesTemplate;
28 use LearnPress\TemplateHooks\CourseBuilder\CourseBuilderTemplate;
29 use LearnPress\TemplateHooks\CourseBuilder\Lesson\BuilderListLessonsTemplate;
30 use LearnPress\TemplateHooks\CourseBuilder\Question\BuilderListQuestionsTemplate;
31 use LearnPress\TemplateHooks\CourseBuilder\Question\BuilderQuestionTemplate;
32 use LearnPress\TemplateHooks\CourseBuilder\Quiz\BuilderListQuizzesTemplate;
33 use LearnPress\TemplateHooks\CourseBuilder\Quiz\BuilderQuizTemplate;
34 use LP_Course_CURD;
35 use LP_Helper;
36 use LP_Lesson_CURD;
37 use LP_Question_CURD;
38 use LP_Quiz_CURD;
39 use LP_REST_Response;
40 use LP_Settings;
41 use stdClass;
42 use Throwable;
43
44 class CourseBuilderAjax extends AbstractAjax {
45 /**
46 * Check permissions and validate parameters.
47 *
48 * @throws Exception
49 *
50 * @since 4.3
51 * @version 1.0.0
52 */
53 public static function check_valid_course() {
54 $params = wp_unslash( $_REQUEST['data'] ?? '' );
55 if ( empty( $params ) ) {
56 throw new Exception( 'Error: params invalid!' );
57 }
58
59 $params = LP_Helper::json_decode( $params, true );
60 $course_id = ! empty( $params['course_id'] ) ? (int) $params['course_id'] : 0;
61 $courseModel = CourseModel::find( $course_id, true );
62 if ( empty( $courseModel ) ) {
63 $params['insert'] = true;
64 $params['course_model'] = '';
65 } else {
66 $params['insert'] = false;
67 $params['course_model'] = $courseModel;
68 }
69
70 return $params;
71 }
72
73 /**
74 * @throws Exception
75 */
76 public static function check_valid_lesson() {
77 $params = wp_unslash( $_REQUEST['data'] ?? '' );
78 if ( empty( $params ) ) {
79 throw new Exception( 'Error: params invalid!' );
80 }
81
82 $params = LP_Helper::json_decode( $params, true );
83 $lesson_id = absint( $params['lesson_id'] ?? 0 );
84 $lesson_model = LessonPostModel::find( $lesson_id, true );
85 if ( empty( $lesson_model ) ) {
86 $params['insert'] = true;
87 $params['lesson_model'] = '';
88 } else {
89 $params['insert'] = false;
90 $params['lesson_model'] = $lesson_model;
91 }
92
93 return $params;
94 }
95
96 public static function check_valid_quiz() {
97 $params = wp_unslash( $_REQUEST['data'] ?? '' );
98 if ( empty( $params ) ) {
99 throw new Exception( 'Error: params invalid!' );
100 }
101
102 $params = LP_Helper::json_decode( $params, true );
103 $quiz_id = ! empty( $params['quiz_id'] ) ? (int) $params['quiz_id'] : 0;
104 $quiz_model = QuizPostModel::find( $quiz_id, true );
105 if ( empty( $quiz_model ) ) {
106 $params['insert'] = true;
107 } else {
108 $params['insert'] = false;
109 $params['quiz_model'] = $quiz_model;
110 }
111
112 return $params;
113 }
114
115 public static function check_valid_question() {
116 $params = wp_unslash( $_REQUEST['data'] ?? '' );
117 if ( empty( $params ) ) {
118 throw new Exception( 'Error: params invalid!' );
119 }
120
121 $params = LP_Helper::json_decode( $params, true );
122 $question_id = ! empty( $params['question_id'] ) ? (int) $params['question_id'] : 0;
123 $question_model = QuestionPostModel::find( $question_id, true );
124 if ( empty( $question_model ) ) {
125 $params['insert'] = true;
126 } else {
127 $params['insert'] = false;
128 $params['question_model'] = $question_model;
129 }
130
131 return $params;
132 }
133
134 /**
135 * Prepare desired slug when restoring a trashed post with a new permalink.
136 *
137 * WordPress stores `_wp_desired_post_slug` on trash and may restore that value
138 * when status changes from `trash` to a non-trash status.
139 *
140 * @param int $post_id
141 * @param string $target_status
142 * @param string $requested_slug
143 *
144 * @return bool
145 */
146 protected function prepare_desired_slug_for_restore( int $post_id, string $target_status, string $requested_slug ): bool {
147 if ( $post_id <= 0 || '' === $requested_slug ) {
148 return false;
149 }
150
151 if ( ! in_array( $target_status, [ 'publish', 'draft' ], true ) ) {
152 return false;
153 }
154
155 if ( 'trash' !== get_post_status( $post_id ) ) {
156 return false;
157 }
158
159 update_post_meta( $post_id, '_wp_desired_post_slug', $requested_slug );
160
161 return true;
162 }
163
164 /**
165 * Ensure restored post keeps requested slug after trash status transition.
166 *
167 * @param int $post_id
168 * @param string $requested_slug
169 *
170 * @return void
171 */
172 protected function sync_slug_after_restore( int $post_id, string $requested_slug ): void {
173 if ( $post_id <= 0 || '' === $requested_slug ) {
174 return;
175 }
176
177 $post = get_post( $post_id );
178 if ( ! $post || 'trash' === $post->post_status ) {
179 return;
180 }
181
182 if ( $post->post_name === $requested_slug ) {
183 return;
184 }
185
186 wp_update_post(
187 [
188 'ID' => $post_id,
189 'post_name' => $requested_slug,
190 ]
191 );
192 }
193
194 /**
195 * Permalink notice when lesson/quiz is not assigned to a course.
196 *
197 * @return string
198 */
199 protected function get_item_permalink_unavailable_message(): string {
200 return __( 'Permalink is only available if the item is already assigned to a course.', 'learnpress' );
201 }
202
203 /**
204 * Check whether a post status is public.
205 *
206 * @param string $status
207 *
208 * @return bool
209 */
210 protected function is_public_post_status( string $status ): bool {
211 $status_object = get_post_status_object( $status );
212
213 return (bool) ( $status_object && ! empty( $status_object->public ) );
214 }
215
216 /**
217 * Normalize target course status based on role/capability and current status.
218 * Mirrors wp-admin behavior for instructors without publish capability.
219 *
220 * @param string $requested_status
221 * @param bool $insert
222 * @param CourseModel|null $courseModel
223 *
224 * @return string
225 */
226 protected function normalize_course_status_for_save( string $requested_status, bool $insert, ?CourseModel $courseModel ): string {
227 $allowed_statuses = [ 'publish', 'future', 'draft', 'pending', 'private' ];
228 if ( ! in_array( $requested_status, $allowed_statuses, true ) ) {
229 $requested_status = 'draft';
230 }
231
232 $is_admin_user = current_user_can( ADMIN_ROLE );
233 if ( $is_admin_user ) {
234 return $requested_status;
235 }
236
237 $is_instructor_user = current_user_can( LP_TEACHER_ROLE );
238 $required_review = LP_Settings::get_option( 'required_review', 'yes' ) === 'yes';
239 $can_publish_course = current_user_can( 'publish_' . LP_COURSE_CPT . 's' );
240
241 $current_status = $insert ? 'auto-draft' : (string) ( $courseModel->post_status ?? 'draft' );
242 $is_public_state = $this->is_public_post_status( $current_status );
243
244 // Require moderation for instructors when review is enabled.
245 if ( $is_instructor_user && $required_review && ! $is_public_state && in_array( $requested_status, [ 'publish', 'private', 'future' ], true ) ) {
246 return 'pending';
247 }
248
249 // Keep wp-admin behavior: users without publish capability submit for review.
250 if ( ! $can_publish_course && ! $is_public_state && in_array( $requested_status, [ 'publish', 'private', 'future' ], true ) ) {
251 return 'pending';
252 }
253
254 return $requested_status;
255 }
256
257 /**
258 * Normalize publish date from Course Builder UI datetime value.
259 *
260 * @param string $datetime_value
261 *
262 * @return array<string, string>|null
263 */
264 protected function normalize_course_post_date_for_save( string $datetime_value ): ?array {
265 $datetime_value = trim( $datetime_value );
266 if ( '' === $datetime_value ) {
267 return null;
268 }
269
270 $timezone = wp_timezone();
271 $formats = [ 'Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s' ];
272 $parsed = null;
273
274 foreach ( $formats as $format ) {
275 $date_candidate = DateTimeImmutable::createFromFormat( $format, $datetime_value, $timezone );
276 if ( ! $date_candidate instanceof DateTimeImmutable ) {
277 continue;
278 }
279
280 $errors = DateTimeImmutable::getLastErrors();
281 if ( is_array( $errors ) && ( ! empty( $errors['warning_count'] ) || ! empty( $errors['error_count'] ) ) ) {
282 continue;
283 }
284
285 $parsed = $date_candidate;
286 break;
287 }
288
289 if ( ! $parsed instanceof DateTimeImmutable ) {
290 return null;
291 }
292
293 $gmt_timezone = new DateTimeZone( 'UTC' );
294
295 return [
296 'post_date' => $parsed->format( 'Y-m-d H:i:s' ),
297 'post_date_gmt' => $parsed->setTimezone( $gmt_timezone )->format( 'Y-m-d H:i:s' ),
298 ];
299 }
300
301 /**
302 * Save Course.
303 *
304 * @since 4.3
305 * @version 1.0.1
306 */
307 public function save_courses() {
308 $response = new LP_REST_Response();
309 $response->data = new stdClass();
310
311 try {
312 $data = self::check_valid_course();
313 $course_id = $data['course_id'] ?? 0;
314 $settings = $data['course_settings'] ?? false;
315 $insert = $data['insert'];
316 $course_title = isset( $data['course_title'] ) ? trim( sanitize_text_field( wp_unslash( (string) $data['course_title'] ) ) ) : '';
317 $course_status_requested = ! empty( $data['course_status'] ) ? sanitize_text_field( $data['course_status'] ) : 'publish';
318 $course_visibility = ! empty( $data['course_visibility'] ) ? sanitize_key( $data['course_visibility'] ) : '';
319 $course_password_raw = isset( $data['course_password'] ) ? wp_unslash( (string) $data['course_password'] ) : '';
320 $course_password = sanitize_text_field( $course_password_raw );
321 $course_post_date_raw = ! empty( $data['course_post_date'] ) ? sanitize_text_field( $data['course_post_date'] ) : '';
322 $course_post_date_data = $this->normalize_course_post_date_for_save( $course_post_date_raw );
323 $post_password_to_update = null;
324
325 if ( '' === $course_title ) {
326 throw new Exception( __( 'Course title is required.', 'learnpress' ) );
327 }
328
329 if ( $insert ) {
330 // Check user capability before insert
331 if ( ! current_user_can( 'edit_lp_courses' ) ) {
332 throw new Exception( __( 'You are not allowed to create courses', 'learnpress' ) );
333 }
334
335 $course_status = $this->normalize_course_status_for_save( $course_status_requested, true, null );
336
337 $categories = ! empty( $data['course_categories'] ) ? array_map( 'absint', explode( ',', $data['course_categories'] ) ) : array();
338 $tags = ! empty( $data['course_tags'] ) ? array_map( 'absint', explode( ',', $data['course_tags'] ) ) : array();
339
340 $insert_post_data = array(
341 'post_type' => LP_COURSE_CPT,
342 'post_title' => $course_title,
343 'post_content' => Template::sanitize_html_content( $data['course_description'] ?? '' ),
344 'post_status' => $course_status,
345 'tax_input' => array(
346 'course_category' => $categories,
347 'course_tag' => $tags,
348 ),
349 );
350
351 if ( ! empty( $course_post_date_data['post_date'] ) ) {
352 $insert_post_data['post_date'] = $course_post_date_data['post_date'];
353 $insert_post_data['post_date_gmt'] = $course_post_date_data['post_date_gmt'];
354 }
355
356 if ( in_array( $course_visibility, [ 'public', 'private' ], true ) ) {
357 $insert_post_data['post_password'] = '';
358 } elseif ( 'password' === $course_visibility ) {
359 if ( '' === $course_password ) {
360 throw new Exception( __( 'Password is required for password protected visibility.', 'learnpress' ) );
361 }
362 $insert_post_data['post_password'] = $course_password;
363 }
364
365 $course_id = wp_insert_post( $insert_post_data, true );
366
367 if ( is_wp_error( $course_id ) ) {
368 throw new Exception( $course_id->get_error_message() );
369 }
370
371 // Load the newly created course as CourseModel
372 $courseModel = CourseModel::find( $course_id, true );
373 if ( ! $courseModel ) {
374 throw new Exception( __( 'Failed to load course model', 'learnpress' ) );
375 }
376 } else {
377 $courseModel = $data['course_model'];
378 $course_status = $this->normalize_course_status_for_save( $course_status_requested, false, $courseModel );
379
380 $co_instructor_ids = $courseModel->get_meta_value_by_key( '_lp_co_teacher', [] );
381 if ( absint( $courseModel->post_author ) !== get_current_user_id() &&
382 ! current_user_can( 'manage_options' ) &&
383 ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
384 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
385 }
386
387 $courseModel->post_status = $course_status;
388 if ( ! empty( $course_post_date_data['post_date'] ) ) {
389 $courseModel->post_date = $course_post_date_data['post_date'];
390 $courseModel->post_date_gmt = $course_post_date_data['post_date_gmt'];
391 }
392
393 if ( in_array( $course_visibility, [ 'public', 'private' ], true ) ) {
394 $post_password_to_update = '';
395 } elseif ( 'password' === $course_visibility ) {
396 if ( '' !== $course_password ) {
397 $post_password_to_update = $course_password;
398 } else {
399 $existing_post = get_post( $courseModel->ID );
400 if ( $existing_post && ! empty( $existing_post->post_password ) ) {
401 $post_password_to_update = (string) $existing_post->post_password;
402 } else {
403 throw new Exception( __( 'Password is required for password protected visibility.', 'learnpress' ) );
404 }
405 }
406 }
407
408 if ( '' !== $course_title ) {
409 $categories = ! empty( $data['course_categories'] ) ? array_map( 'absint', explode( ',', $data['course_categories'] ) ) : array();
410 $tags = ! empty( $data['course_tags'] ) ? array_map( 'absint', explode( ',', $data['course_tags'] ) ) : array();
411
412 $courseModel->post_title = $course_title;
413 $courseModel->post_content = Template::sanitize_html_content( $data['course_description'] ?? '' );
414
415 wp_set_post_terms( $courseModel->ID, $categories, 'course_category' );
416 wp_set_post_terms( $courseModel->ID, $tags, 'course_tag' );
417 }
418
419 // Update permalink/slug if provided
420 if ( ! empty( $data['course_permalink'] ) ) {
421 $new_slug = sanitize_title( $data['course_permalink'] );
422 if ( $new_slug && $new_slug !== $courseModel->post_name ) {
423 wp_update_post(
424 array(
425 'ID' => $courseModel->ID,
426 'post_name' => $new_slug,
427 )
428 );
429 $courseModel->post_name = $new_slug;
430 }
431 }
432
433 $course_id = $courseModel->ID;
434 }
435
436 if ( $settings ) {
437 $this->save_course_settings_to_model( $courseModel, $data );
438 }
439
440 if ( ! empty( $courseModel->meta_data ) ) {
441 $coursePostModel = new CoursePostModel( $courseModel );
442 foreach ( $courseModel->meta_data as $meta_key => $meta_value ) {
443 $coursePostModel->save_meta_value_by_key( $meta_key, $meta_value );
444 }
445 $coursePostModel->save();
446 }
447
448 // Handle course thumbnail - AFTER coursePostModel->save() to avoid being overwritten
449 if ( isset( $data['course_thumbnail_id'] ) ) {
450 $thumbnail_id = absint( $data['course_thumbnail_id'] );
451
452 if ( $thumbnail_id > 0 ) {
453 $result = set_post_thumbnail( $course_id, $thumbnail_id );
454 } else {
455 $result = delete_post_thumbnail( $course_id );
456 }
457 $current_thumbnail = get_post_thumbnail_id( $course_id );
458 }
459
460 if ( null !== $post_password_to_update ) {
461 wp_update_post(
462 array(
463 'ID' => $course_id,
464 'post_password' => $post_password_to_update,
465 )
466 );
467 }
468
469 // Use persisted status after save to avoid UI drift when WP normalizes status by capability.
470 $saved_course_status = get_post_status( $course_id );
471 if ( ! is_string( $saved_course_status ) || '' === $saved_course_status ) {
472 $saved_course_status = $course_status;
473 }
474
475 $response->status = 'success';
476 $response->message = $insert ? __( 'Insert course successfully!', 'learnpress' ) : __( 'Update course successfully!', 'learnpress' );
477 $response->data->status = $saved_course_status;
478 $response->data->button_title = 'publish' === $saved_course_status
479 ? __( 'Update', 'learnpress' )
480 : ( 'pending' === $saved_course_status ? __( 'Submit for Review', 'learnpress' ) : __( 'Publish', 'learnpress' ) );
481 $response->data->course_id_new = $insert ? $course_id : '';
482
483 // Return the actual saved permalink data (important if WordPress auto-generated a unique slug)
484 $saved_post = get_post( $course_id );
485 if ( $saved_post ) {
486 $visibility = 'public';
487 if ( 'private' === $saved_post->post_status ) {
488 $visibility = 'private';
489 } elseif ( ! empty( $saved_post->post_password ) ) {
490 $visibility = 'password';
491 }
492
493 $response->data->visibility = $visibility;
494 $response->data->course_slug = $saved_post->post_name;
495 $response->data->course_permalink = get_permalink( $course_id );
496 }
497
498 // Return full redirect URL for new courses
499 if ( $insert && $course_id ) {
500 $response->data->redirect_url = CourseBuilder::get_link_course_builder( "courses/{$course_id}" );
501 }
502
503 wp_send_json( $response );
504 } catch ( Throwable $th ) {
505 $response->message = $th->getMessage();
506 wp_send_json( $response );
507 }
508 }
509
510 /**
511 * Save Course Settings only (from Settings tab).
512 *
513 * @since 4.3
514 * @version 1.0.0
515 */
516 public function save_course_settings() {
517 $response = new LP_REST_Response();
518 $response->data = new stdClass();
519
520 try {
521 $data = self::check_valid_course();
522 $course_id = $data['course_id'] ?? 0;
523 $insert = $data['insert'];
524 $courseModel = $data['course_model'];
525
526 if ( $insert || empty( $courseModel ) ) {
527 throw new Exception( __( 'Course not found. Please save the course first.', 'learnpress' ) );
528 }
529
530 $co_instructor_ids = $courseModel->get_meta_value_by_key( '_lp_co_teacher', [] );
531 if ( absint( $courseModel->post_author ) !== get_current_user_id() &&
532 ! current_user_can( 'manage_options' ) &&
533 ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
534 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
535 }
536
537 // Save course settings
538 $this->save_course_settings_to_model( $courseModel, $data );
539
540 // Save meta data
541 if ( ! empty( $courseModel->meta_data ) ) {
542 $coursePostModel = new CoursePostModel( $courseModel );
543 foreach ( $courseModel->meta_data as $meta_key => $meta_value ) {
544 $coursePostModel->save_meta_value_by_key( $meta_key, $meta_value );
545 }
546 $coursePostModel->save();
547 }
548
549 $response->status = 'success';
550 $response->message = __( 'Settings saved successfully!', 'learnpress' );
551
552 wp_send_json( $response );
553 } catch ( Throwable $th ) {
554 $response->status = 'error';
555 $response->message = $th->getMessage();
556 wp_send_json( $response );
557 }
558 }
559
560 /**
561 * Save all course settings to CourseModel
562 *
563 * @param CoursePostModel $courseModel
564 * @param array $data
565 *
566 * @throws Exception
567 */
568 public function save_course_settings_to_model( CoursePostModel &$courseModel, array $data ) {
569 // General settings
570 $this->save_general_settings_to_model( $courseModel, $data );
571
572 // Offline course settings
573 $this->save_offline_settings_to_model( $courseModel, $data );
574
575 // Price settings
576 $this->save_price_settings_to_model( $courseModel, $data );
577
578 // Extra info settings
579 $this->save_extra_settings_to_model( $courseModel, $data );
580
581 // Assessment settings
582 $this->save_assessment_settings_to_model( $courseModel, $data );
583
584 // Author settings
585 $this->save_author_settings_to_model( $courseModel, $data );
586
587 /**
588 * Allow addons to persist their own course settings fields added to the
589 * settings metabox tabs (e.g. Co-Instructor) when saving from Course Builder.
590 *
591 * @param CoursePostModel $courseModel Course model being saved.
592 * @param array $data Submitted settings data.
593 *
594 * @since 4.3.8
595 */
596 do_action( 'learn-press/course-builder/save-course-settings', $courseModel, $data );
597 }
598
599 /**
600 * Save general settings to CourseModel
601 *
602 * @param CoursePostModel $courseModel
603 * @param array $data
604 */
605 protected function save_general_settings_to_model( CoursePostModel &$courseModel, array $data ) {
606 if ( isset( $data['_lp_duration'] ) ) {
607 $duration_value = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
608 $explode = explode( ' ', $duration_value );
609 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
610 $unit = $explode[1] ?? 'minute';
611
612 $courseModel->meta_data->{CoursePostModel::META_KEY_DURATION} = $number . ' ' . $unit;
613 }
614
615 $checkbox_fields = [
616 '_lp_block_expire_duration' => CoursePostModel::META_KEY_BLOCK_EXPIRE_DURATION,
617 '_lp_block_finished' => CoursePostModel::META_KEY_BLOCK_FINISH,
618 '_lp_allow_course_repurchase' => CoursePostModel::META_KEY_ALLOW_COURSE_REPURCHASE,
619 '_lp_has_finish' => CoursePostModel::META_KEY_HAS_FINISH,
620 '_lp_featured' => CoursePostModel::META_KEY_FEATURED,
621 ];
622
623 foreach ( $checkbox_fields as $key => $meta_key ) {
624 if ( isset( $data[ $key ] ) ) {
625 $courseModel->meta_data->{$meta_key} = $data[ $key ] === 'yes' ? 'yes' : '';
626 }
627 }
628
629 $simple_fields = [
630 '_lp_course_repurchase_option' => CoursePostModel::META_KEY_COURSE_REPURCHASE_OPTION,
631 '_lp_level' => CoursePostModel::META_KEY_LEVEL,
632 '_lp_featured_review' => CoursePostModel::META_KEY_FEATURED_REVIEW,
633 '_lp_external_link_buy_course' => CoursePostModel::META_KEY_EXTERNAL_LINK_BY_COURSE,
634 ];
635
636 foreach ( $simple_fields as $key => $meta_key ) {
637 if ( isset( $data[ $key ] ) ) {
638 $courseModel->meta_data->{$meta_key} = sanitize_text_field( $data[ $key ] );
639 }
640 }
641
642 $numeric_fields = [
643 '_lp_students' => CoursePostModel::META_KEY_STUDENTS,
644 '_lp_max_students' => CoursePostModel::META_KEY_MAX_STUDENTS,
645 '_lp_retake_count' => CoursePostModel::META_KEY_RETAKE_COUNT,
646 ];
647
648 foreach ( $numeric_fields as $key => $meta_key ) {
649 if ( isset( $data[ $key ] ) ) {
650 $value = absint( $data[ $key ] );
651 $courseModel->meta_data->{$meta_key} = $value;
652 }
653 }
654 }
655
656 /**
657 * Save offline course settings to CourseModel
658 *
659 * @param CoursePostModel $courseModel
660 * @param array $data
661 */
662 protected function save_offline_settings_to_model( CoursePostModel &$courseModel, array $data ) {
663 if ( isset( $data['_lp_offline_course'] ) ) {
664 $courseModel->meta_data->{CoursePostModel::META_KEY_OFFLINE_COURSE} = $data['_lp_offline_course'] === 'yes' ? 'yes' : '';
665 }
666
667 if ( isset( $data['_lp_offline_lesson_count'] ) ) {
668 $courseModel->meta_data->{CoursePostModel::META_KEY_OFFLINE_LESSON_COUNT} = absint( $data['_lp_offline_lesson_count'] );
669 }
670
671 if ( isset( $data['_lp_deliver_type'] ) ) {
672 $courseModel->meta_data->{CoursePostModel::META_KEY_DELIVER} = sanitize_text_field( $data['_lp_deliver_type'] );
673 }
674
675 if ( isset( $data['_lp_address'] ) ) {
676 $courseModel->meta_data->{CoursePostModel::META_KEY_ADDRESS} = sanitize_text_field( $data['_lp_address'] );
677 }
678 }
679
680 /**
681 * Save price settings to CourseModel
682 *
683 * @param CoursePostModel $courseModel
684 * @param array $data
685 */
686 protected function save_price_settings_to_model( CoursePostModel &$courseModel, array $data ) {
687 // Regular price
688 if ( isset( $data['_lp_regular_price'] ) ) {
689 $regular_price = floatval( $data['_lp_regular_price'] );
690 if ( $regular_price < 0 ) {
691 $regular_price = '';
692 }
693 $courseModel->meta_data->{CoursePostModel::META_KEY_REGULAR_PRICE} = $regular_price;
694 }
695
696 // Sale price
697 if ( isset( $data['_lp_sale_price'] ) ) {
698 $sale_price = $data['_lp_sale_price'] !== '' ? floatval( $data['_lp_sale_price'] ) : '';
699 $regular_price = $courseModel->get_regular_price();
700
701 if ( $sale_price !== '' && $sale_price > $regular_price ) {
702 $sale_price = '';
703 }
704 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_PRICE} = $sale_price;
705 }
706
707 // Sale dates
708 if ( isset( $data['_lp_sale_start'] ) ) {
709 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_START} = sanitize_text_field( $data['_lp_sale_start'] );
710 }
711
712 if ( isset( $data['_lp_sale_end'] ) ) {
713 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_END} = sanitize_text_field( $data['_lp_sale_end'] );
714 }
715
716 // Price prefix/suffix
717 if ( isset( $data['_lp_price_prefix'] ) ) {
718 $courseModel->meta_data->{CoursePostModel::META_KEY_PRICE_PREFIX} = sanitize_text_field( $data['_lp_price_prefix'] );
719 }
720
721 if ( isset( $data['_lp_price_suffix'] ) ) {
722 $courseModel->meta_data->{CoursePostModel::META_KEY_PRICE_SUFFIX} = sanitize_text_field( $data['_lp_price_suffix'] );
723 }
724
725 // No required enroll
726 if ( isset( $data['_lp_no_required_enroll'] ) ) {
727 $courseModel->meta_data->{CoursePostModel::META_KEY_NO_REQUIRED_ENROLL} = $data['_lp_no_required_enroll'] === 'yes' ? 'yes' : '';
728 }
729 }
730
731 /**
732 * Normalize an extra-info field value to an array.
733 *
734 * The Course Builder sends these fields as arrays (one entry per input);
735 * older paths may send a comma-separated string. Splitting on comma must
736 * never be applied to array input, or values that legitimately contain a
737 * comma get broken into multiple entries.
738 *
739 * @param mixed $value
740 * @return array
741 */
742 protected function extra_field_to_array( $value ): array {
743 if ( is_array( $value ) ) {
744 return $value;
745 }
746
747 return $value !== '' ? explode( ',', (string) $value ) : [];
748 }
749
750 /**
751 * Save extra info settings to CourseModel
752 *
753 * @param CoursePostModel $courseModel
754 * @param array $data
755 */
756 protected function save_extra_settings_to_model( CoursePostModel &$courseModel, array $data ) {
757 // Requirements
758 if ( isset( $data['_lp_requirements'] ) ) {
759 $requirements = array_filter(
760 $this->extra_field_to_array( $data['_lp_requirements'] ),
761 function ( $item ) {
762 return ! is_null( $item ) && $item !== '';
763 }
764 );
765 $courseModel->meta_data->{CoursePostModel::META_KEY_REQUIREMENTS} = array_map( 'sanitize_text_field', array_values( $requirements ) );
766 }
767
768 if ( isset( $data['_lp_target_audiences'] ) ) {
769 $target_audiences = array_filter(
770 $this->extra_field_to_array( $data['_lp_target_audiences'] ),
771 function ( $item ) {
772 return ! is_null( $item ) && $item !== '';
773 }
774 );
775 $courseModel->meta_data->{CoursePostModel::META_KEY_TARGET} = array_map( 'sanitize_text_field', array_values( $target_audiences ) );
776 }
777
778 if ( isset( $data['_lp_key_features'] ) ) {
779 $key_features = array_filter(
780 $this->extra_field_to_array( $data['_lp_key_features'] ),
781 function ( $item ) {
782 return ! is_null( $item ) && $item !== '';
783 }
784 );
785 $courseModel->meta_data->{CoursePostModel::META_KEY_FEATURES} = array_map( 'sanitize_text_field', array_values( $key_features ) );
786 }
787
788 // FAQs
789 if ( isset( $data['_lp_faqs_question'] ) ) {
790 $questions = $this->extra_field_to_array( $data['_lp_faqs_question'] );
791 $answers = ! empty( $data['_lp_faqs_answer'] ) ? $this->extra_field_to_array( $data['_lp_faqs_answer'] ) : [];
792 $faqs = [];
793
794 if ( ! empty( $questions ) ) {
795 foreach ( $questions as $index => $question ) {
796 $clean_question = trim( $question );
797 if ( ! empty( $clean_question ) ) {
798 $answer_content = $answers[ $index ] ?? '';
799 $faqs[] = [ sanitize_text_field( $clean_question ), wp_kses_post( $answer_content ) ];
800 }
801 }
802 }
803 $courseModel->meta_data->{CoursePostModel::META_KEY_FAQS} = $faqs;
804 }
805 }
806
807 /**
808 * Save assessment settings to CourseModel
809 *
810 * @param CoursePostModel $courseModel
811 * @param array $data
812 */
813 protected function save_assessment_settings_to_model( CoursePostModel &$courseModel, array $data ) {
814 // Course result evaluation type
815 if ( isset( $data['_lp_course_result'] ) ) {
816 $courseModel->meta_data->{CoursePostModel::META_KEY_EVALUATION_TYPE} = sanitize_text_field( $data['_lp_course_result'] );
817 }
818
819 // Passing condition
820 if ( isset( $data['_lp_passing_condition'] ) ) {
821 $passing_condition = floatval( $data['_lp_passing_condition'] );
822 if ( $passing_condition < 0 ) {
823 $passing_condition = 0;
824 } elseif ( $passing_condition > 100 ) {
825 $passing_condition = 100;
826 }
827 $courseModel->meta_data->{CoursePostModel::META_KEY_PASSING_CONDITION} = $passing_condition;
828 }
829 }
830
831 /**
832 * Save author settings to CourseModel
833 *
834 * @param CoursePostModel $courseModel
835 * @param array $data
836 */
837 protected function save_author_settings_to_model( CoursePostModel &$courseModel, array $data ) {
838 if ( ! isset( $data['_post_author'] ) ) {
839 return;
840 }
841
842 $new_author_id = absint( $data['_post_author'] );
843 if ( $new_author_id <= 0 || $new_author_id === (int) $courseModel->post_author ) {
844 return;
845 }
846 $courseModel->meta_data->_post_author = $new_author_id;
847 $courseModel->post_author = $new_author_id;
848 }
849
850 /**
851 * Duplicate for course.
852 *
853 */
854 public function duplicate_course() {
855 $response = new LP_REST_Response();
856 $response->data = new stdClass();
857
858 try {
859 $data = self::check_valid_course();
860 $course_id = $data['course_id'] ?? 0;
861 $courseModel = $data['course_model'];
862
863 if ( absint( $courseModel->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
864 throw new Exception( __( 'You are not allowed to duplicate this course', 'learnpress' ) );
865 }
866
867 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
868 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
869 }
870
871 $curd = new LP_Course_CURD();
872 $new_item_id = $curd->duplicate(
873 $course_id,
874 array(
875 'exclude_meta' => array(
876 'order-pending',
877 'order-processing',
878 'order-completed',
879 'order-cancelled',
880 'order-failed',
881 'count_enrolled_users',
882 '_lp_sample_data',
883 '_lp_retake_count',
884 ),
885 )
886 );
887
888 if ( is_wp_error( $new_item_id ) ) {
889 throw new Exception( $new_item_id->get_error_message() );
890 }
891
892 $courseModel = CourseModel::find( $new_item_id, true );
893 $html = BuilderListCoursesTemplate::render_course( $courseModel );
894 $response->status = 'success';
895 $response->data->html = $html;
896 $response->data->course_id_new = $new_item_id;
897 $response->data->redirect_url = CourseBuilder::get_link_course_builder( "courses/{$new_item_id}" );
898 $response->message = __( 'Course duplicated successfully', 'learnpress' );
899 wp_send_json( $response );
900 } catch ( Throwable $th ) {
901 $response->status = 'error';
902 $response->message = $th->getMessage();
903 wp_send_json( $response );
904 }
905 }
906
907 public function move_trash_course() {
908 $response = new LP_REST_Response();
909 $response->data = new stdClass();
910
911 try {
912 $data = self::check_valid_course();
913 $course_id = $data['course_id'] ?? 0;
914 $courseModel = $data['course_model'];
915 $status = $data['status'] ?? 'trash';
916
917 $co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
918 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
919
920 if ( absint( $courseModel->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
921 throw new Exception( __( 'You are not allowed to delete this course', 'learnpress' ) );
922 }
923
924 if ( $status === 'delete' ) {
925 if ( ! current_user_can( 'manage_options' ) ) {
926 throw new Exception( __( 'You are not allowed to delete this course', 'learnpress' ) );
927 }
928
929 wp_delete_post( $course_id, true );
930
931 $message = __( 'Course has been deleted', 'learnpress' );
932 } elseif ( $status === 'draft' ) {
933 $update = wp_update_post(
934 array(
935 'ID' => $course_id,
936 'post_type' => LP_COURSE_CPT,
937 'post_status' => 'draft',
938 )
939 );
940
941 if ( ! $update ) {
942 throw new Exception( __( 'Course cannot be moved to draft', 'learnpress' ) );
943 }
944
945 $message = __( 'Course has been moved to draft', 'learnpress' );
946 } else {
947 // Store original slug before trashing (wp_trash_post adds __trashed suffix)
948 $original_slug = $courseModel->post_name;
949
950 $delete = wp_trash_post( $course_id );
951
952 if ( ! $delete ) {
953 throw new Exception( __( 'Course cannot be moved to trash', 'learnpress' ) );
954 }
955
956 // Restore original slug after trashing
957 if ( $original_slug ) {
958 wp_update_post(
959 array(
960 'ID' => $course_id,
961 'post_name' => $original_slug,
962 )
963 );
964 }
965
966 $message = __( 'Course moved to trash', 'learnpress' );
967 }
968
969 $response->status = 'success';
970 $response->data->button_title = __( 'Publish', 'learnpress' );
971 $response->data->status = $status;
972 $response->message = $message;
973 wp_send_json( $response );
974 } catch ( Throwable $th ) {
975 $response->status = 'error';
976 $response->message = $th->getMessage();
977 wp_send_json( $response );
978 }
979 }
980
981 public function add_course_category() {
982 $response = new LP_REST_Response();
983 $response->data = new stdClass();
984
985 try {
986 $data = self::check_valid_course();
987 if ( ! current_user_can( 'edit_lp_courses' ) ) {
988 throw new Exception( __( 'You are not allowed to create categories', 'learnpress' ) );
989 }
990
991 $name = sanitize_text_field( $data['name'] ?? '' );
992 $parent = isset( $data['parent'] ) ? (int) $data['parent'] : 0;
993
994 if ( $parent < 0 ) {
995 $parent = 0;
996 }
997
998 $term = wp_insert_term( $name, 'course_category', array( 'parent' => $parent ) );
999
1000 if ( is_wp_error( $term ) ) {
1001 throw new Exception( $term->get_error_message() );
1002 }
1003
1004 $term_id = $term['term_id'];
1005
1006 $html = sprintf(
1007 '<li id="in-course_category-%1$s" class="popular-category">
1008 <label class="selectit">
1009 <input value="%1$s" type="checkbox" name="tax_input[course_category][]" id="in-course_category-%1$s" checked="checked">
1010 %2$s
1011 </label>
1012 </li>',
1013 esc_attr( $term_id ),
1014 esc_html( $name )
1015 );
1016
1017 $response->status = 'success';
1018 $response->data->html = $html;
1019 $response->data->term_id = $term_id;
1020 $response->data->parent = $parent;
1021 $response->message = __( 'Insert category successfully!', 'learnpress' );
1022 wp_send_json( $response );
1023 } catch ( Throwable $th ) {
1024 $response->status = 'error';
1025 $response->message = $th->getMessage();
1026 wp_send_json( $response );
1027 }
1028 }
1029
1030 public function add_course_tag() {
1031 $response = new LP_REST_Response();
1032 $response->data = new stdClass();
1033
1034 try {
1035 $data = self::check_valid_course();
1036 if ( ! current_user_can( 'edit_lp_courses' ) ) {
1037 throw new Exception( __( 'You are not allowed to create tags', 'learnpress' ) );
1038 }
1039
1040 $name = sanitize_text_field( wp_unslash( $data['name'] ?? '' ) );
1041 $term = wp_insert_term( $name, 'course_tag', array() );
1042
1043 if ( is_wp_error( $term ) ) {
1044 throw new Exception( $term->get_error_message() );
1045 }
1046
1047 $html = BuilderEditCourseTemplate::instance()->input_checkbox_tag_item( $term['term_id'], $name, false );
1048 $response->status = 'success';
1049 $response->data->html = $html;
1050 $response->message = __( 'Insert term successfully!', 'learnpress' );
1051 wp_send_json( $response );
1052 } catch ( Throwable $th ) {
1053 $response->status = 'error';
1054 $response->message = $th->getMessage();
1055 wp_send_json( $response );
1056 }
1057 }
1058
1059 /**
1060 * Duplicate for lesson.
1061 *
1062 */
1063 public function duplicate_lesson() {
1064 $response = new LP_REST_Response();
1065 $response->data = new stdClass();
1066
1067 try {
1068 $data = self::check_valid_lesson();
1069 $lesson_id = $data['lesson_id'] ?? 0;
1070 $lesson_model = $data['lesson_model'];
1071
1072 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1073 throw new Exception( __( 'You are not allowed to duplicate this lesson', 'learnpress' ) );
1074 }
1075
1076 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1077 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1078 }
1079
1080 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1081 $curd = new LP_Lesson_CURD();
1082 $new_item_id = $curd->duplicate( $lesson_id, $duplicate_args );
1083
1084 if ( is_wp_error( $new_item_id ) ) {
1085 throw new Exception( $new_item_id->get_error_message() );
1086 }
1087 $lesson_model_new = LessonPostModel::find( $new_item_id, true );
1088 $html = BuilderListLessonsTemplate::render_lesson( $lesson_model_new );
1089 $response->status = 'success';
1090 $response->data->html = $html;
1091 $response->message = __( 'Lesson duplicated successfully', 'learnpress' );
1092 wp_send_json( $response );
1093 } catch ( Throwable $th ) {
1094 $response->status = 'error';
1095 $response->message = $th->getMessage();
1096 wp_send_json( $response );
1097 }
1098 }
1099
1100 /**
1101 * Update Lesson from Course Builder.
1102 * Supports partial updates (title only, description only, or settings only).
1103 *
1104 * @since 4.3
1105 * @version 1.0.3
1106 */
1107 public function builder_update_lesson() {
1108 $response = new Response();
1109 $response->data = new stdClass();
1110
1111 try {
1112 $data = self::check_valid_lesson();
1113 $lesson_id = $data['lesson_id'] ?? 0;
1114 $title = LP_Helper::sanitize_params_submitted( $data['lesson_title'] ?? '' );
1115 $description = LP_Helper::sanitize_params_submitted(
1116 $data['lesson_description'] ?? '',
1117 'html'
1118 );
1119 $settings = $data['lesson_settings'] ?? false;
1120 $is_elementor = $data['is_elementor'] ?? false;
1121 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1122 $insert = $data['insert'];
1123 $lesson_slug = LP_Helper::sanitize_params_submitted(
1124 $data['lesson_permalink'] ?? '',
1125 'key'
1126 );
1127 $course_id = absint( $data['course_id'] ?? 0 );
1128
1129 // Determine target status (draft or publish)
1130 $target_status = LP_Helper::sanitize_params_submitted(
1131 $data['lesson_status'] ?? '',
1132 'key'
1133 );
1134 $restore_with_custom_slug = false;
1135
1136 if ( empty( $title ) ) {
1137 throw new Exception( __( 'Lesson title is required', 'learnpress' ) );
1138 }
1139
1140 if ( $insert ) {
1141 $insert_arg = array(
1142 'post_title' => $title,
1143 'post_content' => $description,
1144 'post_status' => $target_status,
1145 'post_author' => get_current_user_id(),
1146 );
1147
1148 $lessonPostModelNew = new LessonPostModel( $insert_arg );
1149 $lessonPostModelNew->check_capabilities_create_item_course();
1150
1151 $lessonPostModelNew->save();
1152 $lesson_id = $lessonPostModelNew->ID;
1153
1154 $lesson_model = $lessonPostModelNew;
1155 $response->message = __( 'Create lesson successfully', 'learnpress' );
1156 } else {
1157 $lesson_model = $data['lesson_model'] ?? null;
1158 if ( ! $lesson_model instanceof LessonPostModel ) {
1159 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1160 }
1161
1162 $lesson_model->check_capabilities_update_item_course();
1163
1164 if ( ! $course_id ) {
1165 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1166 }
1167
1168 // Support for co-instructor.
1169 /*$co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
1170 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
1171
1172 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
1173 throw new Exception( __( 'You are not allowed to update this lesson', 'learnpress' ) );
1174 }*/
1175
1176 /*$update_arg = array(
1177 'ID' => $lesson_id,
1178 'post_type' => LP_LESSON_CPT,
1179 'post_status' => $target_status,
1180 );*/
1181
1182 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1183 \Elementor\Plugin::$instance->documents->get( $lesson_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1184 }
1185
1186 $lesson_model->post_title = $title;
1187 $lesson_model->post_content = $description;
1188 $lesson_model->post_status = $target_status;
1189 if ( ! empty( $lesson_slug ) ) {
1190 $lesson_model->post_name = $lesson_slug;
1191 }
1192
1193 //$restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $target_status, $lesson_slug );
1194
1195 //$update = wp_update_post( $update_arg );
1196 $lesson_model->save();
1197
1198 /*if ( $restore_with_custom_slug ) {
1199 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1200 }*/
1201
1202 if ( $course_id ) {
1203 $courseModelCache = CourseModel::find( $course_id, true );
1204 if ( $courseModelCache ) {
1205 $courseModelCache->sections_items = null;
1206 $courseModelCache->save();
1207 }
1208 }
1209
1210 $response->message = __( 'Update lesson successfully', 'learnpress' );
1211 }
1212
1213 if ( $settings ) {
1214 $this->save_lesson_settings_to_model( $lesson_model, $data );
1215 }
1216
1217 // Remove lesson from curriculum if status is not public
1218 if ( $target_status !== 'publish' ) {
1219 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1220 }
1221
1222 $response->status = 'success';
1223 $response->data->status = $lesson_model->post_status;
1224 $response->data->button_title = $lesson_model->post_status === 'publish' ?
1225 __( 'Update', 'learnpress' ) :
1226 __( 'Publish', 'learnpress' );
1227 $response->data->lesson_id_new = $insert ? $lesson_id : '';
1228 $response->message = $target_status === 'draft'
1229 ? esc_html__( 'Lesson saved as draft', 'learnpress' )
1230 : ( $insert ? esc_html__( 'Insert lesson successfully', 'learnpress' ) : esc_html__( 'Update lesson successfully', 'learnpress' ) );
1231
1232 $response->data->lesson_slug = $lesson_model->post_name;
1233
1234 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1235 $response->data->permalink_available = false;
1236 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1237 if ( 'publish' === $lesson_model->post_status && $course_id_of_item ) {
1238 $course = learn_press_get_course( $course_id_of_item );
1239 if ( $course ) {
1240 $response->data->permalink_available = true;
1241 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1242 }
1243 }
1244
1245 $lesson_model_for_html = LessonPostModel::find( $lesson_id, true );
1246 if ( $return_html ) {
1247 $response->data->list_item_html = $lesson_model_for_html
1248 ? BuilderListLessonsTemplate::render_lesson( $lesson_model_for_html )
1249 : '';
1250
1251 if ( $course_id ) {
1252 $courseModelForHtml = CourseModel::find( $course_id, true );
1253 if ( $courseModelForHtml && $lesson_model_for_html ) {
1254 $item = new stdClass();
1255 $item->item_id = $lesson_id;
1256 $item->title = $lesson_model_for_html->post_title;
1257 $item->item_type = LP_LESSON_CPT;
1258 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1259 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1260 }
1261 }
1262 }
1263
1264 $response->status = Response::STATUS_SUCCESS;
1265 } catch ( Throwable $e ) {
1266 $response->message = $e->getMessage();
1267 }
1268
1269 wp_send_json( $response );
1270 }
1271
1272 public function move_trash_lesson() {
1273 $response = new LP_REST_Response();
1274 $response->data = new stdClass();
1275
1276 try {
1277 $data = self::check_valid_lesson();
1278 $lesson_id = $data['lesson_id'] ?? 0;
1279 $status = $data['status'] ?? 'trash';
1280 $lesson_model = $data['lesson_model'] ?? [];
1281 $lesson_slug = ! empty( $data['lesson_permalink'] )
1282 ? sanitize_title( wp_unslash( (string) $data['lesson_permalink'] ) )
1283 : '';
1284
1285 if ( ! $lesson_model ) {
1286 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1287 }
1288
1289 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1290 throw new Exception( __( 'You are not allowed to delete this lesson', 'learnpress' ) );
1291 }
1292 $course_id = absint( $data['course_id'] ?? 0 );
1293 if ( ! $course_id ) {
1294 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1295 }
1296
1297 if ( $status === 'trash' ) {
1298 $original_slug = (string) get_post_field( 'post_name', $lesson_id );
1299 $move_trash = wp_trash_post( $lesson_id );
1300
1301 if ( is_wp_error( $move_trash ) ) {
1302 throw new Exception( esc_html__( 'Cannot move this lesson to trash', 'learnpress' ) );
1303 }
1304
1305 if ( '' !== $original_slug ) {
1306 wp_update_post(
1307 [
1308 'ID' => $lesson_id,
1309 'post_name' => $original_slug,
1310 ]
1311 );
1312 }
1313
1314 $message = esc_html__( 'This lesson has been moved to trash.', 'learnpress' );
1315 } elseif ( $status === 'delete' ) {
1316
1317 if ( $lesson_model->post_status !== 'trash' ) {
1318 throw new Exception( esc_html__( 'Lesson must be trashed before deleting.', 'learnpress' ) );
1319 }
1320
1321 $delete = wp_delete_post( $lesson_id );
1322
1323 if ( is_wp_error( $delete ) ) {
1324 throw new Exception( esc_html__( 'Cannot delete this lesson.', 'learnpress' ) );
1325 }
1326 $message = esc_html__( 'Delete this lesson successfully', 'learnpress' );
1327 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1328 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $status, $lesson_slug );
1329 $update_args = [
1330 'ID' => $lesson_id,
1331 'post_type' => LP_LESSON_CPT,
1332 'post_status' => $status,
1333 ];
1334
1335 if ( '' !== $lesson_slug ) {
1336 $update_args['post_name'] = $lesson_slug;
1337 }
1338
1339 $update = wp_update_post( $update_args );
1340 if ( ! $update ) {
1341 if ( 'draft' === $status ) {
1342 throw new Exception( __( 'Lesson cannot be moved to draft', 'learnpress' ) );
1343 }
1344
1345 throw new Exception( __( 'Lesson cannot be moved to publish', 'learnpress' ) );
1346 }
1347
1348 if ( $restore_with_custom_slug ) {
1349 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1350 }
1351
1352 $message = 'draft' === $status
1353 ? __( 'Lesson has been moved to draft', 'learnpress' )
1354 : __( 'Lesson has been moved to publish', 'learnpress' );
1355 } else {
1356 throw new Exception( __( 'Invalid lesson status transition', 'learnpress' ) );
1357 }
1358
1359 $saved_lesson_status = get_post_status( $lesson_id );
1360 if ( ! is_string( $saved_lesson_status ) || '' === $saved_lesson_status ) {
1361 $saved_lesson_status = $status;
1362 }
1363
1364 if ( $saved_lesson_status !== 'publish' ) {
1365 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1366 }
1367
1368 $response->data->status = $saved_lesson_status;
1369 $response->data->button_title = 'publish' === $saved_lesson_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1370 $response->data->lesson_slug = (string) get_post_field( 'post_name', $lesson_id );
1371 $response->data->permalink_available = false;
1372 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1373
1374 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1375 if ( 'publish' === $saved_lesson_status && $course_id_of_item ) {
1376 $course = learn_press_get_course( $course_id_of_item );
1377 if ( $course ) {
1378 $response->data->permalink_available = true;
1379 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1380 }
1381 }
1382
1383 if ( 'delete' !== $status ) {
1384 $lesson_model_new = LessonPostModel::find( $lesson_id, true );
1385 $response->data->html = $lesson_model_new ? BuilderListLessonsTemplate::render_lesson( $lesson_model_new ) : '';
1386 }
1387
1388 $response->status = 'success';
1389 $response->message = $message;
1390 wp_send_json( $response );
1391 } catch ( Throwable $th ) {
1392 $response->status = 'error';
1393 $response->message = $th->getMessage();
1394 wp_send_json( $response );
1395 }
1396 }
1397
1398 /**
1399 * Save Lesson Settings
1400 * @throws Exception
1401 */
1402 protected function save_lesson_settings_to_model( LessonPostModel $lessonModel, array $data ) {
1403 $must_save = 0;
1404
1405 if ( isset( $data['_lp_duration'] ) ) {
1406 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1407 $explode = explode( ' ', $duration );
1408 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1409 $unit = $explode[1] ?? 'minute';
1410
1411 $lessonModel->set_meta_value_by_key( $lessonModel::META_KEY_DURATION, $number . ' ' . $unit );
1412 $must_save = 1;
1413 }
1414
1415 if ( isset( $data['_lp_preview'] ) ) {
1416 $enable = $data['_lp_preview'] === 'yes';
1417 $lessonModel->set_preview( $enable );
1418 $must_save = 1;
1419 }
1420
1421 if ( $must_save ) {
1422 $lessonModel->save();
1423 }
1424
1425 /**
1426 * Allow addons to persist their own lesson settings fields added to the
1427 * settings metabox tabs when saving from Course Builder.
1428 *
1429 * @param LessonPostModel $lessonModel Lesson model being saved.
1430 * @param array $data Submitted settings data.
1431 *
1432 * @since 4.3.8
1433 */
1434 do_action( 'learn-press/course-builder/save-lesson-settings', $lessonModel, $data );
1435 }
1436
1437 /**
1438 * Save Quiz Settings to QuizPostModel
1439 *
1440 * @param QuizPostModel $quizModel
1441 * @param array $data
1442 *
1443 * @since 4.3
1444 * @version 1.0.0
1445 */
1446 protected function save_quiz_settings_to_model( QuizPostModel $quizModel, array $data ) {
1447 if ( isset( $data['_lp_duration'] ) ) {
1448 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1449 $explode = explode( ' ', $duration );
1450 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1451 $unit = $explode[1] ?? 'minute';
1452
1453 $quizModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1454 }
1455
1456 $checkbox_keys = [
1457 '_lp_instant_check',
1458 '_lp_negative_marking',
1459 '_lp_minus_skip_questions',
1460 '_lp_review',
1461 '_lp_show_correct_review',
1462 '_lp_show_check_answer',
1463 '_lp_show_hint',
1464 ];
1465
1466 foreach ( $checkbox_keys as $key ) {
1467 if ( isset( $data[ $key ] ) ) {
1468 $value = $data[ $key ] === 'yes' ? 'yes' : 'no';
1469 $quizModel->save_meta_value_by_key( $key, $value );
1470 }
1471 }
1472
1473 $numeric_keys = [
1474 '_lp_passing_grade',
1475 '_lp_retake_count',
1476 '_lp_pagination',
1477 ];
1478
1479 foreach ( $numeric_keys as $key ) {
1480 if ( isset( $data[ $key ] ) ) {
1481 $value = absint( $data[ $key ] );
1482 if ( '_lp_passing_grade' === $key && $value > 100 ) {
1483 $value = 100;
1484 }
1485 $quizModel->save_meta_value_by_key( $key, $value );
1486 }
1487 }
1488
1489 /**
1490 * Allow addons to persist their own quiz settings fields added to the
1491 * settings metabox tabs when saving from Course Builder.
1492 *
1493 * @param QuizPostModel $quizModel Quiz model being saved.
1494 * @param array $data Submitted settings data.
1495 *
1496 * @since 4.3.8
1497 */
1498 do_action( 'learn-press/course-builder/save-quiz-settings', $quizModel, $data );
1499 }
1500
1501 /**
1502 * Update Quiz from Course Builder.
1503 * Supports partial updates (title only, description only, or settings only).
1504 *
1505 * @since 4.3
1506 * @version 1.0.2
1507 */
1508 public function builder_update_quiz() {
1509 $response = new Response();
1510 $response->data = new stdClass();
1511
1512 try {
1513 $data = self::check_valid_quiz();
1514 $quiz_id = $data['quiz_id'] ?? 0;
1515 $title = LP_Helper::sanitize_params_submitted( $data['quiz_title'] ?? '' );
1516 $description = LP_Helper::sanitize_params_submitted(
1517 $data['quiz_description'] ?? '',
1518 'html'
1519 );
1520 $settings = $data['quiz_settings'] ?? false;
1521 $is_elementor = $data['is_elementor'] ?? false;
1522 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1523 $insert = $data['insert'];
1524 $quiz_slug = LP_Helper::sanitize_params_submitted(
1525 $data['quiz_permalink'] ?? '',
1526 'key'
1527 );
1528 $course_id = absint( $data['course_id'] ?? 0 );
1529
1530 // Determine target status (draft or publish)
1531 $target_status = LP_Helper::sanitize_params_submitted(
1532 $data['quiz_status'] ?? '',
1533 'key'
1534 );
1535
1536 if ( empty( $title ) ) {
1537 throw new Exception( __( 'Quiz title is required', 'learnpress' ) );
1538 }
1539
1540 if ( $insert ) {
1541 $insert_arg = array(
1542 'post_title' => $title,
1543 'post_content' => $description,
1544 'post_status' => $target_status,
1545 'post_author' => get_current_user_id(),
1546 );
1547
1548 $quizPostModelNew = new QuizPostModel( $insert_arg );
1549 $quizPostModelNew->check_capabilities_create_item_course();
1550
1551 $quizPostModelNew->save();
1552 $quiz_id = $quizPostModelNew->ID;
1553
1554 $quiz_model = $quizPostModelNew;
1555 $response->message = __( 'Create quiz successfully', 'learnpress' );
1556 } else {
1557 $quiz_model = $data['quiz_model'] ?? null;
1558 if ( ! $quiz_model instanceof QuizPostModel ) {
1559 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1560 }
1561
1562 $quiz_model->check_capabilities_update_item_course();
1563
1564 if ( ! $course_id ) {
1565 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1566 }
1567
1568 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1569 \Elementor\Plugin::$instance->documents->get( $quiz_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1570 }
1571
1572 $quiz_model->post_title = $title;
1573 $quiz_model->post_content = $description;
1574 $quiz_model->post_status = $target_status;
1575 if ( ! empty( $quiz_slug ) ) {
1576 $quiz_model->post_name = $quiz_slug;
1577 }
1578
1579 $quiz_model->save();
1580
1581 if ( $course_id ) {
1582 $courseModelCache = CourseModel::find( $course_id, true );
1583 if ( $courseModelCache ) {
1584 $courseModelCache->sections_items = null;
1585 $courseModelCache->save();
1586 }
1587 }
1588
1589 $response->message = __( 'Update quiz successfully', 'learnpress' );
1590 }
1591
1592 if ( $settings ) {
1593 $this->save_quiz_settings_to_model( $quiz_model, $data );
1594 }
1595
1596 // Remove quiz from curriculum if status is not public
1597 if ( $target_status !== 'publish' ) {
1598 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1599 }
1600
1601 $response->status = 'success';
1602 $response->data->status = $quiz_model->post_status;
1603 $response->data->button_title = $quiz_model->post_status === 'publish' ?
1604 __( 'Update', 'learnpress' ) :
1605 __( 'Publish', 'learnpress' );
1606 $response->data->quiz_id_new = $insert ? $quiz_id : '';
1607 if ( $insert && $quiz_id ) {
1608 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$quiz_id}" );
1609 }
1610 $response->message = $target_status === 'draft'
1611 ? esc_html__( 'Quiz saved as draft', 'learnpress' )
1612 : ( $insert ? esc_html__( 'Insert quiz successfully', 'learnpress' ) : esc_html__( 'Update quiz successfully', 'learnpress' ) );
1613
1614 $response->data->quiz_slug = $quiz_model->post_name;
1615
1616 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1617 $response->data->permalink_available = false;
1618 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1619 if ( 'publish' === $quiz_model->post_status && $course_id_of_item ) {
1620 $course = learn_press_get_course( $course_id_of_item );
1621 if ( $course ) {
1622 $response->data->permalink_available = true;
1623 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1624 }
1625 }
1626
1627 $quiz_model_for_html = QuizPostModel::find( $quiz_id, true );
1628 if ( $return_html ) {
1629 $response->data->list_item_html = $quiz_model_for_html
1630 ? BuilderListQuizzesTemplate::render_quiz( $quiz_model_for_html )
1631 : '';
1632
1633 if ( $course_id ) {
1634 $courseModelForHtml = CourseModel::find( $course_id, true );
1635 if ( $courseModelForHtml && $quiz_model_for_html ) {
1636 $item = new stdClass();
1637 $item->item_id = $quiz_id;
1638 $item->title = $quiz_model_for_html->post_title;
1639 $item->item_type = LP_QUIZ_CPT;
1640 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1641 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1642 }
1643 }
1644 }
1645
1646 $response->status = Response::STATUS_SUCCESS;
1647 } catch ( Throwable $e ) {
1648 $response->message = $e->getMessage();
1649 }
1650
1651 wp_send_json( $response );
1652 }
1653
1654 public function duplicate_quiz() {
1655 $response = new LP_REST_Response();
1656 $response->data = new stdClass();
1657
1658 try {
1659 $data = self::check_valid_quiz();
1660 $quiz_id = $data['quiz_id'] ?? 0;
1661 $quiz_model = $data['quiz_model'];
1662
1663 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1664 throw new Exception( __( 'You are not allowed to duplicate this quiz', 'learnpress' ) );
1665 }
1666
1667 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1668 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1669 }
1670
1671 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1672 $curd = new LP_Quiz_CURD();
1673 $new_item_id = $curd->duplicate( $quiz_id, $duplicate_args );
1674
1675 if ( is_wp_error( $new_item_id ) ) {
1676 throw new Exception( $new_item_id->get_error_message() );
1677 }
1678 $response->status = 'success';
1679 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$new_item_id}" );
1680 $response->message = __( 'Quiz duplicated successfully', 'learnpress' );
1681 wp_send_json( $response );
1682 } catch ( Throwable $th ) {
1683 $response->status = 'error';
1684 $response->message = $th->getMessage();
1685 wp_send_json( $response );
1686 }
1687 }
1688
1689 public function move_trash_quiz() {
1690 $response = new LP_REST_Response();
1691 $response->data = new stdClass();
1692
1693 try {
1694 $data = self::check_valid_quiz();
1695 $quiz_id = $data['quiz_id'] ?? 0;
1696 $status = $data['status'] ?? 'trash';
1697 $quiz_model = $data['quiz_model'] ?? [];
1698 $quiz_slug = ! empty( $data['quiz_permalink'] )
1699 ? sanitize_title( wp_unslash( (string) $data['quiz_permalink'] ) )
1700 : '';
1701
1702 if ( ! $quiz_model ) {
1703 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1704 }
1705
1706 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1707 throw new Exception( __( 'You are not allowed to delete this quiz', 'learnpress' ) );
1708 }
1709 $course_id = absint( $data['course_id'] ?? 0 );
1710 if ( ! $course_id ) {
1711 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1712 }
1713
1714 if ( $status === 'trash' ) {
1715 $original_slug = (string) get_post_field( 'post_name', $quiz_id );
1716 $move_trash = wp_trash_post( $quiz_id );
1717
1718 if ( is_wp_error( $move_trash ) ) {
1719 throw new Exception( esc_html__( 'Cannot move this quiz to trash', 'learnpress' ) );
1720 }
1721
1722 if ( '' !== $original_slug ) {
1723 wp_update_post(
1724 [
1725 'ID' => $quiz_id,
1726 'post_name' => $original_slug,
1727 ]
1728 );
1729 }
1730
1731 $message = esc_html__( 'This quiz has been moved to trash.', 'learnpress' );
1732 } elseif ( $status === 'delete' ) {
1733
1734 if ( $quiz_model->post_status !== 'trash' ) {
1735 throw new Exception( esc_html__( 'Quiz must be trashed before deleting.', 'learnpress' ) );
1736 }
1737
1738 $delete = wp_delete_post( $quiz_id );
1739
1740 if ( is_wp_error( $delete ) ) {
1741 throw new Exception( esc_html__( 'Cannot delete this quiz.', 'learnpress' ) );
1742 }
1743 $message = esc_html__( 'Delete this quiz successfully', 'learnpress' );
1744 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1745 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $quiz_id, $status, $quiz_slug );
1746 $update_args = [
1747 'ID' => $quiz_id,
1748 'post_type' => LP_QUIZ_CPT,
1749 'post_status' => $status,
1750 ];
1751
1752 if ( '' !== $quiz_slug ) {
1753 $update_args['post_name'] = $quiz_slug;
1754 }
1755
1756 $update = wp_update_post( $update_args );
1757 if ( ! $update ) {
1758 if ( 'draft' === $status ) {
1759 throw new Exception( __( 'Quiz cannot be moved to draft', 'learnpress' ) );
1760 }
1761
1762 throw new Exception( __( 'Quiz cannot be moved to publish', 'learnpress' ) );
1763 }
1764
1765 if ( $restore_with_custom_slug ) {
1766 $this->sync_slug_after_restore( $quiz_id, $quiz_slug );
1767 }
1768
1769 $message = 'draft' === $status
1770 ? __( 'Quiz has been moved to draft', 'learnpress' )
1771 : __( 'Quiz has been moved to publish', 'learnpress' );
1772 } else {
1773 throw new Exception( __( 'Invalid quiz status transition', 'learnpress' ) );
1774 }
1775
1776 $saved_quiz_status = get_post_status( $quiz_id );
1777 if ( ! is_string( $saved_quiz_status ) || '' === $saved_quiz_status ) {
1778 $saved_quiz_status = $status;
1779 }
1780
1781 if ( $saved_quiz_status !== 'publish' ) {
1782 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1783 }
1784
1785 $response->data->status = $saved_quiz_status;
1786 $response->data->button_title = 'publish' === $saved_quiz_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1787 $response->data->quiz_slug = (string) get_post_field( 'post_name', $quiz_id );
1788 $response->data->permalink_available = false;
1789 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1790
1791 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1792 if ( 'publish' === $saved_quiz_status && $course_id_of_item ) {
1793 $course = learn_press_get_course( $course_id_of_item );
1794 if ( $course ) {
1795 $response->data->permalink_available = true;
1796 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1797 }
1798 }
1799
1800 if ( 'delete' !== $status ) {
1801 $fresh_quiz_model = QuizPostModel::find( $quiz_id, true );
1802 $response->data->html = $fresh_quiz_model
1803 ? BuilderListQuizzesTemplate::render_quiz( $fresh_quiz_model )
1804 : '';
1805 }
1806
1807 $response->status = 'success';
1808 $response->message = $message;
1809 wp_send_json( $response );
1810 } catch ( Throwable $th ) {
1811 $response->status = 'error';
1812 $response->message = $th->getMessage();
1813 wp_send_json( $response );
1814 }
1815 }
1816
1817 public function duplicate_question() {
1818 $response = new LP_REST_Response();
1819 $response->data = new stdClass();
1820
1821 try {
1822 $data = self::check_valid_question();
1823 $question_id = $data['question_id'] ?? 0;
1824 $question_model = $data['question_model'];
1825
1826 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1827 throw new Exception( __( 'You are not allowed to duplicate this question', 'learnpress' ) );
1828 }
1829
1830 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1831 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1832 }
1833
1834 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1835 $curd = new LP_Question_CURD();
1836 $new_item_id = $curd->duplicate( $question_id, $duplicate_args );
1837
1838 if ( is_wp_error( $new_item_id ) ) {
1839 throw new Exception( $new_item_id->get_error_message() );
1840 }
1841 $response->status = 'success';
1842 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$new_item_id}" );
1843 $response->message = __( 'Question duplicated successfully', 'learnpress' );
1844 wp_send_json( $response );
1845 } catch ( Throwable $th ) {
1846 $response->status = 'error';
1847 $response->message = $th->getMessage();
1848 wp_send_json( $response );
1849 }
1850 }
1851
1852 public function builder_update_question() {
1853 $response = new Response();
1854 $response->data = new stdClass();
1855
1856 try {
1857 $data = self::check_valid_question();
1858 $question_id = $data['question_id'] ?? 0;
1859 $title = LP_Helper::sanitize_params_submitted( $data['question_title'] ?? '' );
1860 $description = LP_Helper::sanitize_params_submitted(
1861 $data['question_description'] ?? '',
1862 'html'
1863 );
1864 $is_elementor = $data['is_elementor'] ?? false;
1865 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1866 $insert = $data['insert'];
1867 $question_slug = LP_Helper::sanitize_params_submitted(
1868 $data['question_permalink'] ?? '',
1869 'key'
1870 );
1871
1872 // Determine target status (draft or publish)
1873 $target_status = LP_Helper::sanitize_params_submitted(
1874 $data['question_status'] ?? '',
1875 'key'
1876 );
1877
1878 if ( empty( $title ) ) {
1879 throw new Exception( __( 'Question title is required', 'learnpress' ) );
1880 }
1881
1882 if ( $insert ) {
1883 $insert_arg = array(
1884 'post_title' => $title,
1885 'post_content' => $description,
1886 'post_status' => $target_status,
1887 'post_author' => get_current_user_id(),
1888 );
1889
1890 $questionPostModelNew = new QuestionPostModel( $insert_arg );
1891 $questionPostModelNew->check_capabilities_create_item_course();
1892
1893 $questionPostModelNew->save();
1894 $question_id = $questionPostModelNew->ID;
1895
1896 $question_model = $questionPostModelNew;
1897 $response->message = __( 'Create question successfully', 'learnpress' );
1898 } else {
1899 $question_model = $data['question_model'] ?? null;
1900 if ( ! $question_model instanceof QuestionPostModel ) {
1901 throw new Exception( __( 'Question not found', 'learnpress' ) );
1902 }
1903
1904 $question_model->check_capabilities_update_item_course();
1905
1906 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1907 \Elementor\Plugin::$instance->documents->get( $question_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1908 }
1909
1910 $question_model->post_title = $title;
1911 $question_model->post_content = $description;
1912 $question_model->post_status = $target_status;
1913 if ( ! empty( $question_slug ) ) {
1914 $question_model->post_name = $question_slug;
1915 }
1916
1917 $question_model->save();
1918
1919 $response->message = __( 'Update question successfully', 'learnpress' );
1920 }
1921
1922 // Remove question from quizzes if status is not public
1923 if ( $target_status !== 'publish' ) {
1924 $this->remove_question_from_assigned_quizzes( $question_id );
1925 }
1926
1927 do_action( 'learn-press/course-builder/update-question', $data, $question_model );
1928
1929 $response->status = 'success';
1930 $response->data->status = $question_model->post_status;
1931 $response->data->button_title = $question_model->post_status === 'publish' ?
1932 __( 'Update', 'learnpress' ) :
1933 __( 'Publish', 'learnpress' );
1934 $response->data->question_id_new = $insert ? $question_id : '';
1935 if ( $insert && $question_id ) {
1936 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$question_id}" );
1937 }
1938 $response->message = $target_status === 'draft'
1939 ? esc_html__( 'Question saved as draft', 'learnpress' )
1940 : ( $insert ? esc_html__( 'Insert question successfully', 'learnpress' ) : esc_html__( 'Update question successfully', 'learnpress' ) );
1941
1942 $response->data->question_slug = $question_model->post_name;
1943 $response->data->question_permalink = get_permalink( $question_id );
1944
1945 $question_model_for_html = QuestionPostModel::find( $question_id, true );
1946 if ( $return_html ) {
1947 $response->data->list_item_html = $question_model_for_html
1948 ? BuilderListQuestionsTemplate::render_question( $question_model_for_html )
1949 : '';
1950 }
1951
1952 $response->status = Response::STATUS_SUCCESS;
1953 } catch ( Throwable $e ) {
1954 $response->message = $e->getMessage();
1955 }
1956
1957 wp_send_json( $response );
1958 }
1959
1960 public function move_trash_question() {
1961 $response = new LP_REST_Response();
1962 $response->data = new stdClass();
1963
1964 try {
1965 $data = self::check_valid_question();
1966 $question_id = $data['question_id'] ?? 0;
1967 $status = $data['status'] ?? 'trash';
1968 $question_model = $data['question_model'] ?? [];
1969
1970 if ( ! $question_model ) {
1971 throw new Exception( __( 'Question not found', 'learnpress' ) );
1972 }
1973
1974 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1975 throw new Exception( __( 'You are not allowed to delete this question', 'learnpress' ) );
1976 }
1977
1978 if ( $status === 'trash' ) {
1979 $move_trash = wp_trash_post( $question_id );
1980
1981 if ( is_wp_error( $move_trash ) ) {
1982 throw new Exception( esc_html__( 'Cannot move this question to trash', 'learnpress' ) );
1983 }
1984 $message = esc_html__( 'This question has been moved to trash.', 'learnpress' );
1985 } elseif ( $status === 'delete' ) {
1986
1987 if ( $question_model->post_status !== 'trash' ) {
1988 throw new Exception( esc_html__( 'Question must be trashed before deleting.', 'learnpress' ) );
1989 }
1990
1991 $delete = wp_delete_post( $question_id );
1992
1993 if ( is_wp_error( $delete ) ) {
1994 throw new Exception( esc_html__( 'Cannot delete this question.', 'learnpress' ) );
1995 }
1996 $message = esc_html__( 'Delete this question successfully', 'learnpress' );
1997
1998 } elseif ( $status === 'publish' ) {
1999 $update = wp_update_post(
2000 array(
2001 'ID' => $question_id,
2002 'post_type' => LP_QUESTION_CPT,
2003 'post_status' => 'publish',
2004 )
2005 );
2006 if ( ! $update ) {
2007 throw new Exception( __( 'Question cannot be moved to publish', 'learnpress' ) );
2008 }
2009
2010 $message = __( 'Question has been moved to publish', 'learnpress' );
2011 } elseif ( $status === 'draft' ) {
2012 $update = wp_update_post(
2013 array(
2014 'ID' => $question_id,
2015 'post_type' => LP_QUESTION_CPT,
2016 'post_status' => 'draft',
2017 )
2018 );
2019 if ( ! $update ) {
2020 throw new Exception( __( 'Question cannot be restored to draft', 'learnpress' ) );
2021 }
2022
2023 $message = __( 'Question has been restored to draft', 'learnpress' );
2024 }
2025
2026 if ( $status !== 'publish' ) {
2027 $this->remove_question_from_assigned_quizzes( $question_id );
2028 }
2029
2030 $response->data->status = $status;
2031 $response->data->button_title = __( 'Publish', 'learnpress' );
2032
2033 if ( 'delete' !== $status ) {
2034 $fresh_question_model = QuestionPostModel::find( $question_id, true );
2035 $response->data->html = $fresh_question_model
2036 ? BuilderListQuestionsTemplate::render_question( $fresh_question_model )
2037 : '';
2038 }
2039
2040 $response->status = 'success';
2041 $response->message = $message;
2042 wp_send_json( $response );
2043 } catch ( Throwable $th ) {
2044 $response->status = 'error';
2045 $response->message = $th->getMessage();
2046 wp_send_json( $response );
2047 }
2048 }
2049
2050 /**
2051 * Remove lesson/quiz assignment from curriculum sections.
2052 *
2053 * @param int $item_id
2054 * @param int $course_id_fallback
2055 *
2056 * @return void
2057 */
2058 protected function remove_course_item_from_curriculum( int $item_id, int $course_id_fallback = 0 ) {
2059 if ( $item_id <= 0 ) {
2060 return;
2061 }
2062
2063 global $wpdb;
2064
2065 $section_items = $wpdb->get_results(
2066 $wpdb->prepare(
2067 "
2068 SELECT si.section_id, s.section_course_id
2069 FROM {$wpdb->learnpress_section_items} si
2070 INNER JOIN {$wpdb->learnpress_sections} s ON s.section_id = si.section_id
2071 WHERE si.item_id = %d
2072 ",
2073 $item_id
2074 )
2075 );
2076 if ( ! $section_items ) {
2077 if ( $course_id_fallback > 0 ) {
2078 $this->clean_course_curriculum_cache( $course_id_fallback );
2079 }
2080
2081 return;
2082 }
2083
2084 foreach ( $section_items as $section_item ) {
2085 $section_id = absint( $section_item->section_id ?? 0 );
2086 $course_id = absint( $section_item->section_course_id ?? 0 );
2087 if ( ! $section_id || ! $course_id ) {
2088 continue;
2089 }
2090
2091 $course_section_item_model = CourseSectionItemModel::find( $section_id, $item_id );
2092 if ( ! $course_section_item_model ) {
2093 continue;
2094 }
2095
2096 $course_section_item_model->section_course_id = $course_id;
2097 $course_section_item_model->delete();
2098 }
2099 }
2100
2101 /**
2102 * Keep course curriculum caches in sync after direct section item removal.
2103 *
2104 * @param int $course_id
2105 *
2106 * @return void
2107 */
2108 protected function clean_course_curriculum_cache( int $course_id ) {
2109 if ( $course_id <= 0 ) {
2110 return;
2111 }
2112
2113 try {
2114 $courseModel = CourseModel::find( $course_id, true );
2115 if ( ! $courseModel ) {
2116 return;
2117 }
2118
2119 $courseModel->sections_items = null;
2120 $courseModel->total_items = null;
2121 $courseModel->save( true );
2122 } catch ( Throwable $e ) {
2123 error_log( __METHOD__ . ': ' . $e->getMessage() );
2124 }
2125 }
2126
2127 /**
2128 * Remove question assignment from all quizzes.
2129 *
2130 * @param int $question_id
2131 *
2132 * @return void
2133 */
2134 protected function remove_question_from_assigned_quizzes( int $question_id ) {
2135 if ( $question_id <= 0 ) {
2136 return;
2137 }
2138
2139 global $wpdb;
2140 $wpdb->delete(
2141 $wpdb->prefix . 'learnpress_quiz_questions',
2142 array( 'question_id' => $question_id ),
2143 array( '%d' )
2144 );
2145 }
2146
2147 protected function get_course_by_item_id( $item_id ) {
2148 static $cache = [];
2149
2150 global $wpdb;
2151
2152 if ( empty( $item_id ) ) {
2153 return false;
2154 }
2155
2156 $item_id = absint( $item_id );
2157 if ( isset( $cache[ $item_id ] ) ) {
2158 return $cache[ $item_id ] ? $cache[ $item_id ] : false;
2159 }
2160
2161 $course_id = $wpdb->get_var(
2162 $wpdb->prepare(
2163 "SELECT c.ID FROM {$wpdb->posts} c
2164 INNER JOIN {$wpdb->learnpress_sections} s ON c.ID = s.section_course_id
2165 INNER JOIN {$wpdb->learnpress_section_items} si ON si.section_id = s.section_id
2166 WHERE si.item_id = %d ORDER BY si.section_id DESC LIMIT 1
2167 ",
2168 $item_id
2169 )
2170 );
2171 $cache[ $item_id ] = absint( $course_id );
2172
2173 if ( $cache[ $item_id ] ) {
2174 return $cache[ $item_id ];
2175 }
2176
2177 return false;
2178 }
2179
2180 /**
2181 * Save global settings for Course Builder.
2182 *
2183 * @since 4.3.6
2184 * @version 1.0.0
2185 */
2186 public function save_global_settings() {
2187 $response = new LP_REST_Response();
2188 $response->data = new stdClass();
2189
2190 try {
2191 $params = wp_unslash( $_REQUEST['data'] ?? '' );
2192 if ( empty( $params ) ) {
2193 throw new Exception( 'Error: params invalid!' );
2194 }
2195
2196 $data = LP_Helper::json_decode( $params, true );
2197
2198 if ( ! current_user_can( ADMIN_ROLE ) ) {
2199 throw new Exception( __( 'Permission denied', 'learnpress' ) );
2200 }
2201
2202 $hide_instructor_access_admin_screen = ! empty( $data['hide_instructor_access_admin_screen'] ) && $data['hide_instructor_access_admin_screen'] === 'yes' ? 'yes' : 'no';
2203 $logo_remove = ! empty( $data['course_builder_logo_remove'] ) && $data['course_builder_logo_remove'] === 'yes';
2204 $logo_id = absint( $data['course_builder_logo_id'] ?? 0 );
2205
2206 if ( $logo_remove ) {
2207 $logo_id = 0;
2208 }
2209
2210 LP_Settings::update_option( 'hide_instructor_access_admin_screen', $hide_instructor_access_admin_screen );
2211 LP_Settings::update_option( 'course_builder_logo_id', $logo_id );
2212
2213 $logo_url = '';
2214 if ( $logo_id ) {
2215 $logo_url = wp_get_attachment_image_url( $logo_id, 'full' );
2216 }
2217
2218 $response->status = 'success';
2219 $response->message = __( 'Course Builder settings updated.', 'learnpress' );
2220 $response->data->hide_instructor_access_admin_screen = $hide_instructor_access_admin_screen;
2221 $response->data->course_builder_logo_id = $logo_id;
2222 $response->data->course_builder_logo_url = $logo_url;
2223
2224 wp_send_json( $response );
2225 } catch ( Throwable $th ) {
2226 $response->status = 'error';
2227 $response->message = $th->getMessage();
2228 wp_send_json( $response );
2229 }
2230 }
2231 }
2232