LLMS_Admin_Builder
LLMS_Admin_Builder class
Contents
Source Source
File: includes/admin/class.llms.admin.builder.php
class LLMS_Admin_Builder { /** * Search term string used by `get_existing_posts_where()` when querying for existing posts to clone/add to a course. * * @var string */ private static $search_term = ''; /** * Add menu items to the WP Admin Bar to allow quiz returns to the dashboard from the course builder * * @since 3.16.7 * @since 3.24.0 Unknown. * * @param WP_Admin_Bar $wp_admin_bar Instance of WP_Admin_Bar * @return void */ public static function admin_bar_menu( $wp_admin_bar ) { // Partially lifted from `wp_admin_bar_site_menu()` in wp-includes/admin-bar.php. if ( current_user_can( 'read' ) ) { $wp_admin_bar->add_menu( array( 'parent' => 'site-name', 'id' => 'dashboard', 'title' => __( 'Dashboard', 'lifterlms' ), 'href' => admin_url(), ) ); $wp_admin_bar->add_menu( array( 'parent' => 'site-name', 'id' => 'llms-courses', 'title' => __( 'Courses', 'lifterlms' ), 'href' => admin_url( 'edit.php?post_type=course' ), ) ); wp_admin_bar_appearance_menu( $wp_admin_bar ); } } /** * Retrieve the current user's builder autosave preferences * * Defaults to enabled for users who have never configured a setting value. * * @since 4.14.0 * * @return string Either "yes" or "no". */ protected static function get_autosave_status() { $autosave = get_user_option( 'llms_builder_autosave' ); $autosave = empty( $autosave ) ? 'yes' : $autosave; /** * Gets the status of autosave for the builder * * This can be configured on a per-user basis in the user's profile screen on the WP Admin Panel. * * @since 4.14.0 * * @param string $autosave Status of autosave for the current user. Either "yes" or "no". */ return apply_filters( 'llms_builder_autosave_enabled', $autosave ); } /** * Retrieve custom field schemas * * @since 3.17.0 * @since 3.17.6 Add backwards compatibility for the deprecated `llms_get_quiz_theme_settings` filter. * @since 3.38.0 Only run backwards compatibility for `llms_get_quiz_theme_settings` when the filter is being used. * * @return array */ private static function get_custom_schemas() { $quiz_fields = array(); /** * Handle old quiz layout compatibility API: * Translate the old filter into the new one for quizzes. */ if ( get_theme_support( 'lifterlms-quizzes' ) && has_filter( 'llms_get_quiz_theme_settings' ) ) { $theme = wp_get_theme(); $old = llms_get_quiz_theme_setting( 'layout' ); $field = array( 'attribute' => $old['id'], 'id' => $old['id'], 'label' => $old['name'], 'type' => ( 'select' === $old['type'] ) ? 'select' : 'radio', 'options' => $old['options'], ); if ( isset( $old['id_prefix'] ) ) { $field['attribute_prefix'] = $old['id_prefix']; } $quiz_fields[ sprintf( '%s_backwards_theme_group', $theme->get_stylesheet() ) ] = array( // Translators: %s = Theme name. 'title' => sprintf( __( '%s Theme Settings', 'lifterlms' ), $theme->get( 'Name' ) ), 'toggleable' => true, 'fields' => array( array( $field ) ), ); } /** * Add custom fields to the LifterLMS Builder. * * @since 3.17.0 * * @link https://lifterlms.com/docs/course-builder-custom-fields-for-developers * * @param array[] $fields Array of post types containing arrays of custom field data. */ return apply_filters( 'llms_builder_register_custom_fields', array( 'lesson' => array(), 'quiz' => $quiz_fields, ) ); } /** * Retrieve a list of lessons the current user is allowed to clone/attach * * Used for ajax searching to add existing lessons. * * @since 3.14.8 * @since 3.16.12 Unknown. * @since 5.8.0 Allow LMS managers to get all lessons. {@link https://github.com/gocodebox/lifterlms/issues/1849}. * Removed unused `$course_id` parameter. * * @param string $post_type Optional. Search specific post type(s). By default searches for all post types. * @param string $search_term Optional. Search term (searches post_title). Default is empty string. * @param int $page Optional. Used when paginating search results. Default is `1`. * @return array */ private static function get_existing_posts( $post_type = '', $search_term = '', $page = 1 ) { $args = array( 'order' => 'ASC', 'orderby' => 'post_title', 'paged' => $page, 'post_status' => array( 'publish', 'draft', 'pending' ), 'posts_per_page' => 10, ); if ( $post_type ) { $args['post_type'] = $post_type; } if ( ! current_user_can( 'manage_lifterlms' ) ) { $instructor = llms_get_instructor(); $parents = $instructor->get( 'parent_instructors' ); if ( ! $parents ) { $parents = array(); } $args['author__in'] = array_unique( array_merge( array( get_current_user_id() ), $instructor->get_assistants(), $parents ) ); } self::$search_term = $search_term; add_filter( 'posts_where', array( __CLASS__, 'get_existing_posts_where' ), 10, 2 ); $query = new WP_Query( $args ); remove_filter( 'posts_where', array( __CLASS__, 'get_existing_posts_where' ), 10, 2 ); $posts = array(); if ( $query->have_posts() ) { foreach ( $query->posts as $post ) { $post = llms_get_post( $post ); $parents = array(); if ( method_exists( $post, 'is_orphan' ) && $post->is_orphan() ) { $action = 'attach'; } else { $action = 'clone'; $course_id = false; $lesson_id = false; if ( 'lesson' === $post->get( 'type' ) ) { $course_id = $post->get( 'parent_course' ); } elseif ( 'llms_quiz' === $post->get( 'type' ) ) { $lesson_id = $post->get( 'lesson_id' ); $course = $post->get_course(); if ( $course ) { $course_id = $course->get( 'id' ); } } if ( $lesson_id ) { // Translators: %1$s = Lesson title; %2$d = Lesson id. $parents['lesson'] = sprintf( __( 'Lesson: %1$s (#%2$d)', 'lifterlms' ), '<em>' . get_the_title( $lesson_id ) . '</em>', $lesson_id ); } if ( $course_id ) { // Translators: %1$s = Course title; %2$d - Course id. $parents['course'] = sprintf( __( 'Course: %1$s (#%2$d)', 'lifterlms' ), '<em>' . get_the_title( $course_id ) . '</em>', $course_id ); } } $posts[] = array( 'action' => $action, 'data' => $post, 'id' => $post->get( 'id' ), 'parents' => $parents, 'text' => sprintf( '%1$s (#%2$d)', $post->get( 'title' ), $post->get( 'id' ) ), ); } } $ret = array( 'results' => $posts, 'pagination' => array( 'more' => ( $page < $query->max_num_pages ), ), ); return $ret; } /** * Search lessons by search term during existing lesson lookups * * @since 3.14.8 * @since 3.16.12 Unknown. * @since 3.37.11 Made method static. * * @param string $where Existing sql where clause. * @param WP_QUery $wp_query Query object. * @return string */ public static function get_existing_posts_where( $where, $wp_query ) { if ( self::$search_term ) { global $wpdb; $where .= ' AND ' . $wpdb->posts . '.post_title LIKE "%' . esc_sql( $wpdb->esc_like( self::$search_term ) ) . '%"'; } return $where; } /** * Retrieve the HTML of a JS template * * @since 3.16.0 * * @param string $template Template file slug. * @return string */ private static function get_template( $template, $vars = array() ) { ob_start(); extract( $vars ); include 'views/builder/' . $template . '.php'; return ob_get_clean(); } /** * A terrible Rest API for the course builder * * @since 3.13.0 * @since 3.19.2 Unknown. * @since 4.16.0 Remove all filters/actions applied to the title/content when handling the ajax_save by deafault. * This is specially to prevent plugin conflicts, see https://github.com/gocodebox/lifterlms/issues/1530. * @since 4.17.0 Remove `remove_all_*` hooks added in version 4.16.0. * * @param array $request $_REQUEST * @return array */ public static function handle_ajax( $request ) { // @todo Do some real error handling here. if ( ! $request['course_id'] || ! current_user_can( 'edit_course', $request['course_id'] ) ) { return array(); } switch ( $request['action_type'] ) { case 'ajax_save': if ( isset( $request['llms_builder'] ) ) { $request['llms_builder'] = stripslashes( $request['llms_builder'] ); wp_send_json( self::heartbeat_received( array(), $request ) ); } break; case 'get_permalink': $id = isset( $request['id'] ) ? absint( $request['id'] ) : false; if ( ! $id ) { return array(); } $title = isset( $request['title'] ) ? sanitize_title( $request['title'] ) : null; $slug = isset( $request['slug'] ) ? sanitize_title( $request['slug'] ) : null; $link = get_sample_permalink( $id, $title, $slug ); wp_send_json( array( 'slug' => $link[1], 'permalink' => str_replace( '%pagename%', $link[1], $link[0] ), ) ); break; case 'lazy_load': $ret = array(); if ( isset( $request['load_id'] ) ) { $post = llms_get_post( absint( $request['load_id'] ) ); $ret = $post->toArray(); } wp_send_json( $ret ); break; case 'search': $page = isset( $request['page'] ) ? $request['page'] : 1; $term = isset( $request['term'] ) ? sanitize_text_field( $request['term'] ) : ''; $post_type = ''; if ( isset( $request['post_type'] ) ) { if ( is_array( $request['post_type'] ) ) { $post_type = array_map( 'sanitize_text_field', $request['post_type'] ); } else { $post_type = sanitize_text_field( $request['post_type'] ); } } wp_send_json( self::get_existing_posts( $post_type, $term, $page ) ); break; } return array(); } /** * Do post locking stuff on the builder * * Locking the course edit main screen will lock the builder and vice versa... probably need to find a way * to fix that but for now this'll work just fine and if you're unhappy about it, well, sorry... * * @since 3.13.0 * * @param int $course_id WP Post ID. * @return void */ private static function handle_post_locking( $course_id ) { if ( ! wp_check_post_lock( $course_id ) ) { $active_post_lock = wp_set_post_lock( $course_id ); } ?><input type="hidden" id="post_ID" value="<?php echo absint( $course_id ); ?>"> <?php if ( ! empty( $active_post_lock ) ) { ?> <input type="hidden" id="active_post_lock" value="<?php echo esc_attr( implode( ':', $active_post_lock ) ); ?>" /> <?php } add_filter( 'get_edit_post_link', array( __CLASS__, 'modify_take_over_link' ), 10, 3 ); add_action( 'admin_footer', '_admin_notice_post_locked' ); } /** * Handle AJAX Heartbeat received calls * * All builder data is sent through the heartbeat. * @since 3.16.0 * @since 3.24.2 Unknown. * * @param array $res Response data. * @param array $data Data from the heartbeat api. * Builder data will be in the "llms_builder" array. * @return array */ public static function heartbeat_received( $res, $data ) { // Exit if there's no builder data in the heartbeat data. if ( empty( $data['llms_builder'] ) ) { return $res; } // Isolate builder data & ensure slashes aren't removed. $data = $data['llms_builder']; // Escape slashes. $data = json_decode( $data, true ); // Setup our return. $ret = array( 'status' => 'success', 'message' => esc_html__( 'Success', 'lifterlms' ), ); // Need a numeric ID for a course post type! if ( empty( $data['id'] ) || ! is_numeric( $data['id'] ) || 'course' !== get_post_type( $data['id'] ) ) { $ret['status'] = 'error'; $ret['message'] = esc_html__( 'Error: Invalid or missing course ID.', 'lifterlms' ); } elseif ( ! current_user_can( 'edit_course', $data['id'] ) ) { $ret['status'] = 'error'; $ret['message'] = esc_html__( 'Error: You do not have permission to edit this course.', 'lifterlms' ); } else { if ( ! empty( $data['detach'] ) && is_array( $data['detach'] ) ) { $ret['detach'] = self::process_detachments( $data ); } if ( current_user_can( 'delete_course', $data['id'] ) ) { if ( ! empty( $data['trash'] ) && is_array( $data['trash'] ) ) { $ret['trash'] = self::process_trash( $data ); } } if ( ! empty( $data['updates'] ) && is_array( $data['updates'] ) ) { $ret['updates']['sections'] = self::process_updates( $data ); } } // Unescape slashes after saved. // This ensures that updates are recognized as successful during Sync comparisons. // phpcs:ignore -- commented out code // $ret = json_decode( str_replace( '\\\\', '\\', json_encode( $ret ) ), true ); // Return our data. $res['llms_builder'] = $ret; return $res; } /** * Determine if an ID submitted via heartbeat data is a temporary id. * * If so the object must be created rather than updated * * @since 3.16.0 * @since 3.17.0 * * @param string $id An ID string. * @return bool */ public static function is_temp_id( $id ) { return ( ! is_numeric( $id ) && 0 === strpos( $id, 'temp_' ) ); } /** * Modify the "Take Over" link on the post locked modal to send users to the builder when taking over a course * * @since 3.13.0 * * @param string $link Default post edit link. * @param int $post_id WP Post ID of the course. * @param string $context Display context. * @return string */ public static function modify_take_over_link( $link, $post_id, $context ) { return add_query_arg( array( 'page' => 'llms-course-builder', 'course_id' => $post_id, ), admin_url( 'admin.php' ) ); } /** * Output the page content * * @since 3.13.0 * @since 3.19.2 Unknown. * @since 4.14.0 Added builder autosave preference defaults. * @since 7.2.0 Added video explainer template. * * @return void */ public static function output() { global $post; $course_id = isset( $_GET['course_id'] ) ? absint( $_GET['course_id'] ) : null; if ( ! $course_id || ( $course_id && 'course' !== get_post_type( $course_id ) ) ) { _e( 'Invalid course ID', 'lifterlms' ); return; } $post = get_post( $course_id ); $course = llms_get_post( $post ); if ( ! current_user_can( 'edit_course', $course_id ) ) { _e( 'You cannot edit this course!', 'lifterlms' ); return; } remove_all_actions( 'the_title' ); remove_all_actions( 'the_content' ); global $llms_builder_lazy_load; $llms_builder_lazy_load = true; ?> <div class="wrap lifterlms llms-builder"> <?php do_action( 'llms_before_builder', $course_id ); ?> <div class="llms-builder-main" id="llms-builder-main"></div> <aside class="llms-builder-sidebar" id="llms-builder-sidebar"></aside> <?php $templates = array( 'assignment', 'course', 'editor', 'elements', 'lesson', 'lesson-settings', 'quiz', 'question', 'question-choice', 'question-type', 'section', 'settings-fields', 'sidebar', 'utilities', 'video-explainer', ); foreach ( $templates as $template ) { echo self::get_template( $template, array( 'course_id' => $course_id, ) ); } ?> <script>window.llms_builder = <?php echo json_encode( /** * Filters the settings passed to the builder. * * @since 7.2.0 * * @param array $settings Associative array of settings passed to the LifterLMS course builder. */ apply_filters( 'llms_builder_settings', array( 'autosave' => self::get_autosave_status(), 'admin_url' => admin_url(), 'course' => $course->toArray(), 'debug' => array( 'enabled' => ( defined( 'LLMS_BUILDER_DEBUG' ) && LLMS_BUILDER_DEBUG ), ), 'questions' => array_values( llms_get_question_types() ), 'schemas' => self::get_custom_schemas(), 'sync' => apply_filters( /** * Filters the sync builder settings. * * @since 3.16.0 * * @param array $settings Associative array of settings passed to the LifterLMS course builder used for the sync. */ 'llms_builder_sync_settings', array( 'check_interval_ms' => 10000, ) ), 'enable_video_explainer' => true, ) ) ); ?> </script> <?php do_action( 'llms_after_builder', $course_id ); ?> </div> <?php $llms_builder_lazy_load = false; self::handle_post_locking( $course_id ); } /** * Process lesson detachments from the heartbeat data * * @since 3.16.0 * @since 3.27.0 Unknown. * * @param array $data Array of lesson ids. * @return array */ private static function process_detachments( $data ) { $ret = array(); foreach ( $data['detach'] as $id ) { $res = array( // Translators: %s = Item id. 'error' => sprintf( esc_html__( 'Unable to detach "%s". Invalid ID.', 'lifterlms' ), $id ), 'id' => $id, ); $type = get_post_type( $id ); $post_types = apply_filters( 'llms_builder_detachable_post_types', array( 'lesson', 'llms_question', 'llms_quiz' ) ); if ( ! is_numeric( $id ) || ! in_array( $type, $post_types ) ) { array_push( $ret, $res ); continue; } $post = llms_get_post( $id ); if ( ! is_a( $post, 'LLMS_Post_Model' ) ) { array_push( $ret, $res ); continue; } if ( 'lesson' === $type ) { $post->set( 'parent_course', '' ); $post->set( 'parent_section', '' ); } elseif ( 'llms_question' === $type ) { $post->set( 'parent_id', '' ); } elseif ( 'llms_quiz' === $type ) { $parent = $post->get_lesson(); if ( $parent ) { $parent->set( 'quiz_enabled', 'no' ); $parent->set( 'quiz', '' ); $post->set( 'lesson_id', 0 ); } } do_action( 'llms_builder_detach_' . $type, $post ); unset( $res['error'] ); array_push( $ret, $res ); } return $ret; } /** * Delete/trash elements from heartbeat data * * @since 3.16.0 * @since 3.17.1 Unknown. * @since 3.37.12 Refactored method to reduce method complexity. * * @param array $data Array of ids to trash/delete. * @return array[] Array of arrays containing information about the deleted items. */ private static function process_trash( $data ) { $ret = array(); foreach ( $data['trash'] as $id ) { $ret[] = self::process_trash_item( $id ); } return $ret; } /** * Trash (or delete) a single item * * @since 3.37.12 * * @param mixed $id Item id. Usually a WP_Post ID but can also be custom ID strings. * @return array Associative array containing information about the trashed item. * On success returns an array with an `id` key corresponding to the item's id. * On failure returns the `id` as well as an `error` key which is a string describing the error. */ private static function process_trash_item( $id ) { // Default response. $res = array( // Translators: %s = Item id. 'error' => sprintf( esc_html__( 'Unable to delete "%s". Invalid ID.', 'lifterlms' ), $id ), 'id' => $id, ); /** * Custom or 3rd party items can perform custom deletion actions using this filter. * * Return an associative array containing at least the `$id` to cease execution and have * the custom item returned via the `process_trash()` method. * * A successful deletion return should be: `array( 'id' => $id )`. * * A failure should contain an error message in a second array member: * `array( 'id' => $id, 'error' => esc_html__( 'My error message', 'my-domain' ) )`. * * @since Unknown. * * @param null|array $trash_response Denotes the trash response. See description above for details. * @param array $res The initial default error response which can be modified for your needs and then returned. * @param mixed $id The ID of the course element. Usually a WP_Post id. */ $custom = apply_filters( 'llms_builder_trash_custom_item', null, $res, $id ); if ( $custom ) { return $custom; } // Determine the element's post type. $type = is_numeric( $id ) ? get_post_type( $id ) : false; if ( $type ) { $status = self::process_trash_item_post_type( $id, $type ); } else { $status = self::process_trash_item_non_post_type( $id ); } // Error deleting. if ( is_wp_error( $status ) ) { $res['error'] = $status->get_error_message(); } elseif ( true === $status ) { // Success. unset( $res['error'] ); } return $res; } /** * Delete non-post type elements * * Currently handles deletion of question choices. In the future additional non-post type elements * may be handled by this method. * * @since 3.37.12 * * @param string $id Custom item ID. This should be a question choice id in the format of "{$question_id}:{$choice_id}". * @return null|true|WP_Error `null` when the $id cannot be parsed into a question choice id. * `true` on success. * `WP_Error` when an error is encountered. */ private static function process_trash_item_non_post_type( $id ) { // Can't process. if ( false === strpos( $id, ':' ) ) { return null; } $split = explode( ':', $id ); $question = llms_get_post( $split[0] ); // Not a question choice. if ( ! $question || ! is_a( $question, 'LLMS_Question' ) ) { return null; } // Error. if ( ! $question->delete_choice( $split[1] ) ) { // Translators: %s = Question choice ID. return new WP_Error( 'llms_builder_trash_custom_item', sprintf( esc_html__( 'Error deleting the question choice "%s"', 'lifterlms' ), $id ) ); } // Success. return true; } /** * Delete / Trash a post type * * @since 3.37.12 * * @param int $id WP_Post ID. * @param string $post_type Post type name. * @return boolean|WP_Error `true` when successfully deleted or trashed. * `WP_Error` for unsupported post types or when a deletion error is encountered. */ private static function process_trash_item_post_type( $id, $post_type ) { // Used for errors. $obj = get_post_type_object( $post_type ); /** * Filter course elements that can be deleted or trashed via the course builder. * * Note that the use of "trash" in the filter name is not semantically correct as this filter does not guarantee * that the element will be sent to the trash. Use the filter `llms_builder_trash_{$post_type}_force_delete` to * determine if the element is sent to the trash or deleted immediately. * * @since Unknown * @since 3.37.12 The "question_choice" item was removed from the default list and is being handled as a "custom item". * * @param string[] $post_types Array of post type names. */ $post_types = apply_filters( 'llms_builder_trashable_post_types', array( 'lesson', 'llms_quiz', 'llms_question', 'section' ) ); if ( ! in_array( $post_type, $post_types, true ) ) { // Translators: %s = Post type name. return new WP_Error( 'llms_builder_trash_unsupported_post_type', sprintf( esc_html__( '%s cannot be deleted via the Course Builder.', 'lifterlms' ), $obj->labels->name ) ); } // Default force value: these post types are force deleted and others are moved to the trash. $force = in_array( $post_type, array( 'section', 'llms_question', 'llms_quiz' ), true ); /** * Determine whether or not a post type should be moved to the trash or deleted when trashed via the Course Builder. * * The dynamic portion of this hook, `$post_type`, refers to the post type name of the post that's being trashed. * * By default all post types are moved to trash except for `section`, `llms_question`, and `llms_quiz` post types. * * @since 3.37.12 * * @param boolean $force If `true` the post is deleted, if `false` it will be moved to the trash. * @param int $id WP_Post ID of the post being trashed. */ $force = apply_filters( "llms_builder_{$post_type}_force_delete", $force, $id ); // Delete or trash the post. $res = $force ? wp_delete_post( $id, true ) : wp_trash_post( $id ); if ( ! $res ) { // Translators: %1$s = Post type singular name; %2$d = Post id. return new WP_Error( 'llms_builder_trash_post_type', sprintf( esc_html__( 'Error deleting the %1$s "%2$d".', 'lifterlms' ), $obj->labels->singular_name, $id ) ); } return true; } /** * Process all the update data from the heartbeat * * @since 3.16.0 * * @param array $data Array of course updates (all the way down the tree). * @return array */ private static function process_updates( $data ) { $ret = array(); if ( ! empty( $data['updates']['sections'] ) && is_array( $data['updates']['sections'] ) ) { foreach ( $data['updates']['sections'] as $section_data ) { if ( ! isset( $section_data['id'] ) ) { continue; } $ret[] = self::update_section( $section_data, $data['id'] ); } } return $ret; } /** * Handle updating custom schema data * * @since 3.17.0 * @since 3.30.0 Fixed typo preventing fields specifying a custom callback from working. * @since 3.30.0 Array fields will run field values through `sanitize_text_field()` instead of requiring a custom sanitization callback. * * @param string $type Model type (lesson, quiz, etc...). * @param LLMS_Post_Model $post LLMS_Post_Model object for the model being updated. * @param array $post_data Assoc array of raw data to update the model with. * @return void */ public static function update_custom_schemas( $type, $post, $post_data ) { $schemas = self::get_custom_schemas(); if ( empty( $schemas[ $type ] ) ) { return; } $groups = $schemas[ $type ]; foreach ( $groups as $name => $group ) { // Allow 3rd parties to manage their own custom save methods. if ( apply_filters( 'llms_builder_update_custom_fields_group_' . $name, false, $post, $post_data, $groups ) ) { continue; } foreach ( $group['fields'] as $fields ) { foreach ( $fields as $field ) { $keys = array( $field['attribute'] ); if ( isset( $field['switch_attribute'] ) ) { $keys[] = $field['switch_attribute']; } foreach ( $keys as $attr ) { if ( isset( $post_data[ $attr ] ) ) { if ( isset( $field['sanitize_callback'] ) ) { $val = call_user_func( $field['sanitize_callback'], $post_data[ $attr ] ); } else { if ( is_array( $post_data[ $attr ] ) ) { $val = array_map( 'sanitize_text_field', $post_data[ $attr ] ); } else { $val = sanitize_text_field( $post_data[ $attr ] ); } } $attr = isset( $field['attribute_prefix'] ) ? $field['attribute_prefix'] . $attr : $attr; update_post_meta( $post_data['id'], $attr, $val ); } } } } } } /** * Update lesson from heartbeat data. * * @since 3.16.0 * @since 5.1.3 Made sure a lesson moved in a just created section is correctly assigned to it. * @since 7.3.0 Skip revision creation when creating a brand new lesson. * * @param array $lessons Lesson data from heartbeat. * @param LLMS_Section $section instance of the parent LLMS_Section. * @return array */ private static function update_lessons( $lessons, $section ) { $ret = array(); foreach ( $lessons as $lesson_data ) { if ( ! isset( $lesson_data['id'] ) ) { continue; } $res = array_merge( $lesson_data, array( 'orig_id' => $lesson_data['id'], ) ); // Create a new lesson. if ( self::is_temp_id( $lesson_data['id'] ) ) { $lesson = new LLMS_Lesson( 'new', array( 'post_title' => isset( $lesson_data['title'] ) ? $lesson_data['title'] : __( 'New Lesson', 'lifterlms' ), ) ); $created = true; } else { $lesson = llms_get_post( $lesson_data['id'] ); $created = false; } if ( empty( $lesson ) || ! is_a( $lesson, 'LLMS_Lesson' ) ) { // Translators: %s = Lesson post id. $res['error'] = sprintf( esc_html__( 'Unable to update lesson "%s". Invalid lesson ID.', 'lifterlms' ), $lesson_data['id'] ); } else { // Don't create useless revision on "creating". add_filter( 'wp_revisions_to_keep', '__return_zero', 999 ); /** * If the parent section was just created the lesson will have a temp id * replace it with the newly created section's real ID. */ if ( ! isset( $lesson_data['parent_section'] ) || self::is_temp_id( $lesson_data['parent_section'] ) ) { $lesson_data['parent_section'] = $section->get( 'id' ); } // Return the real ID (important when creating a new lesson). $res['id'] = $lesson->get( 'id' ); $properties = array_merge( array_keys( $lesson->get_properties() ), array( 'content', 'title', ) ); $skip_props = apply_filters( 'llms_builder_update_lesson_skip_props', array( 'quiz' ) ); // Update all updatable properties. foreach ( $properties as $prop ) { if ( isset( $lesson_data[ $prop ] ) && ! in_array( $prop, $skip_props, true ) ) { $lesson->set( $prop, $lesson_data[ $prop ] ); } } // Update all custom fields. self::update_custom_schemas( 'lesson', $lesson, $lesson_data ); // During clone's we want to ensure custom field data comes with the lesson. if ( $created && isset( $lesson_data['custom'] ) ) { foreach ( $lesson_data['custom'] as $custom_key => $custom_vals ) { foreach ( $custom_vals as $val ) { add_post_meta( $lesson->get( 'id' ), $custom_key, maybe_unserialize( $val ) ); } } } // Ensure slug gets updated when changing title from default "New Lesson". if ( isset( $lesson_data['title'] ) && ! $lesson->has_modified_slug() ) { $lesson->set( 'name', sanitize_title( $lesson_data['title'] ) ); } // Remove revision prevention. remove_filter( 'wp_revisions_to_keep', '__return_zero', 999 ); if ( ! empty( $lesson_data['quiz'] ) && is_array( $lesson_data['quiz'] ) ) { $res['quiz'] = self::update_quiz( $lesson_data['quiz'], $lesson ); } } // Allow 3rd parties to update custom data. $res = apply_filters( 'llms_builder_update_lesson', $res, $lesson_data, $lesson, $created ); array_push( $ret, $res ); } return $ret; } /** * Update quiz questions from heartbeat data * * @since 3.16.0 * @since 3.16.11 Unknown. * @since 3.38.2 Make sure that a question as a type set, otherwise set it by default to `'choice'`. * * @param array $questions Question data array. * @param LLMS_Quiz|LLMS_Question $parent Instance of an LLMS_Quiz or LLMS_Question (group). * @return array */ private static function update_questions( $questions, $parent ) { $res = array(); foreach ( $questions as $q_data ) { $ret = array_merge( $q_data, array( 'orig_id' => $q_data['id'], ) ); // Remove temp id if we have one so we'll create a new question. if ( self::is_temp_id( $q_data['id'] ) ) { unset( $q_data['id'] ); } // Remove choices because we'll add them individually after creation. $choices = ( isset( $q_data['choices'] ) && is_array( $q_data['choices'] ) ) ? $q_data['choices'] : false; unset( $q_data['choices'] ); // Remove child questions if it's a question group. $questions = ( isset( $q_data['questions'] ) && is_array( $q_data['questions'] ) ) ? $q_data['questions'] : false; unset( $q_data['questions'] ); $question_id = $parent->questions()->update_question( $q_data ); if ( ! $question_id ) { // Translators: %s = Question post id. $ret['error'] = sprintf( esc_html__( 'Unable to update question "%s". Invalid question ID.', 'lifterlms' ), $q_data['id'] ); } else { $ret['id'] = $question_id; $question = $parent->questions()->get_question( $question_id ); /** * When saving a question, make sure that it has a question type set * otherwise set it by default to `'choice'`. */ if ( ! $question->get( 'question_type', true ) ) { $question->set( 'question_type', 'choice' ); } if ( $choices ) { $ret['choices'] = array(); foreach ( $choices as $c_data ) { $choice_res = array_merge( $c_data, array( 'orig_id' => $c_data['id'], ) ); unset( $c_data['question_id'] ); // Remove the temp ID so that we create it if it's new. if ( self::is_temp_id( $c_data['id'] ) ) { unset( $c_data['id'] ); } $choice_id = $question->update_choice( $c_data ); if ( ! $choice_id ) { // Translators: %s = Question choice ID. $choice_res['error'] = sprintf( esc_html__( 'Unable to update choice "%s". Invalid choice ID.', 'lifterlms' ), $c_data['id'] ); } else { $choice_res['id'] = $choice_id; } array_push( $ret['choices'], $choice_res ); } } elseif ( $questions ) { $ret['questions'] = self::update_questions( $questions, $question ); } } array_push( $res, $ret ); } return $res; } /** * Update quizzes during heartbeats * * @since 3.16.0 * @since 3.17.6 Unknown. * * @param array $quiz_data Array of quiz updates. * @param LLMS_Lesson $lesson Instance of the parent LLMS_Lesson. * @return array */ private static function update_quiz( $quiz_data, $lesson ) { $res = array_merge( $quiz_data, array( 'orig_id' => $quiz_data['id'], ) ); // Create a quiz. if ( self::is_temp_id( $quiz_data['id'] ) ) { $quiz = new LLMS_Quiz( 'new' ); // Update existing quiz. } else { $quiz = llms_get_post( $quiz_data['id'] ); } $lesson->set( 'quiz', $quiz->get( 'id' ) ); $lesson->set( 'quiz_enabled', 'yes' ); // We don't have a proper quiz to work with... if ( empty( $quiz ) || ! is_a( $quiz, 'LLMS_Quiz' ) ) { // Translators: %s = Quiz post id. $res['error'] = sprintf( esc_html__( 'Unable to update quiz "%s". Invalid quiz ID.', 'lifterlms' ), $quiz_data['id'] ); } else { // Return the real ID (important when creating a new quiz). $res['id'] = $quiz->get( 'id' ); /** * If the parent lesson was just created the lesson will have a temp id * replace it with the newly created lessons's real ID. */ if ( ! isset( $quiz_data['lesson_id'] ) || self::is_temp_id( $quiz_data['lesson_id'] ) ) { $quiz_data['lesson_id'] = $lesson->get( 'id' ); } $properties = array_merge( array_keys( $quiz->get_properties() ), array( // phpcs:ignore -- commented out code // 'content', 'status', 'title', ) ); // Update all updatable properties. foreach ( $properties as $prop ) { if ( isset( $quiz_data[ $prop ] ) ) { $quiz->set( $prop, $quiz_data[ $prop ] ); } } if ( isset( $quiz_data['questions'] ) && is_array( $quiz_data['questions'] ) ) { $res['questions'] = self::update_questions( $quiz_data['questions'], $quiz ); } // Update all custom fields. self::update_custom_schemas( 'quiz', $quiz, $quiz_data ); } return $res; } /** * Update a section with data from the heartbeat * * @since 3.16.0 * @since 3.16.11 Unknown. * * @param array $section_data Array of section data. * @param LLMS_Course $course_id Instance of the parent LLMS_Course. * @return array */ private static function update_section( $section_data, $course_id ) { $res = array_merge( $section_data, array( 'orig_id' => $section_data['id'], ) ); // Create a new section. if ( self::is_temp_id( $section_data['id'] ) ) { $section = new LLMS_Section( 'new' ); $section->set( 'parent_course', $course_id ); // Update existing section. } else { $section = llms_get_post( $section_data['id'] ); } // We don't have a proper section to work with... if ( empty( $section ) || ! is_a( $section, 'LLMS_Section' ) ) { // Translators: %s = Section post id. $res['error'] = sprintf( esc_html__( 'Unable to update section "%s". Invalid section ID.', 'lifterlms' ), $section_data['id'] ); } else {
Expand full source code Collapse full source code View on GitHub
Methods Methods
- admin_bar_menu — Add menu items to the WP Admin Bar to allow quiz returns to the dashboard from the course builder
- get_autosave_status — Retrieve the current user's builder autosave preferences
- get_custom_schemas — Retrieve custom field schemas
- get_existing_posts — Retrieve a list of lessons the current user is allowed to clone/attach
- get_existing_posts_where — Search lessons by search term during existing lesson lookups
- get_template — Retrieve the HTML of a JS template
- handle_ajax — A terrible Rest API for the course builder
- handle_post_locking — Do post locking stuff on the builder
- heartbeat_received — Handle AJAX Heartbeat received calls
- is_temp_id — Determine if an ID submitted via heartbeat data is a temporary id.
- modify_take_over_link — Modify the "Take Over" link on the post locked modal to send users to the builder when taking over a course
- output — Output the page content
- process_detachments — Process lesson detachments from the heartbeat data
- process_trash — Delete/trash elements from heartbeat data
- process_trash_item — Trash (or delete) a single item
- process_trash_item_non_post_type — Delete non-post type elements
- process_trash_item_post_type — Delete / Trash a post type
- process_updates — Process all the update data from the heartbeat
- update_custom_schemas — Handle updating custom schema data
- update_lessons — Update lesson from heartbeat data
- update_questions — Update quiz questions from heartbeat data
- update_quiz — Update quizzes during heartbeats
- update_section — Update a section with data from the heartbeat
Changelog Changelog
Version | Description |
---|---|
3.38.2 | On quiz saving, made sure that a question as a type set, otherwise set it by default to 'choice' . |
3.38.0 | Improve backwards compatibility handling for the llms_get_quiz_theme_settings filter. |
3.37.12 | Refactored the process_trash() method. Added new filter, llms_builder_{$post_type}_force_delete to allow control of how post type deletion is handled when deleted via the builder. |
3.37.11 | Made method get_existing_posts_where() static. |
3.30.0 | Fixed issues related to custom field sanitization. |
3.13.0 | Introduced. |