PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.2
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.2
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.2, at inc/Ajax/CourseBuilder/CourseBuilderAjax.php

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