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