includes/admin/class.llms.admin.builder.php
<?php
/**
* LifterLMS Admin Course Builder
*
* @package LifterLMS/Admin/Classes
*
* @since 3.13.0
* @version 7.3.0
*/
defined( 'ABSPATH' ) || exit;
/**
* LLMS_Admin_Builder class
*
* @since 3.13.0
* @since 3.30.0 Fixed issues related to custom field sanitization.
* @since 3.37.11 Made method `get_existing_posts_where()` static.
* @since 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.
* @since 3.38.0 Improve backwards compatibility handling for the `llms_get_quiz_theme_settings` filter.
* @since 3.38.2 On quiz saving, made sure that a question as a type set, otherwise set it by default to `'choice'`.
*/
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 ) ? 'no' : $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 ) {
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.
* @since 7.6.0 Removed 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 );
if ( ! current_user_can( 'edit_course', $course_id ) ) {
_e( 'You cannot edit this course!', 'lifterlms' );
return;
}
if ( 'auto-draft' === $post->post_status ) {
wp_update_post(
array(
'ID' => $course_id,
'post_status' => 'draft',
'post_title' => __( 'New Course', 'lifterlms' ),
)
);
$post = get_post( $course_id );
}
$course = llms_get_post( $post );
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',
);
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' => ( 'yes' === self::get_autosave_status() ? 10000 : 1000 ),
)
),
'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 {
// Return the real ID (important when creating a new section).
$res['id'] = $section->get( 'id' );
// Run through all possible updated fields.
foreach ( array( 'order', 'title' ) as $key ) {
// Update those that were sent through.
if ( isset( $section_data[ $key ] ) ) {
$section->set( $key, $section_data[ $key ] );
}
}
if ( isset( $section_data['lessons'] ) && is_array( $section_data['lessons'] ) ) {
$res['lessons'] = self::update_lessons( $section_data['lessons'], $section );
}
}
return $res;
}
}