<?php
/**
* File containing the class Sensei_Course_Structure.
*
* @package sensei
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Contains methods for retrieving and saving a Sensei course's structure.
*
* @since 3.6.0
*/
class Sensei_Course_Structure {
/**
* Course instances.
*
* @var self[]
*/
private static $instances = [];
/**
* The course post ID.
*
* @var int
*/
private $course_id;
/**
* Get an instance of this class for a course.
*
* @param int $course_id The course post ID.
*
* @return static
*/
public static function instance( int $course_id ): self {
if ( ! isset( self::$instances[ $course_id ] ) ) {
self::$instances[ $course_id ] = new static( $course_id );
}
return self::$instances[ $course_id ];
}
/**
* Sensei_Course_Structure constructor.
*
* @param int $course_id The course post ID.
*/
private function __construct( int $course_id ) {
$this->course_id = $course_id;
}
/**
* Get the course structure.
*
* @see Sensei_Course_Structure::prepare_lesson()
* @see Sensei_Course_Structure::prepare_module()
*
* @param string $context Context that structure is being retrieved for. Possible values: edit, view.
* @param boolean $no_cache Avoid query cache.
*
* @return array {
* An array which has course structure information.
*
* @type array Each element is an array with either module or lesson information as defined in prepare_lesson()
* and prepare_module().
* }
*/
public function get( $context = 'view', $no_cache = false ) {
if ( $no_cache ) {
add_filter( 'posts_where', [ $this, 'filter_no_cache_where' ] );
}
$context = in_array( $context, [ 'view', 'edit' ], true ) ? $context : 'view';
$structure = [];
$published_lessons_only = 'view' === $context && ! current_user_can( 'read_private_posts' );
$post_status = $published_lessons_only ? 'publish' : 'any';
$no_module_lessons = wp_list_pluck( Sensei()->modules->get_none_module_lessons( $this->course_id, $post_status ), 'ID' );
$modules = $this->get_modules();
foreach ( $modules as $module_term ) {
$module = $this->prepare_module( $module_term, $post_status );
if ( ! empty( $module['lessons'] ) || 'edit' === $context ) {
$structure[] = $module;
}
}
foreach ( $no_module_lessons as $lesson_id ) {
$lesson = get_post( $lesson_id );
if ( ! $lesson ) {
continue;
}
$structure[] = $this->prepare_lesson( $lesson );
}
if ( $no_cache ) {
remove_filter( 'posts_where', [ $this, 'filter_no_cache_where' ] );
}
return $structure;
}
/**
* Filter where adding an extra condition to avoid cache.
*
* @access private
*
* @param string $where Current where.
*
* @return string Where with extra condition to avoid cache.
*/
public function filter_no_cache_where( $where ) {
$time = time();
return $where . ' AND ' . $time . ' = ' . $time;
}
/**
* Prepare the result for a module.
*
* @see Sensei_Course_Structure::prepare_lesson()
*
* @param WP_Term $module_term Module term.
* @param array|string $lesson_post_status Lesson post status(es).
*
* @return array {
* An array which has module information.
*
* @type string $type The type of the array which is 'module'.
* @type int $id The module term id.
* @type string $title The module name.
* @type string $teacher Name of the teacher of this module, gets populated only in admin panel for admins and if the teacher is not admin.
* @type int $teacherId ID of the module author, used by block editor to detect change of teacher.
* @type string $lastTitle Used in the block editor for unchanged title state reference.
* @type string $slug Slug of the module, not empty only if the slug is custom.
* @type string $description The module description.
* @type array $lessons An array of the module lessons. See Sensei_Course_Structure::prepare_lesson().
* }
*/
private function prepare_module( WP_Term $module_term, $lesson_post_status ): array {
$lessons = $this->get_module_lessons( $module_term->term_id, $lesson_post_status );
$author = Sensei_Core_Modules::get_term_author( $module_term->slug );
$default_slug = $this->get_module_slug( $module_term->name );
$module = [
'type' => 'module',
'id' => $module_term->term_id,
'title' => $module_term->name,
'description' => $module_term->description,
'teacher' => user_can( $author, 'manage_options' ) ? '' : $author->display_name,
'teacherId' => $author->ID,
'lastTitle' => $module_term->name,
'slug' => $module_term->slug === $default_slug ? '' : $module_term->slug,
'lessons' => [],
];
foreach ( $lessons as $lesson ) {
$module['lessons'][] = $this->prepare_lesson( $lesson );
}
return $module;
}
/**
* Prepare the result for a lesson.
*
* @param WP_Post $lesson_post Lesson post object.
*
* @return array {
* An array which has lesson information.
*
* @type string $type The type of the array which is 'lesson'.
* @type int $id The lesson post id.
* @type string $title The lesson title.
* @type bool $draft True if the lesson is a draft.
* }
*/
private function prepare_lesson( WP_Post $lesson_post ): array {
return [
'type' => 'lesson',
'id' => $lesson_post->ID,
'title' => $lesson_post->post_title,
'draft' => 'draft' === $lesson_post->post_status,
'preview' => Sensei_Utils::is_preview_lesson( $lesson_post->ID ),
'initialContent' => get_post_meta( $lesson_post->ID, '_initial_content', true ),
];
}
/**
* Get the lessons for a module.
*
* @param int $module_term_id Term ID for the module.
* @param array|string $lesson_post_status Lesson post status(es).
*
* @return WP_Post[]
*/
private function get_module_lessons( int $module_term_id, $lesson_post_status ): array {
$lessons_query = Sensei()->modules->get_lessons_query( $this->course_id, $module_term_id, $lesson_post_status );
return $lessons_query instanceof WP_Query ? $lessons_query->posts : [];
}
/**
* Get module terms in the correct order.
*
* @return WP_Term[]
*/
private function get_modules(): array {
$modules = Sensei()->modules->get_course_modules( $this->course_id );
if ( is_wp_error( $modules ) ) {
$modules = [];
}
return $modules;
}
/**
* Save a new course structure.
*
* @see Sensei_Course_Structure::get()
*
* @param array $raw_structure Course structure to save in its raw, un-sanitized form as returned by get().
*
* @return bool|WP_Error
*/
public function save( array $raw_structure ) {
$structure = $this->sanitize_structure( $raw_structure );
if ( is_wp_error( $structure ) ) {
return $structure;
}
$current_structure = $this->get( 'edit' );
list( $current_lesson_ids, $current_module_ids, ) = $this->flatten_structure( $current_structure );
$lesson_ids = [];
$module_order = [];
$lesson_order = [];
foreach ( $structure as $item ) {
if ( 'module' === $item['type'] ) {
$save_module_result = $this->save_module( $item );
if ( ! $save_module_result ) {
return false;
}
list( $module_id, $module_lesson_ids ) = $save_module_result;
$lesson_ids = array_merge( $lesson_ids, $module_lesson_ids );
$module_order[] = $module_id;
} elseif ( 'lesson' === $item['type'] ) {
$lesson_id = $this->save_lesson( $item );
if ( ! $lesson_id ) {
return false;
}
$lesson_ids[] = $lesson_id;
$lesson_order[] = $lesson_id;
update_post_meta( $lesson_id, '_order_' . $this->course_id, count( $lesson_order ) );
}
}
// Save the module association.
$module_diff = array_diff( $current_module_ids, $module_order );
if ( ! empty( $module_diff ) || count( $current_module_ids ) !== count( $module_order ) ) {
wp_set_object_terms( $this->course_id, $module_order, 'module' );
}
// Save the module order.
$this->save_module_order( $module_order );
// Save the lesson order.
update_post_meta( $this->course_id, '_lesson_order', implode( ',', $lesson_order ) );
// Delete removed modules and lessons.
$delete_lesson_ids = array_diff( $current_lesson_ids, $lesson_ids );
foreach ( $delete_lesson_ids as $lesson_id ) {
$this->clear_lesson_associations( $lesson_id );
}
return true;
}
/**
* Save a module item.
*
* @see Sensei_Course_Structure::prepare_module()
*
* @param array $item Item to save as returned from prepare_module().
*
* @return false|array[] {
* If successful, we return this:
*
* @type int $0 $module_id Saved module ID.
* @type array $1 $lesson_ids All the lesson IDs from this module.
* }
*/
private function save_module( array $item ) {
if ( $item['id'] || $item['slug'] ) {
$term = get_term( $item['id'], 'module' );
$default_slug = $this->get_module_slug( $item['title'] );
// Custom slug gets priority over conventional slug.
$slug = $item['slug'] ?? $default_slug;
if ( ! $term || $term->slug !== $slug ) {
$existing_module = $this->try_get_existing_module_by_slug_or_title_for_author( $slug, $item['title'] );
if ( $existing_module ) {
$temp_item = $item;
$temp_item['id'] = $existing_module->term_id;
$module_id = $this->update_module( $temp_item );
} else {
$module_id = $this->create_module( $item );
}
} else {
$module_id = $this->update_module( $item );
}
} else {
$module_id = $this->create_module( $item );
}
if ( ! $module_id ) {
return false;
}
$lesson_ids = [];
$lesson_order_key = '_order_module_' . $module_id;
foreach ( $item['lessons'] as $lesson_item ) {
$lesson_id = $this->save_lesson( $lesson_item, $module_id );
if ( ! $lesson_id ) {
return false;
}
wp_set_object_terms( $lesson_id, [ $module_id ], 'module' );
update_post_meta( $lesson_id, $lesson_order_key, count( $lesson_ids ) );
delete_post_meta( $lesson_id, '_order_' . $this->course_id );
$lesson_ids[] = $lesson_id;
}
if ( $item['slug'] ) {
// If a custom slug is defined, remove any unused module generated when changing teacher.
$previous_term = get_term_by( 'slug', $this->get_module_slug( $item['lastTitle'] ), 'module' );
$previous_term && Sensei()->modules->remove_if_unused( $previous_term->term_id );
}
// If the previous id does not match the current id, remove the previous module.
if ( $item['id'] && $item['id'] !== $module_id ) {
wp_remove_object_terms( $this->course_id, $item['id'], 'module' );
Sensei()->modules->remove_if_unused( $item['id'] );
}
Sensei_Core_Modules::update_module_teacher_meta( $module_id, get_post( $this->course_id )->post_author );
return [
$module_id,
$lesson_ids,
];
}
/**
* Attempt to find an existing module available to the user.
*
* @param string $module_name Module name.
*
* @return int|null Term ID if found.
*/
private function get_existing_module( string $module_name ) {
$slug = $this->get_module_slug( $module_name );
$existing_module = get_term_by( 'slug', $slug, 'module' );
if ( $existing_module ) {
return (int) $existing_module->term_id;
}
return null;
}
/**
* Create a module.
*
* @param array $item Item to create.
*
* @return false|int
*/
private function create_module( array $item ) {
$args = [
'description' => $item['description'],
'slug' => $item['slug'] ?? $this->get_module_slug( $item['title'] ),
];
$create_result = wp_insert_term( $item['title'], 'module', $args );
if ( is_wp_error( $create_result ) ) {
return false;
}
return (int) $create_result['term_id'];
}
/**
* Update an existing module.
*
* @param array $item Item to save.
*
* @return false|int
*/
private function update_module( array $item ) {
$term = get_term( $item['id'], 'module' );
$changed_args = [];
if ( $term->name !== $item['title'] ) {
$changed_args['name'] = $item['title'];
}
if ( $term->description !== $item['description'] ) {
$changed_args['description'] = $item['description'];
}
if ( $item['slug'] && $term->slug !== $item['slug'] ) {
$changed_args['slug'] = $item['slug'];
}
if ( ! empty( $changed_args ) ) {
$change_result = wp_update_term(
$item['id'],
'module',
$changed_args
);
if ( is_wp_error( $change_result ) ) {
return false;
}
}
return $term->term_id;
}
/**
* Get module slug.
*
* @param string $title Module title.
*
* @return string Slug.
*/
private function get_module_slug( $title ) {
$teacher_user_id = get_post( $this->course_id )->post_author;
return user_can( $teacher_user_id, 'manage_options' )
? sanitize_title( $title )
: intval( $teacher_user_id ) . '-' . sanitize_title( $title );
}
/**
* Save module order.
*
* @param array $module_order Module order to save.
*/
public function save_module_order( array $module_order ) {
$current_module_order_raw = get_post_meta( $this->course_id, '_module_order', true );
$current_module_order = $current_module_order_raw ? array_map( 'intval', $current_module_order_raw ) : [];
if (
( $current_module_order || ! empty( $module_order ) )
&& ( $current_module_order !== $module_order )
) {
if ( empty( $module_order ) ) {
delete_post_meta( $this->course_id, '_module_order' );
} else {
update_post_meta( $this->course_id, '_module_order', array_map( 'strval', $module_order ) );
}
}
}
/**
* Save a lesson item.
*
* @see Sensei_Course_Structure::prepare_lesson()
*
* @param array $item Item to save.
* @param int $module_id Module ID.
*
* @return false|int
*/
private function save_lesson( array $item, int $module_id = null ) {
if ( $item['id'] ) {
$lesson_id = $this->update_lesson( $item );
} else {
$lesson_id = $this->create_lesson( $item );
}
if ( $lesson_id ) {
if ( ! $module_id ) {
$module_id = [];
}
wp_set_object_terms( $lesson_id, $module_id, 'module' );
}
return $lesson_id;
}
/**
* Get initial content markup.
* This is the content that is shown to the user when they first view the lesson.
*
* @param string $text_content Text content.
*
* @return string Markup or empty.
*/
private function get_lesson_content_markup( $text_content ) {
$markup = '';
if ( $text_content ) {
$markup = '<!-- wp:paragraph -->' . wpautop( $text_content ) . '<!-- /wp:paragraph -->';
}
return $markup;
}
/**
* Create a lesson.
*
* @param array $item Item to create.
*
* @return false|int
*/
private function create_lesson( array $item ) {
$post_args = [
'post_title' => $item['title'],
'post_type' => 'lesson',
'post_status' => 'draft',
'post_content' => $this->get_lesson_content_markup( $item['initialContent'] ?? '' ),
'meta_input' => [
'_lesson_course' => $this->course_id,
'_new_post' => true,
'_initial_content' => $item['initialContent'],
],
];
$lesson_id = wp_insert_post( $post_args );
if ( ! $lesson_id ) {
return false;
}
/**
* Fires after a lesson is created while saving the course structure.
*
* @since 4.20.1
*
* @hook sensei_course_structure_lesson_created
*
* @param {int} $lesson_id Lesson post ID.
* @param {int} $course_id Course post ID.
*/
do_action( 'sensei_course_structure_lesson_created', $lesson_id, $this->course_id );
$this->create_quiz( $lesson_id );
return $lesson_id;
}
/**
* Create an empty quiz for the lesson.
*
* @param int $lesson_id Lesson ID to create quiz for.
*/
private function create_quiz( int $lesson_id ) {
$lesson = get_post( $lesson_id );
$post_args = [
'post_content' => '',
'post_status' => $lesson->post_status,
'post_title' => $lesson->post_title,
'post_type' => 'quiz',
'post_parent' => $lesson_id,
'meta_input' => [
'_quiz_lesson' => $lesson_id,
],
];
$quiz_id = wp_insert_post( $post_args );
if ( ! $quiz_id ) {
return;
}
update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
/**
* Fires after a quiz is created while saving the course structure.
*
* @since 4.20.1
*
* @deprecated 4.22.0 Use sensei_quiz_create instead.
*
* @hook sensei_course_structure_quiz_created
*
* @param {int} $quiz_id Quiz post ID.
* @param {int} $lesson_id Course post ID.
*/
do_action_deprecated( 'sensei_course_structure_quiz_created', array( $quiz_id, $lesson_id ), '4.22.0', 'sensei_quiz_create' );
/**
* Fires after a quiz is created while saving the course structure.
*
* @since 4.22.0
*
* @hook sensei_quiz_create
*
* @param {int} $quiz_id Quiz post ID.
* @param {int} $lesson_id Course post ID.
*/
do_action( 'sensei_quiz_create', $quiz_id, $lesson_id );
}
/**
* Update an existing lesson.
*
* @param array $item Item to save.
*
* @return false|int
*/
private function update_lesson( array $item ) {
$lesson = get_post( $item['id'] );
if ( $lesson->post_title !== $item['title'] ) {
$post_args = [
'ID' => $lesson->ID,
'post_title' => $item['title'],
];
$update_result = wp_update_post( $post_args );
if ( ! $update_result || is_wp_error( $update_result ) ) {
return false;
}
}
$current_course = (int) get_post_meta( $lesson->ID, '_lesson_course', true );
if ( $this->course_id !== $current_course ) {
$this->clear_lesson_associations( $lesson->ID );
update_post_meta( $lesson->ID, '_lesson_course', $this->course_id );
}
return $lesson->ID;
}
/**
* Clear any previous associations a lesson had with a course.
*
* @param int $lesson_id Lesson ID.
*/
private function clear_lesson_associations( int $lesson_id ) {
delete_post_meta( $lesson_id, '_lesson_course' );
$lesson_modules = get_the_terms( $lesson_id, 'module' );
if ( is_array( $lesson_modules ) ) {
foreach ( $lesson_modules as $module ) {
delete_post_meta( $lesson_id, '_order_module_' . $module->term_id );
}
}
wp_set_object_terms( $lesson_id, [], 'module' );
}
/**
* Parses the lesson IDs and module IDs from the structure.
*
* @see Sensei_Course_Structure::get()
*
* @param array $structure Structure to flatten as returned by get().
*
* @return array[] {
* @type array $0 $lesson_ids All the lesson IDs.
* @type array $1 $module_ids All the module IDs.
* @type array $2 $module_titles All the module titles.
* }
*/
private function flatten_structure( array $structure ): array {
$lesson_ids = [];
$module_ids = [];
$module_titles = [];
foreach ( $structure as $item ) {
if ( ! isset( $item['type'] ) ) {
continue;
}
if ( 'module' === $item['type'] ) {
if ( ! empty( $item['id'] ) ) {
$module_ids[] = $item['id'];
}
if ( isset( $item['title'] ) ) {
$module_titles[] = $item['title'];
}
if ( ! empty( $item['lessons'] ) ) {
foreach ( $item['lessons'] as $lesson_item ) {
if ( ! empty( $lesson_item['id'] ) ) {
$lesson_ids[] = $lesson_item['id'];
}
}
}
} elseif ( 'lesson' === $item['type'] && ! empty( $item['id'] ) ) {
$lesson_ids[] = $item['id'];
}
}
return [
$lesson_ids,
$module_ids,
$module_titles,
];
}
/**
* Parse, validate, and sanitize the structure input.
*
* @see Sensei_Course_Structure::get()
*
* @param array $raw_structure Structure array as returned by get().
*
* @return WP_Error|array False if the input is invalid.
*/
private function sanitize_structure( array $raw_structure ) {
list( $lesson_ids, $module_ids, $module_titles ) = $this->flatten_structure( $raw_structure );
$module_titles = array_filter( $module_titles );
if (
array_unique( $module_ids ) !== $module_ids
|| array_unique( $lesson_ids ) !== $lesson_ids
) {
return new WP_Error(
'sensei_course_structure_duplicate_items',
__( 'Individual lesson or modules cannot appear multiple times in the same course.', 'sensei-lms' )
);
}
if ( array_unique( $module_titles ) !== $module_titles ) {
return new WP_Error(
'sensei_course_structure_duplicate_module_title',
__( 'Different modules cannot have the same name.', 'sensei-lms' )
);
}
$structure = [];
foreach ( $raw_structure as $raw_item ) {
if ( ! is_array( $raw_item ) ) {
return new WP_Error(
'sensei_course_structure_invalid_item',
__( 'Each item must be an array.', 'sensei-lms' )
);
}
$item = $this->sanitize_item( $raw_item );
if ( is_wp_error( $item ) ) {
return $item;
}
$structure[] = $item;
}
return $structure;
}
/**
* Validate and sanitize input item of structure.
*
* @see Sensei_Course_Structure::prepare_lesson()
* @see Sensei_Course_Structure::prepare_module()
*
* @param array $raw_item Module or lesson as returned by prepare_lesson or prepare_module.
*
* @return array|WP_Error
*/
private function sanitize_item( array $raw_item ) {
$validate = $this->validate_item_structure( $raw_item );
if ( is_wp_error( $validate ) ) {
return $validate;
}
$item = [
'type' => $raw_item['type'],
'id' => ! empty( $raw_item['id'] ) ? intval( $raw_item['id'] ) : null,
'title' => trim( sanitize_text_field( $raw_item['title'] ) ),
];
if ( 'module' === $raw_item['type'] ) {
if ( $item['id'] ) {
$term = get_term( $item['id'], 'module' );
// Because term may get deleted in some cases during the process of changing the course teacher
// which takes place before saving structure.
if ( ! $term && $raw_item['lastTitle'] ) {
// During the teacher changing process of courses, modules are created or fetched using the
// existing title, not the one that is sent in the course structure save process.
$term = $this->get_existing_module( $raw_item['lastTitle'] );
$item['id'] = $term;
}
if ( ! $term || is_wp_error( $term ) ) {
return new WP_Error(
'sensei_course_structure_missing_module',
// translators: Placeholder is ID for module.
sprintf( __( 'Module with id "%d" was not found', 'sensei-lms' ), $item['id'] )
);
}
} else {
// Attempt to find an existing module available to the user.
$item['id'] = $this->get_existing_module( $item['title'] );
}
$item['description'] = isset( $raw_item['description'] ) ? trim( wp_kses_post( $raw_item['description'] ) ) : null;
$item['slug'] = ! empty( $raw_item['slug'] ) ? trim( sanitize_text_field( $raw_item['slug'] ) ) : null;
$item['lastTitle'] = ! empty( $raw_item['lastTitle'] ) ? trim( sanitize_text_field( $raw_item['lastTitle'] ) ) : null;
$item['lessons'] = [];
if ( $item['slug'] ) {
$course_name = Sensei_Teacher::is_module_in_use_by_different_course_and_teacher( $item['slug'], $this->course_id, absint( $raw_item['teacherId'] ) );
if ( $course_name ) {
return new WP_Error(
'module_in_use_in_different_course_by_different_teacher',
sprintf(
/* translators: Placeholder 1 is the module slug and 2 is course name. */
__( 'Slug %1$s exists and is being used in %2$s course', 'sensei-lms' ),
$item['slug'],
$course_name
)
);
}
if ( Sensei_Core_Modules::get_term_author( $item['slug'] )->ID !== wp_get_current_user()->ID &&
! current_user_can( 'manage_options' ) &&
get_term_by( 'slug', $item['slug'], 'module' )
) {
return new WP_Error(
'module_belongs_to_another_user',
sprintf(
/* translators: Placeholder is the module slug. */
__( 'A module with the slug %s is already owned by another teacher', 'sensei-lms' ),
$item['slug']
)
);
}
}
foreach ( $raw_item['lessons'] as $raw_lesson ) {
$lesson = $this->sanitize_item( $raw_lesson );
if ( is_wp_error( $lesson ) ) {
return $lesson;
}
if ( 'lesson' !== $lesson['type'] ) {
return new WP_Error(
'sensei_course_structure_invalid_module_lesson',
__( 'Module lessons array can only contain lessons.', 'sensei-lms' )
);
}
$item['lessons'][] = $lesson;
}
} elseif ( 'lesson' === $raw_item['type'] ) {
if ( $item['id'] ) {
$lesson = get_post( $item['id'] );
if ( ! $lesson || in_array( $lesson->post_status, [ 'trash', 'auto-draft' ], true ) || 'lesson' !== $lesson->post_type ) {
return new WP_Error(
'sensei_course_structure_missing_lesson',
// translators: Placeholder is ID for lesson.
sprintf( __( 'Lesson with id "%d" was not found', 'sensei-lms' ), $item['id'] )
);
}
}
$item['initialContent'] = ! empty( $raw_item['initialContent'] ) ? trim( wp_kses_post( $raw_item['initialContent'] ) ) : null;
}
return $item;
}
/**
* Validate item is build correctly.
*
* @see Sensei_Course_Structure::prepare_lesson()
* @see Sensei_Course_Structure::prepare_module()
*
* @param array $raw_item Raw item to sanitize.
*
* @return true|WP_Error
*/
private function validate_item_structure( array $raw_item ) {
if ( ! isset( $raw_item['type'] ) || ! in_array( $raw_item['type'], [ 'module', 'lesson' ], true ) ) {
return new WP_Error(
'sensei_course_structure_invalid_item_type',
__( 'All items must have a `type` set.', 'sensei-lms' )
);
}
if ( ! isset( $raw_item['title'] ) || '' === trim( sanitize_text_field( $raw_item['title'] ) ) ) {
if ( 'module' === $raw_item['type'] ) {
return new WP_Error(
'sensei_course_structure_modules_missing_title',
__( 'Please ensure all modules have a name before saving.', 'sensei-lms' )
);
}
return new WP_Error(
'sensei_course_structure_lessons_missing_title',
__( 'Please ensure all lessons have a name before saving.', 'sensei-lms' )
);
}
if (
'module' === $raw_item['type']
&& (
! isset( $raw_item['lessons'] )
|| ! is_array( $raw_item['lessons'] )
)
) {
return new WP_Error(
'sensei_course_structure_missing_lessons',
__( 'Module items must include a `lessons` array.', 'sensei-lms' )
);
}
return true;
}
/**
* Sort structure.
*
* @param array $structure Structure to be sorted.
* @param int[] $order Order to sort the lessons.
* @param string $type Type to be sorted (lesson or module).
*
* @return array Sorted structure.
*/
public static function sort_structure( $structure, $order, $type ) {
if ( ! empty( $order )
&& [ 0 ] !== $order ) {
usort(
$structure,
function ( $a, $b ) use ( $order, $type ) {
// One of the types is not being sorted.
if ( $type !== $a['type'] || $type !== $b['type'] ) {
// If types are equal, keep in the current positions.
if ( $a['type'] === $b['type'] ) {
return 0;
}
// Always keep the modules before the lessons.
return 'module' === $a['type'] ? - 1 : 1;
}
$a_position = array_search( $a['id'], $order, true );
$b_position = array_search( $b['id'], $order, true );
// If both weren't sorted, keep the current positions.
if ( false === $a_position && false === $b_position ) {
return 0;
}
// Keep not sorted items in the end.
if ( false === $a_position ) {
return 1;
}
return false === $b_position || $a_position < $b_position ? - 1 : 1;
}
);
}
return $structure;
}
/**
* Get first incomplete lesson ID or `false` if there is none.
*
* @return int|false
*/
public function get_first_incomplete_lesson_id() {
list( $lesson_ids, ) = $this->flatten_structure( $this->get() );
foreach ( $lesson_ids as $lesson_id ) {
if ( ! Sensei_Utils::user_completed_lesson( $lesson_id ) ) {
return $lesson_id;
}
}
return false;
}
/**
* Try getting an existing module by custom slug, default slug, or name for course teacher.
*
* @since 4.6.0
*
* @param string $slug The slug of the module.
* @param string $title The title of the module.
*
* @return WP_Term|null The module if it exists, null otherwise.
*/
private function try_get_existing_module_by_slug_or_title_for_author( $slug, $title ) {
$default_slug = $this->get_module_slug( $title );
// First try to get an existing module directly by the provided slug.
$existing_module = get_term_by( 'slug', $slug, 'module' );
// If not found, try to find if this teacher already has a module for this, with the default slug.
if ( ! $existing_module && $slug !== $default_slug ) {
$existing_module = get_term_by( 'slug', $default_slug, 'module' );
}
$author = get_post( $this->course_id )->post_author;
// If not found, try to find module for this teacher using term-meta.
if ( ! $existing_module && ! user_can( $author, 'manage_options' ) ) {
$modules = get_terms(
array(
'taxonomy' => 'module',
'name' => $title,
'hide_empty' => false,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Only executed while saving, not too many data possibly.
array(
'key' => 'module_author',
'value' => $author,
'compare' => '=',
),
),
)
);
if ( ! empty( $modules ) ) {
$existing_module = $modules[0];
}
}
return $existing_module;
}
}