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

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

2,250 lines 73.9 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 $has_title = array_key_exists( 'lesson_title', $data );
1115 $has_content = array_key_exists( 'lesson_description', $data );
1116 $title = LP_Helper::sanitize_params_submitted( $data['lesson_title'] ?? '' );
1117 $description = LP_Helper::sanitize_params_submitted(
1118 $data['lesson_description'] ?? '',
1119 'html'
1120 );
1121 $settings = $data['lesson_settings'] ?? false;
1122 $is_elementor = $data['is_elementor'] ?? false;
1123 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1124 $insert = $data['insert'];
1125 $lesson_slug = LP_Helper::sanitize_params_submitted(
1126 $data['lesson_permalink'] ?? '',
1127 'key'
1128 );
1129 $course_id = absint( $data['course_id'] ?? 0 );
1130
1131 // Determine target status (draft or publish)
1132 $target_status = LP_Helper::sanitize_params_submitted(
1133 $data['lesson_status'] ?? '',
1134 'key'
1135 );
1136 $restore_with_custom_slug = false;
1137
1138 if ( ( $insert || $has_title ) && empty( $title ) ) {
1139 throw new Exception( __( 'Lesson title is required', 'learnpress' ) );
1140 }
1141
1142 if ( $insert ) {
1143 $insert_arg = array(
1144 'post_title' => $title,
1145 'post_content' => $description,
1146 'post_status' => $target_status,
1147 'post_author' => get_current_user_id(),
1148 );
1149
1150 $lessonPostModelNew = new LessonPostModel( $insert_arg );
1151 $lessonPostModelNew->check_capabilities_create_item_course();
1152
1153 $lessonPostModelNew->save();
1154 $lesson_id = $lessonPostModelNew->ID;
1155
1156 $lesson_model = $lessonPostModelNew;
1157 $response->message = __( 'Create lesson successfully', 'learnpress' );
1158 } else {
1159 $lesson_model = $data['lesson_model'] ?? null;
1160 if ( ! $lesson_model instanceof LessonPostModel ) {
1161 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1162 }
1163
1164 $lesson_model->check_capabilities_update_item_course();
1165
1166 if ( ! $course_id ) {
1167 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1168 }
1169
1170 // Support for co-instructor.
1171 /*$co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
1172 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
1173
1174 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
1175 throw new Exception( __( 'You are not allowed to update this lesson', 'learnpress' ) );
1176 }*/
1177
1178 /*$update_arg = array(
1179 'ID' => $lesson_id,
1180 'post_type' => LP_LESSON_CPT,
1181 'post_status' => $target_status,
1182 );*/
1183
1184 if ( defined( 'ELEMENTOR_VERSION' ) && array_key_exists( 'is_elementor', $data ) ) {
1185 \Elementor\Plugin::$instance->documents->get( $lesson_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1186 }
1187
1188 $must_save_lesson = false;
1189 if ( $has_title ) {
1190 $lesson_model->post_title = $title;
1191 $must_save_lesson = true;
1192 }
1193
1194 if ( $has_content ) {
1195 $lesson_model->post_content = $description;
1196 $must_save_lesson = true;
1197 }
1198
1199 if ( ! empty( $target_status ) ) {
1200 $lesson_model->post_status = $target_status;
1201 $must_save_lesson = true;
1202 }
1203
1204 if ( ! empty( $lesson_slug ) ) {
1205 $lesson_model->post_name = $lesson_slug;
1206 $must_save_lesson = true;
1207 }
1208
1209 //$restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $target_status, $lesson_slug );
1210
1211 //$update = wp_update_post( $update_arg );
1212 if ( $must_save_lesson ) {
1213 $lesson_model->save();
1214 }
1215
1216 /*if ( $restore_with_custom_slug ) {
1217 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1218 }*/
1219
1220 if ( $course_id ) {
1221 $courseModelCache = CourseModel::find( $course_id, true );
1222 if ( $courseModelCache ) {
1223 $courseModelCache->sections_items = null;
1224 $courseModelCache->save();
1225 }
1226 }
1227
1228 $response->message = __( 'Update lesson successfully', 'learnpress' );
1229 }
1230
1231 if ( $settings ) {
1232 $this->save_lesson_settings_to_model( $lesson_model, $data );
1233 }
1234
1235 // Remove lesson from curriculum if status is not public
1236 if ( ! empty( $target_status ) && $target_status !== 'publish' ) {
1237 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1238 }
1239
1240 $response->status = 'success';
1241 $response->data->status = $lesson_model->post_status;
1242 $response->data->button_title = $lesson_model->post_status === 'publish' ?
1243 __( 'Update', 'learnpress' ) :
1244 __( 'Publish', 'learnpress' );
1245 $response->data->lesson_id_new = $insert ? $lesson_id : '';
1246 $response->message = $target_status === 'draft'
1247 ? esc_html__( 'Lesson saved as draft', 'learnpress' )
1248 : ( $insert ? esc_html__( 'Insert lesson successfully', 'learnpress' ) : esc_html__( 'Update lesson successfully', 'learnpress' ) );
1249
1250 $response->data->lesson_slug = $lesson_model->post_name;
1251
1252 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1253 $response->data->permalink_available = false;
1254 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1255 if ( 'publish' === $lesson_model->post_status && $course_id_of_item ) {
1256 $course = learn_press_get_course( $course_id_of_item );
1257 if ( $course ) {
1258 $response->data->permalink_available = true;
1259 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1260 }
1261 }
1262
1263 $lesson_model_for_html = LessonPostModel::find( $lesson_id, false );
1264 if ( $return_html ) {
1265 $response->data->list_item_html = $lesson_model_for_html
1266 ? BuilderListLessonsTemplate::render_lesson( $lesson_model_for_html )
1267 : '';
1268
1269 if ( $course_id ) {
1270 $courseModelForHtml = CourseModel::find( $course_id, true );
1271 if ( $courseModelForHtml && $lesson_model_for_html ) {
1272 $item = new stdClass();
1273 $item->item_id = $lesson_id;
1274 $item->title = $lesson_model_for_html->post_title;
1275 $item->item_type = LP_LESSON_CPT;
1276 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1277 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1278 }
1279 }
1280 }
1281
1282 $response->status = Response::STATUS_SUCCESS;
1283 } catch ( Throwable $e ) {
1284 $response->message = $e->getMessage();
1285 }
1286
1287 wp_send_json( $response );
1288 }
1289
1290 public function move_trash_lesson() {
1291 $response = new LP_REST_Response();
1292 $response->data = new stdClass();
1293
1294 try {
1295 $data = self::check_valid_lesson();
1296 $lesson_id = $data['lesson_id'] ?? 0;
1297 $status = $data['status'] ?? 'trash';
1298 $lesson_model = LessonPostModel::find( $lesson_id, false );
1299 $lesson_slug = ! empty( $data['lesson_permalink'] )
1300 ? sanitize_title( wp_unslash( (string) $data['lesson_permalink'] ) )
1301 : '';
1302
1303 if ( ! $lesson_model ) {
1304 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1305 }
1306
1307 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1308 throw new Exception( __( 'You are not allowed to delete this lesson', 'learnpress' ) );
1309 }
1310 $course_id = absint( $data['course_id'] ?? 0 );
1311 if ( ! $course_id ) {
1312 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1313 }
1314
1315 if ( $status === 'trash' ) {
1316 $original_slug = (string) get_post_field( 'post_name', $lesson_id );
1317 $move_trash = wp_trash_post( $lesson_id );
1318
1319 if ( is_wp_error( $move_trash ) ) {
1320 throw new Exception( esc_html__( 'Cannot move this lesson to trash', 'learnpress' ) );
1321 }
1322
1323 if ( '' !== $original_slug ) {
1324 wp_update_post(
1325 [
1326 'ID' => $lesson_id,
1327 'post_name' => $original_slug,
1328 ]
1329 );
1330 }
1331
1332 $message = esc_html__( 'This lesson has been moved to trash.', 'learnpress' );
1333 } elseif ( $status === 'delete' ) {
1334
1335 if ( $lesson_model->post_status !== 'trash' ) {
1336 throw new Exception( esc_html__( 'Lesson must be trashed before deleting.', 'learnpress' ) );
1337 }
1338
1339 $delete = wp_delete_post( $lesson_id );
1340
1341 if ( is_wp_error( $delete ) ) {
1342 throw new Exception( esc_html__( 'Cannot delete this lesson.', 'learnpress' ) );
1343 }
1344 $message = esc_html__( 'Delete this lesson successfully', 'learnpress' );
1345 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1346 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $status, $lesson_slug );
1347 $update_args = [
1348 'ID' => $lesson_id,
1349 'post_type' => LP_LESSON_CPT,
1350 'post_status' => $status,
1351 ];
1352
1353 if ( '' !== $lesson_slug ) {
1354 $update_args['post_name'] = $lesson_slug;
1355 }
1356
1357 $update = wp_update_post( $update_args );
1358 if ( ! $update ) {
1359 if ( 'draft' === $status ) {
1360 throw new Exception( __( 'Lesson cannot be moved to draft', 'learnpress' ) );
1361 }
1362
1363 throw new Exception( __( 'Lesson cannot be moved to publish', 'learnpress' ) );
1364 }
1365
1366 if ( $restore_with_custom_slug ) {
1367 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1368 }
1369
1370 $message = 'draft' === $status
1371 ? __( 'Lesson has been moved to draft', 'learnpress' )
1372 : __( 'Lesson has been moved to publish', 'learnpress' );
1373 } else {
1374 throw new Exception( __( 'Invalid lesson status transition', 'learnpress' ) );
1375 }
1376
1377 $saved_lesson_status = get_post_status( $lesson_id );
1378 if ( ! is_string( $saved_lesson_status ) || '' === $saved_lesson_status ) {
1379 $saved_lesson_status = $status;
1380 }
1381
1382 if ( $saved_lesson_status !== 'publish' ) {
1383 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1384 }
1385
1386 $response->data->status = $saved_lesson_status;
1387 $response->data->button_title = 'publish' === $saved_lesson_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1388 $response->data->lesson_slug = (string) get_post_field( 'post_name', $lesson_id );
1389 $response->data->permalink_available = false;
1390 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1391
1392 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1393 if ( 'publish' === $saved_lesson_status && $course_id_of_item ) {
1394 $course = learn_press_get_course( $course_id_of_item );
1395 if ( $course ) {
1396 $response->data->permalink_available = true;
1397 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1398 }
1399 }
1400
1401 if ( 'delete' !== $status ) {
1402 $lesson_model_new = LessonPostModel::find( $lesson_id, false );
1403 $response->data->html = $lesson_model_new ? BuilderListLessonsTemplate::render_lesson( $lesson_model_new ) : '';
1404 }
1405
1406 $response->status = 'success';
1407 $response->message = $message;
1408 wp_send_json( $response );
1409 } catch ( Throwable $th ) {
1410 $response->status = 'error';
1411 $response->message = $th->getMessage();
1412 wp_send_json( $response );
1413 }
1414 }
1415
1416 /**
1417 * Save Lesson Settings
1418 * @throws Exception
1419 */
1420 protected function save_lesson_settings_to_model( LessonPostModel $lessonModel, array $data ) {
1421 $must_save = 0;
1422
1423 if ( isset( $data['_lp_duration'] ) ) {
1424 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1425 $explode = explode( ' ', $duration );
1426 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1427 $unit = $explode[1] ?? 'minute';
1428
1429 $lessonModel->set_meta_value_by_key( $lessonModel::META_KEY_DURATION, $number . ' ' . $unit );
1430 $must_save = 1;
1431 }
1432
1433 if ( isset( $data['_lp_preview'] ) ) {
1434 $enable = $data['_lp_preview'] === 'yes';
1435 $lessonModel->set_preview( $enable );
1436 $must_save = 1;
1437 }
1438
1439 if ( $must_save ) {
1440 $lessonModel->save();
1441 }
1442
1443 /**
1444 * Allow addons to persist their own lesson settings fields added to the
1445 * settings metabox tabs when saving from Course Builder.
1446 *
1447 * @param LessonPostModel $lessonModel Lesson model being saved.
1448 * @param array $data Submitted settings data.
1449 *
1450 * @since 4.3.8
1451 */
1452 do_action( 'learn-press/course-builder/save-lesson-settings', $lessonModel, $data );
1453 }
1454
1455 /**
1456 * Save Quiz Settings to QuizPostModel
1457 *
1458 * @param QuizPostModel $quizModel
1459 * @param array $data
1460 *
1461 * @since 4.3
1462 * @version 1.0.0
1463 */
1464 protected function save_quiz_settings_to_model( QuizPostModel $quizModel, array $data ) {
1465 if ( isset( $data['_lp_duration'] ) ) {
1466 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1467 $explode = explode( ' ', $duration );
1468 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1469 $unit = $explode[1] ?? 'minute';
1470
1471 $quizModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1472 }
1473
1474 $checkbox_keys = [
1475 '_lp_instant_check',
1476 '_lp_negative_marking',
1477 '_lp_minus_skip_questions',
1478 '_lp_review',
1479 '_lp_show_correct_review',
1480 '_lp_show_check_answer',
1481 '_lp_show_hint',
1482 ];
1483
1484 foreach ( $checkbox_keys as $key ) {
1485 if ( isset( $data[ $key ] ) ) {
1486 $value = $data[ $key ] === 'yes' ? 'yes' : 'no';
1487 $quizModel->save_meta_value_by_key( $key, $value );
1488 }
1489 }
1490
1491 $numeric_keys = [
1492 '_lp_passing_grade',
1493 '_lp_retake_count',
1494 '_lp_pagination',
1495 ];
1496
1497 foreach ( $numeric_keys as $key ) {
1498 if ( isset( $data[ $key ] ) ) {
1499 $value = absint( $data[ $key ] );
1500 if ( '_lp_passing_grade' === $key && $value > 100 ) {
1501 $value = 100;
1502 }
1503 $quizModel->save_meta_value_by_key( $key, $value );
1504 }
1505 }
1506
1507 /**
1508 * Allow addons to persist their own quiz settings fields added to the
1509 * settings metabox tabs when saving from Course Builder.
1510 *
1511 * @param QuizPostModel $quizModel Quiz model being saved.
1512 * @param array $data Submitted settings data.
1513 *
1514 * @since 4.3.8
1515 */
1516 do_action( 'learn-press/course-builder/save-quiz-settings', $quizModel, $data );
1517 }
1518
1519 /**
1520 * Update Quiz from Course Builder.
1521 * Supports partial updates (title only, description only, or settings only).
1522 *
1523 * @since 4.3
1524 * @version 1.0.2
1525 */
1526 public function builder_update_quiz() {
1527 $response = new Response();
1528 $response->data = new stdClass();
1529
1530 try {
1531 $data = self::check_valid_quiz();
1532 $quiz_id = $data['quiz_id'] ?? 0;
1533 $title = LP_Helper::sanitize_params_submitted( $data['quiz_title'] ?? '' );
1534 $description = LP_Helper::sanitize_params_submitted(
1535 $data['quiz_description'] ?? '',
1536 'html'
1537 );
1538 $settings = $data['quiz_settings'] ?? false;
1539 $is_elementor = $data['is_elementor'] ?? false;
1540 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1541 $insert = $data['insert'];
1542 $quiz_slug = LP_Helper::sanitize_params_submitted(
1543 $data['quiz_permalink'] ?? '',
1544 'key'
1545 );
1546 $course_id = absint( $data['course_id'] ?? 0 );
1547
1548 // Determine target status (draft or publish)
1549 $target_status = LP_Helper::sanitize_params_submitted(
1550 $data['quiz_status'] ?? '',
1551 'key'
1552 );
1553
1554 if ( empty( $title ) ) {
1555 throw new Exception( __( 'Quiz title is required', 'learnpress' ) );
1556 }
1557
1558 if ( $insert ) {
1559 $insert_arg = array(
1560 'post_title' => $title,
1561 'post_content' => $description,
1562 'post_status' => $target_status,
1563 'post_author' => get_current_user_id(),
1564 );
1565
1566 $quizPostModelNew = new QuizPostModel( $insert_arg );
1567 $quizPostModelNew->check_capabilities_create_item_course();
1568
1569 $quizPostModelNew->save();
1570 $quiz_id = $quizPostModelNew->ID;
1571
1572 $quiz_model = $quizPostModelNew;
1573 $response->message = __( 'Create quiz successfully', 'learnpress' );
1574 } else {
1575 $quiz_model = $data['quiz_model'] ?? null;
1576 if ( ! $quiz_model instanceof QuizPostModel ) {
1577 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1578 }
1579
1580 $quiz_model->check_capabilities_update_item_course();
1581
1582 if ( ! $course_id ) {
1583 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1584 }
1585
1586 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1587 \Elementor\Plugin::$instance->documents->get( $quiz_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1588 }
1589
1590 $quiz_model->post_title = $title;
1591 $quiz_model->post_content = $description;
1592 $quiz_model->post_status = $target_status;
1593 if ( ! empty( $quiz_slug ) ) {
1594 $quiz_model->post_name = $quiz_slug;
1595 }
1596
1597 $quiz_model->save();
1598
1599 if ( $course_id ) {
1600 $courseModelCache = CourseModel::find( $course_id, true );
1601 if ( $courseModelCache ) {
1602 $courseModelCache->sections_items = null;
1603 $courseModelCache->save();
1604 }
1605 }
1606
1607 $response->message = __( 'Update quiz successfully', 'learnpress' );
1608 }
1609
1610 if ( $settings ) {
1611 $this->save_quiz_settings_to_model( $quiz_model, $data );
1612 }
1613
1614 // Remove quiz from curriculum if status is not public
1615 if ( $target_status !== 'publish' ) {
1616 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1617 }
1618
1619 $response->status = 'success';
1620 $response->data->status = $quiz_model->post_status;
1621 $response->data->button_title = $quiz_model->post_status === 'publish' ?
1622 __( 'Update', 'learnpress' ) :
1623 __( 'Publish', 'learnpress' );
1624 $response->data->quiz_id_new = $insert ? $quiz_id : '';
1625 if ( $insert && $quiz_id ) {
1626 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$quiz_id}" );
1627 }
1628 $response->message = $target_status === 'draft'
1629 ? esc_html__( 'Quiz saved as draft', 'learnpress' )
1630 : ( $insert ? esc_html__( 'Insert quiz successfully', 'learnpress' ) : esc_html__( 'Update quiz successfully', 'learnpress' ) );
1631
1632 $response->data->quiz_slug = $quiz_model->post_name;
1633
1634 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1635 $response->data->permalink_available = false;
1636 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1637 if ( 'publish' === $quiz_model->post_status && $course_id_of_item ) {
1638 $course = learn_press_get_course( $course_id_of_item );
1639 if ( $course ) {
1640 $response->data->permalink_available = true;
1641 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1642 }
1643 }
1644
1645 $quiz_model_for_html = QuizPostModel::find( $quiz_id, true );
1646 if ( $return_html ) {
1647 $response->data->list_item_html = $quiz_model_for_html
1648 ? BuilderListQuizzesTemplate::render_quiz( $quiz_model_for_html )
1649 : '';
1650
1651 if ( $course_id ) {
1652 $courseModelForHtml = CourseModel::find( $course_id, true );
1653 if ( $courseModelForHtml && $quiz_model_for_html ) {
1654 $item = new stdClass();
1655 $item->item_id = $quiz_id;
1656 $item->title = $quiz_model_for_html->post_title;
1657 $item->item_type = LP_QUIZ_CPT;
1658 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1659 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1660 }
1661 }
1662 }
1663
1664 $response->status = Response::STATUS_SUCCESS;
1665 } catch ( Throwable $e ) {
1666 $response->message = $e->getMessage();
1667 }
1668
1669 wp_send_json( $response );
1670 }
1671
1672 public function duplicate_quiz() {
1673 $response = new LP_REST_Response();
1674 $response->data = new stdClass();
1675
1676 try {
1677 $data = self::check_valid_quiz();
1678 $quiz_id = $data['quiz_id'] ?? 0;
1679 $quiz_model = $data['quiz_model'];
1680
1681 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1682 throw new Exception( __( 'You are not allowed to duplicate this quiz', 'learnpress' ) );
1683 }
1684
1685 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1686 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1687 }
1688
1689 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1690 $curd = new LP_Quiz_CURD();
1691 $new_item_id = $curd->duplicate( $quiz_id, $duplicate_args );
1692
1693 if ( is_wp_error( $new_item_id ) ) {
1694 throw new Exception( $new_item_id->get_error_message() );
1695 }
1696 $response->status = 'success';
1697 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$new_item_id}" );
1698 $response->message = __( 'Quiz duplicated successfully', 'learnpress' );
1699 wp_send_json( $response );
1700 } catch ( Throwable $th ) {
1701 $response->status = 'error';
1702 $response->message = $th->getMessage();
1703 wp_send_json( $response );
1704 }
1705 }
1706
1707 public function move_trash_quiz() {
1708 $response = new LP_REST_Response();
1709 $response->data = new stdClass();
1710
1711 try {
1712 $data = self::check_valid_quiz();
1713 $quiz_id = $data['quiz_id'] ?? 0;
1714 $status = $data['status'] ?? 'trash';
1715 $quiz_model = QuizPostModel::find( $quiz_id, false );
1716 $quiz_slug = ! empty( $data['quiz_permalink'] )
1717 ? sanitize_title( wp_unslash( (string) $data['quiz_permalink'] ) )
1718 : '';
1719
1720 if ( ! $quiz_model ) {
1721 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1722 }
1723
1724 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1725 throw new Exception( __( 'You are not allowed to delete this quiz', 'learnpress' ) );
1726 }
1727 $course_id = absint( $data['course_id'] ?? 0 );
1728 if ( ! $course_id ) {
1729 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1730 }
1731
1732 if ( $status === 'trash' ) {
1733 $original_slug = (string) get_post_field( 'post_name', $quiz_id );
1734 $move_trash = wp_trash_post( $quiz_id );
1735
1736 if ( is_wp_error( $move_trash ) ) {
1737 throw new Exception( esc_html__( 'Cannot move this quiz to trash', 'learnpress' ) );
1738 }
1739
1740 if ( '' !== $original_slug ) {
1741 wp_update_post(
1742 [
1743 'ID' => $quiz_id,
1744 'post_name' => $original_slug,
1745 ]
1746 );
1747 }
1748
1749 $message = esc_html__( 'This quiz has been moved to trash.', 'learnpress' );
1750 } elseif ( $status === 'delete' ) {
1751
1752 if ( $quiz_model->post_status !== 'trash' ) {
1753 throw new Exception( esc_html__( 'Quiz must be trashed before deleting.', 'learnpress' ) );
1754 }
1755
1756 $delete = wp_delete_post( $quiz_id );
1757
1758 if ( is_wp_error( $delete ) ) {
1759 throw new Exception( esc_html__( 'Cannot delete this quiz.', 'learnpress' ) );
1760 }
1761 $message = esc_html__( 'Delete this quiz successfully', 'learnpress' );
1762 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1763 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $quiz_id, $status, $quiz_slug );
1764 $update_args = [
1765 'ID' => $quiz_id,
1766 'post_type' => LP_QUIZ_CPT,
1767 'post_status' => $status,
1768 ];
1769
1770 if ( '' !== $quiz_slug ) {
1771 $update_args['post_name'] = $quiz_slug;
1772 }
1773
1774 $update = wp_update_post( $update_args );
1775 if ( ! $update ) {
1776 if ( 'draft' === $status ) {
1777 throw new Exception( __( 'Quiz cannot be moved to draft', 'learnpress' ) );
1778 }
1779
1780 throw new Exception( __( 'Quiz cannot be moved to publish', 'learnpress' ) );
1781 }
1782
1783 if ( $restore_with_custom_slug ) {
1784 $this->sync_slug_after_restore( $quiz_id, $quiz_slug );
1785 }
1786
1787 $message = 'draft' === $status
1788 ? __( 'Quiz has been moved to draft', 'learnpress' )
1789 : __( 'Quiz has been moved to publish', 'learnpress' );
1790 } else {
1791 throw new Exception( __( 'Invalid quiz status transition', 'learnpress' ) );
1792 }
1793
1794 $saved_quiz_status = get_post_status( $quiz_id );
1795 if ( ! is_string( $saved_quiz_status ) || '' === $saved_quiz_status ) {
1796 $saved_quiz_status = $status;
1797 }
1798
1799 if ( $saved_quiz_status !== 'publish' ) {
1800 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1801 }
1802
1803 $response->data->status = $saved_quiz_status;
1804 $response->data->button_title = 'publish' === $saved_quiz_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1805 $response->data->quiz_slug = (string) get_post_field( 'post_name', $quiz_id );
1806 $response->data->permalink_available = false;
1807 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1808
1809 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1810 if ( 'publish' === $saved_quiz_status && $course_id_of_item ) {
1811 $course = learn_press_get_course( $course_id_of_item );
1812 if ( $course ) {
1813 $response->data->permalink_available = true;
1814 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1815 }
1816 }
1817
1818 if ( 'delete' !== $status ) {
1819 $fresh_quiz_model = QuizPostModel::find( $quiz_id, false );
1820 $response->data->html = $fresh_quiz_model
1821 ? BuilderListQuizzesTemplate::render_quiz( $fresh_quiz_model )
1822 : '';
1823 }
1824
1825 $response->status = 'success';
1826 $response->message = $message;
1827 wp_send_json( $response );
1828 } catch ( Throwable $th ) {
1829 $response->status = 'error';
1830 $response->message = $th->getMessage();
1831 wp_send_json( $response );
1832 }
1833 }
1834
1835 public function duplicate_question() {
1836 $response = new LP_REST_Response();
1837 $response->data = new stdClass();
1838
1839 try {
1840 $data = self::check_valid_question();
1841 $question_id = $data['question_id'] ?? 0;
1842 $question_model = $data['question_model'];
1843
1844 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1845 throw new Exception( __( 'You are not allowed to duplicate this question', 'learnpress' ) );
1846 }
1847
1848 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1849 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1850 }
1851
1852 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1853 $curd = new LP_Question_CURD();
1854 $new_item_id = $curd->duplicate( $question_id, $duplicate_args );
1855
1856 if ( is_wp_error( $new_item_id ) ) {
1857 throw new Exception( $new_item_id->get_error_message() );
1858 }
1859 $response->status = 'success';
1860 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$new_item_id}" );
1861 $response->message = __( 'Question duplicated successfully', 'learnpress' );
1862 wp_send_json( $response );
1863 } catch ( Throwable $th ) {
1864 $response->status = 'error';
1865 $response->message = $th->getMessage();
1866 wp_send_json( $response );
1867 }
1868 }
1869
1870 public function builder_update_question() {
1871 $response = new Response();
1872 $response->data = new stdClass();
1873
1874 try {
1875 $data = self::check_valid_question();
1876 $question_id = $data['question_id'] ?? 0;
1877 $title = LP_Helper::sanitize_params_submitted( $data['question_title'] ?? '' );
1878 $description = LP_Helper::sanitize_params_submitted(
1879 $data['question_description'] ?? '',
1880 'html'
1881 );
1882 $is_elementor = $data['is_elementor'] ?? false;
1883 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1884 $insert = $data['insert'];
1885 $question_slug = LP_Helper::sanitize_params_submitted(
1886 $data['question_permalink'] ?? '',
1887 'key'
1888 );
1889
1890 // Determine target status (draft or publish)
1891 $target_status = LP_Helper::sanitize_params_submitted(
1892 $data['question_status'] ?? '',
1893 'key'
1894 );
1895
1896 if ( empty( $title ) ) {
1897 throw new Exception( __( 'Question title is required', 'learnpress' ) );
1898 }
1899
1900 if ( $insert ) {
1901 $insert_arg = array(
1902 'post_title' => $title,
1903 'post_content' => $description,
1904 'post_status' => $target_status,
1905 'post_author' => get_current_user_id(),
1906 );
1907
1908 $questionPostModelNew = new QuestionPostModel( $insert_arg );
1909 $questionPostModelNew->check_capabilities_create_item_course();
1910
1911 $questionPostModelNew->save();
1912 $question_id = $questionPostModelNew->ID;
1913
1914 $question_model = $questionPostModelNew;
1915 $response->message = __( 'Create question successfully', 'learnpress' );
1916 } else {
1917 $question_model = $data['question_model'] ?? null;
1918 if ( ! $question_model instanceof QuestionPostModel ) {
1919 throw new Exception( __( 'Question not found', 'learnpress' ) );
1920 }
1921
1922 $question_model->check_capabilities_update_item_course();
1923
1924 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1925 \Elementor\Plugin::$instance->documents->get( $question_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1926 }
1927
1928 $question_model->post_title = $title;
1929 $question_model->post_content = $description;
1930 $question_model->post_status = $target_status;
1931 if ( ! empty( $question_slug ) ) {
1932 $question_model->post_name = $question_slug;
1933 }
1934
1935 $question_model->save();
1936
1937 $response->message = __( 'Update question successfully', 'learnpress' );
1938 }
1939
1940 // Remove question from quizzes if status is not public
1941 if ( $target_status !== 'publish' ) {
1942 $this->remove_question_from_assigned_quizzes( $question_id );
1943 }
1944
1945 do_action( 'learn-press/course-builder/update-question', $data, $question_model );
1946
1947 $response->status = 'success';
1948 $response->data->status = $question_model->post_status;
1949 $response->data->button_title = $question_model->post_status === 'publish' ?
1950 __( 'Update', 'learnpress' ) :
1951 __( 'Publish', 'learnpress' );
1952 $response->data->question_id_new = $insert ? $question_id : '';
1953 if ( $insert && $question_id ) {
1954 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$question_id}" );
1955 }
1956 $response->message = $target_status === 'draft'
1957 ? esc_html__( 'Question saved as draft', 'learnpress' )
1958 : ( $insert ? esc_html__( 'Insert question successfully', 'learnpress' ) : esc_html__( 'Update question successfully', 'learnpress' ) );
1959
1960 $response->data->question_slug = $question_model->post_name;
1961 $response->data->question_permalink = get_permalink( $question_id );
1962
1963 $question_model_for_html = QuestionPostModel::find( $question_id, true );
1964 if ( $return_html ) {
1965 $response->data->list_item_html = $question_model_for_html
1966 ? BuilderListQuestionsTemplate::render_question( $question_model_for_html )
1967 : '';
1968 }
1969
1970 $response->status = Response::STATUS_SUCCESS;
1971 } catch ( Throwable $e ) {
1972 $response->message = $e->getMessage();
1973 }
1974
1975 wp_send_json( $response );
1976 }
1977
1978 public function move_trash_question() {
1979 $response = new LP_REST_Response();
1980 $response->data = new stdClass();
1981
1982 try {
1983 $data = self::check_valid_question();
1984 $question_id = $data['question_id'] ?? 0;
1985 $status = $data['status'] ?? 'trash';
1986 $question_model = QuestionPostModel::find( $question_id, false );
1987
1988 if ( ! $question_model ) {
1989 throw new Exception( __( 'Question not found', 'learnpress' ) );
1990 }
1991
1992 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1993 throw new Exception( __( 'You are not allowed to delete this question', 'learnpress' ) );
1994 }
1995
1996 if ( $status === 'trash' ) {
1997 $move_trash = wp_trash_post( $question_id );
1998
1999 if ( is_wp_error( $move_trash ) ) {
2000 throw new Exception( esc_html__( 'Cannot move this question to trash', 'learnpress' ) );
2001 }
2002 $message = esc_html__( 'This question has been moved to trash.', 'learnpress' );
2003 } elseif ( $status === 'delete' ) {
2004
2005 if ( $question_model->post_status !== 'trash' ) {
2006 throw new Exception( esc_html__( 'Question must be trashed before deleting.', 'learnpress' ) );
2007 }
2008
2009 $delete = wp_delete_post( $question_id );
2010
2011 if ( is_wp_error( $delete ) ) {
2012 throw new Exception( esc_html__( 'Cannot delete this question.', 'learnpress' ) );
2013 }
2014 $message = esc_html__( 'Delete this question successfully', 'learnpress' );
2015
2016 } elseif ( $status === 'publish' ) {
2017 $update = wp_update_post(
2018 array(
2019 'ID' => $question_id,
2020 'post_type' => LP_QUESTION_CPT,
2021 'post_status' => 'publish',
2022 )
2023 );
2024 if ( ! $update ) {
2025 throw new Exception( __( 'Question cannot be moved to publish', 'learnpress' ) );
2026 }
2027
2028 $message = __( 'Question has been moved to publish', 'learnpress' );
2029 } elseif ( $status === 'draft' ) {
2030 $update = wp_update_post(
2031 array(
2032 'ID' => $question_id,
2033 'post_type' => LP_QUESTION_CPT,
2034 'post_status' => 'draft',
2035 )
2036 );
2037 if ( ! $update ) {
2038 throw new Exception( __( 'Question cannot be restored to draft', 'learnpress' ) );
2039 }
2040
2041 $message = __( 'Question has been restored to draft', 'learnpress' );
2042 }
2043
2044 if ( $status !== 'publish' ) {
2045 $this->remove_question_from_assigned_quizzes( $question_id );
2046 }
2047
2048 $response->data->status = $status;
2049 $response->data->button_title = __( 'Publish', 'learnpress' );
2050
2051 if ( 'delete' !== $status ) {
2052 $fresh_question_model = QuestionPostModel::find( $question_id, false );
2053 $response->data->html = $fresh_question_model
2054 ? BuilderListQuestionsTemplate::render_question( $fresh_question_model )
2055 : '';
2056 }
2057
2058 $response->status = 'success';
2059 $response->message = $message;
2060 wp_send_json( $response );
2061 } catch ( Throwable $th ) {
2062 $response->status = 'error';
2063 $response->message = $th->getMessage();
2064 wp_send_json( $response );
2065 }
2066 }
2067
2068 /**
2069 * Remove lesson/quiz assignment from curriculum sections.
2070 *
2071 * @param int $item_id
2072 * @param int $course_id_fallback
2073 *
2074 * @return void
2075 */
2076 protected function remove_course_item_from_curriculum( int $item_id, int $course_id_fallback = 0 ) {
2077 if ( $item_id <= 0 ) {
2078 return;
2079 }
2080
2081 global $wpdb;
2082
2083 $section_items = $wpdb->get_results(
2084 $wpdb->prepare(
2085 "
2086 SELECT si.section_id, s.section_course_id
2087 FROM {$wpdb->learnpress_section_items} si
2088 INNER JOIN {$wpdb->learnpress_sections} s ON s.section_id = si.section_id
2089 WHERE si.item_id = %d
2090 ",
2091 $item_id
2092 )
2093 );
2094 if ( ! $section_items ) {
2095 if ( $course_id_fallback > 0 ) {
2096 $this->clean_course_curriculum_cache( $course_id_fallback );
2097 }
2098
2099 return;
2100 }
2101
2102 foreach ( $section_items as $section_item ) {
2103 $section_id = absint( $section_item->section_id ?? 0 );
2104 $course_id = absint( $section_item->section_course_id ?? 0 );
2105 if ( ! $section_id || ! $course_id ) {
2106 continue;
2107 }
2108
2109 $course_section_item_model = CourseSectionItemModel::find( $section_id, $item_id );
2110 if ( ! $course_section_item_model ) {
2111 continue;
2112 }
2113
2114 $course_section_item_model->section_course_id = $course_id;
2115 $course_section_item_model->delete();
2116 }
2117 }
2118
2119 /**
2120 * Keep course curriculum caches in sync after direct section item removal.
2121 *
2122 * @param int $course_id
2123 *
2124 * @return void
2125 */
2126 protected function clean_course_curriculum_cache( int $course_id ) {
2127 if ( $course_id <= 0 ) {
2128 return;
2129 }
2130
2131 try {
2132 $courseModel = CourseModel::find( $course_id, true );
2133 if ( ! $courseModel ) {
2134 return;
2135 }
2136
2137 $courseModel->sections_items = null;
2138 $courseModel->total_items = null;
2139 $courseModel->save( true );
2140 } catch ( Throwable $e ) {
2141 error_log( __METHOD__ . ': ' . $e->getMessage() );
2142 }
2143 }
2144
2145 /**
2146 * Remove question assignment from all quizzes.
2147 *
2148 * @param int $question_id
2149 *
2150 * @return void
2151 */
2152 protected function remove_question_from_assigned_quizzes( int $question_id ) {
2153 if ( $question_id <= 0 ) {
2154 return;
2155 }
2156
2157 global $wpdb;
2158 $wpdb->delete(
2159 $wpdb->prefix . 'learnpress_quiz_questions',
2160 array( 'question_id' => $question_id ),
2161 array( '%d' )
2162 );
2163 }
2164
2165 protected function get_course_by_item_id( $item_id ) {
2166 static $cache = [];
2167
2168 global $wpdb;
2169
2170 if ( empty( $item_id ) ) {
2171 return false;
2172 }
2173
2174 $item_id = absint( $item_id );
2175 if ( isset( $cache[ $item_id ] ) ) {
2176 return $cache[ $item_id ] ? $cache[ $item_id ] : false;
2177 }
2178
2179 $course_id = $wpdb->get_var(
2180 $wpdb->prepare(
2181 "SELECT c.ID FROM {$wpdb->posts} c
2182 INNER JOIN {$wpdb->learnpress_sections} s ON c.ID = s.section_course_id
2183 INNER JOIN {$wpdb->learnpress_section_items} si ON si.section_id = s.section_id
2184 WHERE si.item_id = %d ORDER BY si.section_id DESC LIMIT 1
2185 ",
2186 $item_id
2187 )
2188 );
2189 $cache[ $item_id ] = absint( $course_id );
2190
2191 if ( $cache[ $item_id ] ) {
2192 return $cache[ $item_id ];
2193 }
2194
2195 return false;
2196 }
2197
2198 /**
2199 * Save global settings for Course Builder.
2200 *
2201 * @since 4.3.6
2202 * @version 1.0.0
2203 */
2204 public function save_global_settings() {
2205 $response = new LP_REST_Response();
2206 $response->data = new stdClass();
2207
2208 try {
2209 $params = wp_unslash( $_REQUEST['data'] ?? '' );
2210 if ( empty( $params ) ) {
2211 throw new Exception( 'Error: params invalid!' );
2212 }
2213
2214 $data = LP_Helper::json_decode( $params, true );
2215
2216 if ( ! current_user_can( ADMIN_ROLE ) ) {
2217 throw new Exception( __( 'Permission denied', 'learnpress' ) );
2218 }
2219
2220 $hide_instructor_access_admin_screen = ! empty( $data['hide_instructor_access_admin_screen'] ) && $data['hide_instructor_access_admin_screen'] === 'yes' ? 'yes' : 'no';
2221 $logo_remove = ! empty( $data['course_builder_logo_remove'] ) && $data['course_builder_logo_remove'] === 'yes';
2222 $logo_id = absint( $data['course_builder_logo_id'] ?? 0 );
2223
2224 if ( $logo_remove ) {
2225 $logo_id = 0;
2226 }
2227
2228 LP_Settings::update_option( 'hide_instructor_access_admin_screen', $hide_instructor_access_admin_screen );
2229 LP_Settings::update_option( 'course_builder_logo_id', $logo_id );
2230
2231 $logo_url = '';
2232 if ( $logo_id ) {
2233 $logo_url = wp_get_attachment_image_url( $logo_id, 'full' );
2234 }
2235
2236 $response->status = 'success';
2237 $response->message = __( 'Course Builder settings updated.', 'learnpress' );
2238 $response->data->hide_instructor_access_admin_screen = $hide_instructor_access_admin_screen;
2239 $response->data->course_builder_logo_id = $logo_id;
2240 $response->data->course_builder_logo_url = $logo_url;
2241
2242 wp_send_json( $response );
2243 } catch ( Throwable $th ) {
2244 $response->status = 'error';
2245 $response->message = $th->getMessage();
2246 wp_send_json( $response );
2247 }
2248 }
2249 }
2250