PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
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 4.2.0 All 139 releases
learnpress / inc / Ajax / CourseBuilder / CourseBuilderAjax.php

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

2,223 lines 73.2 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 */
1401 protected function save_lesson_settings_to_model( LessonPostModel $lessonModel, array $data ) {
1402 if ( isset( $data['_lp_duration'] ) ) {
1403 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1404 $explode = explode( ' ', $duration );
1405 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1406 $unit = $explode[1] ?? 'minute';
1407
1408 $lessonModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1409 }
1410
1411 if ( isset( $data['_lp_preview'] ) ) {
1412 $enable = $data['_lp_preview'] === 'yes';
1413 $lessonModel->set_preview( $enable );
1414 }
1415
1416 /**
1417 * Allow addons to persist their own lesson settings fields added to the
1418 * settings metabox tabs when saving from Course Builder.
1419 *
1420 * @param LessonPostModel $lessonModel Lesson model being saved.
1421 * @param array $data Submitted settings data.
1422 *
1423 * @since 4.3.8
1424 */
1425 do_action( 'learn-press/course-builder/save-lesson-settings', $lessonModel, $data );
1426 }
1427
1428 /**
1429 * Save Quiz Settings to QuizPostModel
1430 *
1431 * @param QuizPostModel $quizModel
1432 * @param array $data
1433 *
1434 * @since 4.3
1435 * @version 1.0.0
1436 */
1437 protected function save_quiz_settings_to_model( QuizPostModel $quizModel, array $data ) {
1438 if ( isset( $data['_lp_duration'] ) ) {
1439 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1440 $explode = explode( ' ', $duration );
1441 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1442 $unit = $explode[1] ?? 'minute';
1443
1444 $quizModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1445 }
1446
1447 $checkbox_keys = [
1448 '_lp_instant_check',
1449 '_lp_negative_marking',
1450 '_lp_minus_skip_questions',
1451 '_lp_review',
1452 '_lp_show_correct_review',
1453 '_lp_show_check_answer',
1454 '_lp_show_hint',
1455 ];
1456
1457 foreach ( $checkbox_keys as $key ) {
1458 if ( isset( $data[ $key ] ) ) {
1459 $value = $data[ $key ] === 'yes' ? 'yes' : 'no';
1460 $quizModel->save_meta_value_by_key( $key, $value );
1461 }
1462 }
1463
1464 $numeric_keys = [
1465 '_lp_passing_grade',
1466 '_lp_retake_count',
1467 '_lp_pagination',
1468 ];
1469
1470 foreach ( $numeric_keys as $key ) {
1471 if ( isset( $data[ $key ] ) ) {
1472 $value = absint( $data[ $key ] );
1473 if ( '_lp_passing_grade' === $key && $value > 100 ) {
1474 $value = 100;
1475 }
1476 $quizModel->save_meta_value_by_key( $key, $value );
1477 }
1478 }
1479
1480 /**
1481 * Allow addons to persist their own quiz settings fields added to the
1482 * settings metabox tabs when saving from Course Builder.
1483 *
1484 * @param QuizPostModel $quizModel Quiz model being saved.
1485 * @param array $data Submitted settings data.
1486 *
1487 * @since 4.3.8
1488 */
1489 do_action( 'learn-press/course-builder/save-quiz-settings', $quizModel, $data );
1490 }
1491
1492 /**
1493 * Update Quiz from Course Builder.
1494 * Supports partial updates (title only, description only, or settings only).
1495 *
1496 * @since 4.3
1497 * @version 1.0.2
1498 */
1499 public function builder_update_quiz() {
1500 $response = new Response();
1501 $response->data = new stdClass();
1502
1503 try {
1504 $data = self::check_valid_quiz();
1505 $quiz_id = $data['quiz_id'] ?? 0;
1506 $title = LP_Helper::sanitize_params_submitted( $data['quiz_title'] ?? '' );
1507 $description = LP_Helper::sanitize_params_submitted(
1508 $data['quiz_description'] ?? '',
1509 'html'
1510 );
1511 $settings = $data['quiz_settings'] ?? false;
1512 $is_elementor = $data['is_elementor'] ?? false;
1513 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1514 $insert = $data['insert'];
1515 $quiz_slug = LP_Helper::sanitize_params_submitted(
1516 $data['quiz_permalink'] ?? '',
1517 'key'
1518 );
1519 $course_id = absint( $data['course_id'] ?? 0 );
1520
1521 // Determine target status (draft or publish)
1522 $target_status = LP_Helper::sanitize_params_submitted(
1523 $data['quiz_status'] ?? '',
1524 'key'
1525 );
1526
1527 if ( empty( $title ) ) {
1528 throw new Exception( __( 'Quiz title is required', 'learnpress' ) );
1529 }
1530
1531 if ( $insert ) {
1532 $insert_arg = array(
1533 'post_title' => $title,
1534 'post_content' => $description,
1535 'post_status' => $target_status,
1536 'post_author' => get_current_user_id(),
1537 );
1538
1539 $quizPostModelNew = new QuizPostModel( $insert_arg );
1540 $quizPostModelNew->check_capabilities_create_item_course();
1541
1542 $quizPostModelNew->save();
1543 $quiz_id = $quizPostModelNew->ID;
1544
1545 $quiz_model = $quizPostModelNew;
1546 $response->message = __( 'Create quiz successfully', 'learnpress' );
1547 } else {
1548 $quiz_model = $data['quiz_model'] ?? null;
1549 if ( ! $quiz_model instanceof QuizPostModel ) {
1550 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1551 }
1552
1553 $quiz_model->check_capabilities_update_item_course();
1554
1555 if ( ! $course_id ) {
1556 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1557 }
1558
1559 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1560 \Elementor\Plugin::$instance->documents->get( $quiz_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1561 }
1562
1563 $quiz_model->post_title = $title;
1564 $quiz_model->post_content = $description;
1565 $quiz_model->post_status = $target_status;
1566 if ( ! empty( $quiz_slug ) ) {
1567 $quiz_model->post_name = $quiz_slug;
1568 }
1569
1570 $quiz_model->save();
1571
1572 if ( $course_id ) {
1573 $courseModelCache = CourseModel::find( $course_id, true );
1574 if ( $courseModelCache ) {
1575 $courseModelCache->sections_items = null;
1576 $courseModelCache->save();
1577 }
1578 }
1579
1580 $response->message = __( 'Update quiz successfully', 'learnpress' );
1581 }
1582
1583 if ( $settings ) {
1584 $this->save_quiz_settings_to_model( $quiz_model, $data );
1585 }
1586
1587 // Remove quiz from curriculum if status is not public
1588 if ( $target_status !== 'publish' ) {
1589 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1590 }
1591
1592 $response->status = 'success';
1593 $response->data->status = $quiz_model->post_status;
1594 $response->data->button_title = $quiz_model->post_status === 'publish' ?
1595 __( 'Update', 'learnpress' ) :
1596 __( 'Publish', 'learnpress' );
1597 $response->data->quiz_id_new = $insert ? $quiz_id : '';
1598 if ( $insert && $quiz_id ) {
1599 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$quiz_id}" );
1600 }
1601 $response->message = $target_status === 'draft'
1602 ? esc_html__( 'Quiz saved as draft', 'learnpress' )
1603 : ( $insert ? esc_html__( 'Insert quiz successfully', 'learnpress' ) : esc_html__( 'Update quiz successfully', 'learnpress' ) );
1604
1605 $response->data->quiz_slug = $quiz_model->post_name;
1606
1607 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1608 $response->data->permalink_available = false;
1609 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1610 if ( 'publish' === $quiz_model->post_status && $course_id_of_item ) {
1611 $course = learn_press_get_course( $course_id_of_item );
1612 if ( $course ) {
1613 $response->data->permalink_available = true;
1614 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1615 }
1616 }
1617
1618 $quiz_model_for_html = QuizPostModel::find( $quiz_id, true );
1619 if ( $return_html ) {
1620 $response->data->list_item_html = $quiz_model_for_html
1621 ? BuilderListQuizzesTemplate::render_quiz( $quiz_model_for_html )
1622 : '';
1623
1624 if ( $course_id ) {
1625 $courseModelForHtml = CourseModel::find( $course_id, true );
1626 if ( $courseModelForHtml && $quiz_model_for_html ) {
1627 $item = new stdClass();
1628 $item->item_id = $quiz_id;
1629 $item->title = $quiz_model_for_html->post_title;
1630 $item->item_type = LP_QUIZ_CPT;
1631 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1632 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1633 }
1634 }
1635 }
1636
1637 $response->status = Response::STATUS_SUCCESS;
1638 } catch ( Throwable $e ) {
1639 $response->message = $e->getMessage();
1640 }
1641
1642 wp_send_json( $response );
1643 }
1644
1645 public function duplicate_quiz() {
1646 $response = new LP_REST_Response();
1647 $response->data = new stdClass();
1648
1649 try {
1650 $data = self::check_valid_quiz();
1651 $quiz_id = $data['quiz_id'] ?? 0;
1652 $quiz_model = $data['quiz_model'];
1653
1654 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1655 throw new Exception( __( 'You are not allowed to duplicate this quiz', 'learnpress' ) );
1656 }
1657
1658 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1659 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1660 }
1661
1662 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1663 $curd = new LP_Quiz_CURD();
1664 $new_item_id = $curd->duplicate( $quiz_id, $duplicate_args );
1665
1666 if ( is_wp_error( $new_item_id ) ) {
1667 throw new Exception( $new_item_id->get_error_message() );
1668 }
1669 $response->status = 'success';
1670 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$new_item_id}" );
1671 $response->message = __( 'Quiz duplicated successfully', 'learnpress' );
1672 wp_send_json( $response );
1673 } catch ( Throwable $th ) {
1674 $response->status = 'error';
1675 $response->message = $th->getMessage();
1676 wp_send_json( $response );
1677 }
1678 }
1679
1680 public function move_trash_quiz() {
1681 $response = new LP_REST_Response();
1682 $response->data = new stdClass();
1683
1684 try {
1685 $data = self::check_valid_quiz();
1686 $quiz_id = $data['quiz_id'] ?? 0;
1687 $status = $data['status'] ?? 'trash';
1688 $quiz_model = $data['quiz_model'] ?? [];
1689 $quiz_slug = ! empty( $data['quiz_permalink'] )
1690 ? sanitize_title( wp_unslash( (string) $data['quiz_permalink'] ) )
1691 : '';
1692
1693 if ( ! $quiz_model ) {
1694 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1695 }
1696
1697 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1698 throw new Exception( __( 'You are not allowed to delete this quiz', 'learnpress' ) );
1699 }
1700 $course_id = absint( $data['course_id'] ?? 0 );
1701 if ( ! $course_id ) {
1702 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1703 }
1704
1705 if ( $status === 'trash' ) {
1706 $original_slug = (string) get_post_field( 'post_name', $quiz_id );
1707 $move_trash = wp_trash_post( $quiz_id );
1708
1709 if ( is_wp_error( $move_trash ) ) {
1710 throw new Exception( esc_html__( 'Cannot move this quiz to trash', 'learnpress' ) );
1711 }
1712
1713 if ( '' !== $original_slug ) {
1714 wp_update_post(
1715 [
1716 'ID' => $quiz_id,
1717 'post_name' => $original_slug,
1718 ]
1719 );
1720 }
1721
1722 $message = esc_html__( 'This quiz has been moved to trash.', 'learnpress' );
1723 } elseif ( $status === 'delete' ) {
1724
1725 if ( $quiz_model->post_status !== 'trash' ) {
1726 throw new Exception( esc_html__( 'Quiz must be trashed before deleting.', 'learnpress' ) );
1727 }
1728
1729 $delete = wp_delete_post( $quiz_id );
1730
1731 if ( is_wp_error( $delete ) ) {
1732 throw new Exception( esc_html__( 'Cannot delete this quiz.', 'learnpress' ) );
1733 }
1734 $message = esc_html__( 'Delete this quiz successfully', 'learnpress' );
1735 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1736 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $quiz_id, $status, $quiz_slug );
1737 $update_args = [
1738 'ID' => $quiz_id,
1739 'post_type' => LP_QUIZ_CPT,
1740 'post_status' => $status,
1741 ];
1742
1743 if ( '' !== $quiz_slug ) {
1744 $update_args['post_name'] = $quiz_slug;
1745 }
1746
1747 $update = wp_update_post( $update_args );
1748 if ( ! $update ) {
1749 if ( 'draft' === $status ) {
1750 throw new Exception( __( 'Quiz cannot be moved to draft', 'learnpress' ) );
1751 }
1752
1753 throw new Exception( __( 'Quiz cannot be moved to publish', 'learnpress' ) );
1754 }
1755
1756 if ( $restore_with_custom_slug ) {
1757 $this->sync_slug_after_restore( $quiz_id, $quiz_slug );
1758 }
1759
1760 $message = 'draft' === $status
1761 ? __( 'Quiz has been moved to draft', 'learnpress' )
1762 : __( 'Quiz has been moved to publish', 'learnpress' );
1763 } else {
1764 throw new Exception( __( 'Invalid quiz status transition', 'learnpress' ) );
1765 }
1766
1767 $saved_quiz_status = get_post_status( $quiz_id );
1768 if ( ! is_string( $saved_quiz_status ) || '' === $saved_quiz_status ) {
1769 $saved_quiz_status = $status;
1770 }
1771
1772 if ( $saved_quiz_status !== 'publish' ) {
1773 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1774 }
1775
1776 $response->data->status = $saved_quiz_status;
1777 $response->data->button_title = 'publish' === $saved_quiz_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1778 $response->data->quiz_slug = (string) get_post_field( 'post_name', $quiz_id );
1779 $response->data->permalink_available = false;
1780 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1781
1782 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1783 if ( 'publish' === $saved_quiz_status && $course_id_of_item ) {
1784 $course = learn_press_get_course( $course_id_of_item );
1785 if ( $course ) {
1786 $response->data->permalink_available = true;
1787 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1788 }
1789 }
1790
1791 if ( 'delete' !== $status ) {
1792 $fresh_quiz_model = QuizPostModel::find( $quiz_id, true );
1793 $response->data->html = $fresh_quiz_model
1794 ? BuilderListQuizzesTemplate::render_quiz( $fresh_quiz_model )
1795 : '';
1796 }
1797
1798 $response->status = 'success';
1799 $response->message = $message;
1800 wp_send_json( $response );
1801 } catch ( Throwable $th ) {
1802 $response->status = 'error';
1803 $response->message = $th->getMessage();
1804 wp_send_json( $response );
1805 }
1806 }
1807
1808 public function duplicate_question() {
1809 $response = new LP_REST_Response();
1810 $response->data = new stdClass();
1811
1812 try {
1813 $data = self::check_valid_question();
1814 $question_id = $data['question_id'] ?? 0;
1815 $question_model = $data['question_model'];
1816
1817 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1818 throw new Exception( __( 'You are not allowed to duplicate this question', 'learnpress' ) );
1819 }
1820
1821 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1822 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1823 }
1824
1825 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1826 $curd = new LP_Question_CURD();
1827 $new_item_id = $curd->duplicate( $question_id, $duplicate_args );
1828
1829 if ( is_wp_error( $new_item_id ) ) {
1830 throw new Exception( $new_item_id->get_error_message() );
1831 }
1832 $response->status = 'success';
1833 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$new_item_id}" );
1834 $response->message = __( 'Question duplicated successfully', 'learnpress' );
1835 wp_send_json( $response );
1836 } catch ( Throwable $th ) {
1837 $response->status = 'error';
1838 $response->message = $th->getMessage();
1839 wp_send_json( $response );
1840 }
1841 }
1842
1843 public function builder_update_question() {
1844 $response = new Response();
1845 $response->data = new stdClass();
1846
1847 try {
1848 $data = self::check_valid_question();
1849 $question_id = $data['question_id'] ?? 0;
1850 $title = LP_Helper::sanitize_params_submitted( $data['question_title'] ?? '' );
1851 $description = LP_Helper::sanitize_params_submitted(
1852 $data['question_description'] ?? '',
1853 'html'
1854 );
1855 $is_elementor = $data['is_elementor'] ?? false;
1856 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1857 $insert = $data['insert'];
1858 $question_slug = LP_Helper::sanitize_params_submitted(
1859 $data['question_permalink'] ?? '',
1860 'key'
1861 );
1862
1863 // Determine target status (draft or publish)
1864 $target_status = LP_Helper::sanitize_params_submitted(
1865 $data['question_status'] ?? '',
1866 'key'
1867 );
1868
1869 if ( empty( $title ) ) {
1870 throw new Exception( __( 'Question title is required', 'learnpress' ) );
1871 }
1872
1873 if ( $insert ) {
1874 $insert_arg = array(
1875 'post_title' => $title,
1876 'post_content' => $description,
1877 'post_status' => $target_status,
1878 'post_author' => get_current_user_id(),
1879 );
1880
1881 $questionPostModelNew = new QuestionPostModel( $insert_arg );
1882 $questionPostModelNew->check_capabilities_create_item_course();
1883
1884 $questionPostModelNew->save();
1885 $question_id = $questionPostModelNew->ID;
1886
1887 $question_model = $questionPostModelNew;
1888 $response->message = __( 'Create question successfully', 'learnpress' );
1889 } else {
1890 $question_model = $data['question_model'] ?? null;
1891 if ( ! $question_model instanceof QuestionPostModel ) {
1892 throw new Exception( __( 'Question not found', 'learnpress' ) );
1893 }
1894
1895 $question_model->check_capabilities_update_item_course();
1896
1897 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1898 \Elementor\Plugin::$instance->documents->get( $question_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1899 }
1900
1901 $question_model->post_title = $title;
1902 $question_model->post_content = $description;
1903 $question_model->post_status = $target_status;
1904 if ( ! empty( $question_slug ) ) {
1905 $question_model->post_name = $question_slug;
1906 }
1907
1908 $question_model->save();
1909
1910 $response->message = __( 'Update question successfully', 'learnpress' );
1911 }
1912
1913 // Remove question from quizzes if status is not public
1914 if ( $target_status !== 'publish' ) {
1915 $this->remove_question_from_assigned_quizzes( $question_id );
1916 }
1917
1918 do_action( 'learn-press/course-builder/update-question', $data, $question_model );
1919
1920 $response->status = 'success';
1921 $response->data->status = $question_model->post_status;
1922 $response->data->button_title = $question_model->post_status === 'publish' ?
1923 __( 'Update', 'learnpress' ) :
1924 __( 'Publish', 'learnpress' );
1925 $response->data->question_id_new = $insert ? $question_id : '';
1926 if ( $insert && $question_id ) {
1927 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$question_id}" );
1928 }
1929 $response->message = $target_status === 'draft'
1930 ? esc_html__( 'Question saved as draft', 'learnpress' )
1931 : ( $insert ? esc_html__( 'Insert question successfully', 'learnpress' ) : esc_html__( 'Update question successfully', 'learnpress' ) );
1932
1933 $response->data->question_slug = $question_model->post_name;
1934 $response->data->question_permalink = get_permalink( $question_id );
1935
1936 $question_model_for_html = QuestionPostModel::find( $question_id, true );
1937 if ( $return_html ) {
1938 $response->data->list_item_html = $question_model_for_html
1939 ? BuilderListQuestionsTemplate::render_question( $question_model_for_html )
1940 : '';
1941 }
1942
1943 $response->status = Response::STATUS_SUCCESS;
1944 } catch ( Throwable $e ) {
1945 $response->message = $e->getMessage();
1946 }
1947
1948 wp_send_json( $response );
1949 }
1950
1951 public function move_trash_question() {
1952 $response = new LP_REST_Response();
1953 $response->data = new stdClass();
1954
1955 try {
1956 $data = self::check_valid_question();
1957 $question_id = $data['question_id'] ?? 0;
1958 $status = $data['status'] ?? 'trash';
1959 $question_model = $data['question_model'] ?? [];
1960
1961 if ( ! $question_model ) {
1962 throw new Exception( __( 'Question not found', 'learnpress' ) );
1963 }
1964
1965 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1966 throw new Exception( __( 'You are not allowed to delete this question', 'learnpress' ) );
1967 }
1968
1969 if ( $status === 'trash' ) {
1970 $move_trash = wp_trash_post( $question_id );
1971
1972 if ( is_wp_error( $move_trash ) ) {
1973 throw new Exception( esc_html__( 'Cannot move this question to trash', 'learnpress' ) );
1974 }
1975 $message = esc_html__( 'This question has been moved to trash.', 'learnpress' );
1976 } elseif ( $status === 'delete' ) {
1977
1978 if ( $question_model->post_status !== 'trash' ) {
1979 throw new Exception( esc_html__( 'Question must be trashed before deleting.', 'learnpress' ) );
1980 }
1981
1982 $delete = wp_delete_post( $question_id );
1983
1984 if ( is_wp_error( $delete ) ) {
1985 throw new Exception( esc_html__( 'Cannot delete this question.', 'learnpress' ) );
1986 }
1987 $message = esc_html__( 'Delete this question successfully', 'learnpress' );
1988
1989 } elseif ( $status === 'publish' ) {
1990 $update = wp_update_post(
1991 array(
1992 'ID' => $question_id,
1993 'post_type' => LP_QUESTION_CPT,
1994 'post_status' => 'publish',
1995 )
1996 );
1997 if ( ! $update ) {
1998 throw new Exception( __( 'Question cannot be moved to publish', 'learnpress' ) );
1999 }
2000
2001 $message = __( 'Question has been moved to publish', 'learnpress' );
2002 } elseif ( $status === 'draft' ) {
2003 $update = wp_update_post(
2004 array(
2005 'ID' => $question_id,
2006 'post_type' => LP_QUESTION_CPT,
2007 'post_status' => 'draft',
2008 )
2009 );
2010 if ( ! $update ) {
2011 throw new Exception( __( 'Question cannot be restored to draft', 'learnpress' ) );
2012 }
2013
2014 $message = __( 'Question has been restored to draft', 'learnpress' );
2015 }
2016
2017 if ( $status !== 'publish' ) {
2018 $this->remove_question_from_assigned_quizzes( $question_id );
2019 }
2020
2021 $response->data->status = $status;
2022 $response->data->button_title = __( 'Publish', 'learnpress' );
2023
2024 if ( 'delete' !== $status ) {
2025 $fresh_question_model = QuestionPostModel::find( $question_id, true );
2026 $response->data->html = $fresh_question_model
2027 ? BuilderListQuestionsTemplate::render_question( $fresh_question_model )
2028 : '';
2029 }
2030
2031 $response->status = 'success';
2032 $response->message = $message;
2033 wp_send_json( $response );
2034 } catch ( Throwable $th ) {
2035 $response->status = 'error';
2036 $response->message = $th->getMessage();
2037 wp_send_json( $response );
2038 }
2039 }
2040
2041 /**
2042 * Remove lesson/quiz assignment from curriculum sections.
2043 *
2044 * @param int $item_id
2045 * @param int $course_id_fallback
2046 *
2047 * @return void
2048 */
2049 protected function remove_course_item_from_curriculum( int $item_id, int $course_id_fallback = 0 ) {
2050 if ( $item_id <= 0 ) {
2051 return;
2052 }
2053
2054 global $wpdb;
2055
2056 $section_items = $wpdb->get_results(
2057 $wpdb->prepare(
2058 "
2059 SELECT si.section_id, s.section_course_id
2060 FROM {$wpdb->learnpress_section_items} si
2061 INNER JOIN {$wpdb->learnpress_sections} s ON s.section_id = si.section_id
2062 WHERE si.item_id = %d
2063 ",
2064 $item_id
2065 )
2066 );
2067 if ( ! $section_items ) {
2068 if ( $course_id_fallback > 0 ) {
2069 $this->clean_course_curriculum_cache( $course_id_fallback );
2070 }
2071
2072 return;
2073 }
2074
2075 foreach ( $section_items as $section_item ) {
2076 $section_id = absint( $section_item->section_id ?? 0 );
2077 $course_id = absint( $section_item->section_course_id ?? 0 );
2078 if ( ! $section_id || ! $course_id ) {
2079 continue;
2080 }
2081
2082 $course_section_item_model = CourseSectionItemModel::find( $section_id, $item_id );
2083 if ( ! $course_section_item_model ) {
2084 continue;
2085 }
2086
2087 $course_section_item_model->section_course_id = $course_id;
2088 $course_section_item_model->delete();
2089 }
2090 }
2091
2092 /**
2093 * Keep course curriculum caches in sync after direct section item removal.
2094 *
2095 * @param int $course_id
2096 *
2097 * @return void
2098 */
2099 protected function clean_course_curriculum_cache( int $course_id ) {
2100 if ( $course_id <= 0 ) {
2101 return;
2102 }
2103
2104 try {
2105 $courseModel = CourseModel::find( $course_id, true );
2106 if ( ! $courseModel ) {
2107 return;
2108 }
2109
2110 $courseModel->sections_items = null;
2111 $courseModel->total_items = null;
2112 $courseModel->save( true );
2113 } catch ( Throwable $e ) {
2114 error_log( __METHOD__ . ': ' . $e->getMessage() );
2115 }
2116 }
2117
2118 /**
2119 * Remove question assignment from all quizzes.
2120 *
2121 * @param int $question_id
2122 *
2123 * @return void
2124 */
2125 protected function remove_question_from_assigned_quizzes( int $question_id ) {
2126 if ( $question_id <= 0 ) {
2127 return;
2128 }
2129
2130 global $wpdb;
2131 $wpdb->delete(
2132 $wpdb->prefix . 'learnpress_quiz_questions',
2133 array( 'question_id' => $question_id ),
2134 array( '%d' )
2135 );
2136 }
2137
2138 protected function get_course_by_item_id( $item_id ) {
2139 static $cache = [];
2140
2141 global $wpdb;
2142
2143 if ( empty( $item_id ) ) {
2144 return false;
2145 }
2146
2147 $item_id = absint( $item_id );
2148 if ( isset( $cache[ $item_id ] ) ) {
2149 return $cache[ $item_id ] ? $cache[ $item_id ] : false;
2150 }
2151
2152 $course_id = $wpdb->get_var(
2153 $wpdb->prepare(
2154 "SELECT c.ID FROM {$wpdb->posts} c
2155 INNER JOIN {$wpdb->learnpress_sections} s ON c.ID = s.section_course_id
2156 INNER JOIN {$wpdb->learnpress_section_items} si ON si.section_id = s.section_id
2157 WHERE si.item_id = %d ORDER BY si.section_id DESC LIMIT 1
2158 ",
2159 $item_id
2160 )
2161 );
2162 $cache[ $item_id ] = absint( $course_id );
2163
2164 if ( $cache[ $item_id ] ) {
2165 return $cache[ $item_id ];
2166 }
2167
2168 return false;
2169 }
2170
2171 /**
2172 * Save global settings for Course Builder.
2173 *
2174 * @since 4.3.6
2175 * @version 1.0.0
2176 */
2177 public function save_global_settings() {
2178 $response = new LP_REST_Response();
2179 $response->data = new stdClass();
2180
2181 try {
2182 $params = wp_unslash( $_REQUEST['data'] ?? '' );
2183 if ( empty( $params ) ) {
2184 throw new Exception( 'Error: params invalid!' );
2185 }
2186
2187 $data = LP_Helper::json_decode( $params, true );
2188
2189 if ( ! current_user_can( ADMIN_ROLE ) ) {
2190 throw new Exception( __( 'Permission denied', 'learnpress' ) );
2191 }
2192
2193 $hide_instructor_access_admin_screen = ! empty( $data['hide_instructor_access_admin_screen'] ) && $data['hide_instructor_access_admin_screen'] === 'yes' ? 'yes' : 'no';
2194 $logo_remove = ! empty( $data['course_builder_logo_remove'] ) && $data['course_builder_logo_remove'] === 'yes';
2195 $logo_id = absint( $data['course_builder_logo_id'] ?? 0 );
2196
2197 if ( $logo_remove ) {
2198 $logo_id = 0;
2199 }
2200
2201 LP_Settings::update_option( 'hide_instructor_access_admin_screen', $hide_instructor_access_admin_screen );
2202 LP_Settings::update_option( 'course_builder_logo_id', $logo_id );
2203
2204 $logo_url = '';
2205 if ( $logo_id ) {
2206 $logo_url = wp_get_attachment_image_url( $logo_id, 'full' );
2207 }
2208
2209 $response->status = 'success';
2210 $response->message = __( 'Course Builder settings updated.', 'learnpress' );
2211 $response->data->hide_instructor_access_admin_screen = $hide_instructor_access_admin_screen;
2212 $response->data->course_builder_logo_id = $logo_id;
2213 $response->data->course_builder_logo_url = $logo_url;
2214
2215 wp_send_json( $response );
2216 } catch ( Throwable $th ) {
2217 $response->status = 'error';
2218 $response->message = $th->getMessage();
2219 wp_send_json( $response );
2220 }
2221 }
2222 }
2223