gocodebox/lifterlms

View on GitHub
includes/class-llms-generator-courses.php

Summary

Maintainability
C
1 day
Test Coverage
A
93%
<?php
/**
 * Generate LMS Content from export files or raw arrays of data
 *
 * @package LifterLMS/Classes
 *
 * @since 4.7.0
 * @version 7.1.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Generator_Courses class
 *
 * @since 4.7.0
 */
class LLMS_Generator_Courses extends LLMS_Abstract_Generator_Posts {

    /**
     * Exception code: Raw data missing required data.
     *
     * @var int
     */
    const ERROR_GEN_MISSING_REQUIRED = 2000;

    /**
     * Exception code: Raw data in an invalid format.
     *
     * @var int
     */
    const ERROR_GEN_INVALID_FORMAT = 2001;

    /**
     * Add taxonomy terms to a course
     *
     * @since 3.3.0
     * @since 3.7.5 Unknown.
     * @since 4.7.0 Moved from `LLMS_Generator` and made `protected` instead of `private`.
     *
     * @param obj   $course_id WP_Post ID of a Course.
     * @param array $raw_terms Array of raw term arrays.
     * @return void
     */
    protected function add_course_terms( $course_id, $raw_terms ) {

        $taxes = array(
            'course_cat'        => 'categories',
            'course_difficulty' => 'difficulty',
            'course_tag'        => 'tags',
            'course_track'      => 'tracks',
        );

        foreach ( $taxes as $tax => $key ) {

            if ( ! empty( $raw_terms[ $key ] ) && is_array( $raw_terms[ $key ] ) ) {

                // We can only have one difficulty at a time.
                $append = ( 'difficulty' === $key ) ? false : true;

                $terms = array();

                // Find term id or create it.
                foreach ( $raw_terms[ $key ] as $term_name ) {

                    if ( empty( $term_name ) ) {
                        continue;
                    }

                    $term_id = $this->get_term_id( $term_name, $tax );
                    if ( $term_id ) {
                        $terms[] = $term_id;
                    }
                }

                wp_set_post_terms( $course_id, $terms, $tax, $append );

            }
        }

    }

    /**
     * Generator called when cloning a course
     *
     * @since 4.13.0
     *
     * @param array $raw Raw course data array.
     * @return int|null WP_Post ID of the generated course or `null` on failure.
     */
    public function clone_course( $raw ) {
        return $this->generate_course( $this->setup_raw_for_clone( $raw ) );
    }

    /**
     * Generator called when cloning a lesson
     *
     * @since 3.14.8
     * @since 4.7.0 Moved from `LLMS_Generator` and made `public` instead of `private`.
     * @since 4.13.0 Use `setup_raw_for_clone()` to normalize the
     *
     * @param array $raw Raw data array.
     * @return int|WP_Error WP_Post ID of the created lesson on success and an error object on failure.
     */
    public function clone_lesson( $raw ) {
        return $this->create_lesson( $this->setup_raw_for_clone( $raw ), 0, '', '' );
    }

    /**
     * Generator called for single course imports
     *
     * Converts the single course into a format that can be handled by the bulk courses generator
     * and invokes that generator.
     *
     * @since 3.3.0
     * @since 4.7.0 Moved from `LLMS_Generator` and made `public` instead of `private`.
     *              Returns an int on success.
     * @param array $raw Raw data array.
     * @return int|null WP_Post ID of the generated course or `null` on failure.
     */
    public function generate_course( $raw ) {

        $new_raw = array();

        foreach ( array( '_generator', '_version', '_source' ) as $meta ) {
            if ( isset( $raw[ $meta ] ) ) {
                $new_raw[ $meta ] = $raw[ $meta ];
                unset( $raw[ $meta ] );
            }
        }

        $new_raw['courses'] = array( $raw );
        $courses            = $this->generate_courses( $new_raw );

        return is_array( $courses ) ? $courses[0] : null;

    }

    /**
     * Generator called for bulk course imports
     *
     * @since 3.3.0
     * @since 4.7.0 Moved from `LLMS_Generator` to `LLMS_Abstract_Generator_Courses`.
     *               Updated method access from `private` to `public`.
     *               Throws an exception in favor of returning `null` when an error is encountered.
     *               Returns an array of generated course IDs on success.
     *
     * @param array $raw Raw data array.
     * @return void
     *
     * @throws Exception When invalid `$raw` data is submitted.
     */
    public function generate_courses( $raw ) {

        if ( empty( $raw['courses'] ) ) {
            throw new Exception( __( 'Raw data is missing the required "courses" array.', 'lifterlms' ), self::ERROR_GEN_MISSING_REQUIRED );
        } elseif ( ! is_array( $raw['courses'] ) ) {
            throw new Exception( __( 'The raw "courses" item must be an array.', 'lifterlms' ), self::ERROR_GEN_INVALID_FORMAT );
        }

        $courses = array();

        foreach ( $raw['courses'] as $raw_course ) {
            unset( $raw_course['_generator'], $raw_course['_version'] );
            $courses[] = $this->create_course( $raw_course );
        }

        $this->handle_prerequisites();

        return $courses;

    }

    /**
     * Create a new access plan
     *
     * @since 3.3.0
     * @since 3.7.3 Unknown.
     * @since 4.3.3 Use an empty string in favor of `null` for an empty `post_content` field.
     * @since 4.7.0 Sideload images attached to the post, use `create_post()` from abstract, add hooks.
     *
     * @param array $raw                Raw Access Plan Data.
     * @param int   $course_id          WP Post ID of a LLMS Course to assign the access plan to.
     * @param int   $fallback_author_id Optional. WP User ID to use for the access plan author if no author is supplied in the raw data. Default is `null`.
     *                                  When not supplied the fall back will be on the current user ID.
     * @return int
     */
    protected function create_access_plan( $raw, $course_id, $fallback_author_id = null ) {

        /**
         * Filter raw course import data prior to generation
         *
         * @since 4.7.0
         *
         * @param array          $raw       Raw course data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_access_plan', $raw, $this );

        // Force course relationship.
        $raw['product_id'] = $course_id;

        $plan = $this->create_post( 'access_plan', $raw, $fallback_author_id );
        if ( ! $plan ) {
            return null;
        }

        /**
         * Action triggered immediately following generation of a new acess plan
         *
         * @since 4.7.0
         *
         * @param LLMS_Access_Plan $plan      Generated access plan object.
         * @param array            $raw       Original raw course data array.
         * @param LLMS_Generator   $generator Generator instance.
         */
        do_action( 'llms_generator_new_access_plan', $plan, $raw, $this );

        return $plan->get( 'id' );

    }

    /**
     * Create a new course
     *
     * @since 3.3.0
     * @since 3.30.2 Added hooks.
     * @since 4.3.3 Use an empty string in favor of `null` for empty `post_content` and `post_excerpt` fields.
     * @since 4.7.0 Import images and reusable blocks found in the post's content and use `create_post()` from abstract.
     *
     * @param array $raw Raw course data.
     * @return int
     *
     * @throws Exception When an error is encountered during course creation.
     */
    protected function create_course( $raw ) {

        /**
         * Filter raw course import data prior to generation
         *
         * @since 3.30.2
         *
         * @param array          $raw       Raw course data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_course', $raw, $this );

        // Create the course.
        $course = $this->create_post( 'course', $raw, get_current_user_id() );

        // Add terms to our course.
        $terms = array();
        if ( isset( $raw['difficulty'] ) ) {
            $terms['difficulty'] = array( $raw['difficulty'] );
        }
        foreach ( array( 'categories', 'tags', 'tracks' ) as $tax ) {
            if ( isset( $raw[ $tax ] ) ) {
                $terms[ $tax ] = $raw[ $tax ];
            }
        }
        $this->add_course_terms( $course->get( 'id' ), $terms );

        // Create all access plans.
        if ( isset( $raw['access_plans'] ) ) {
            foreach ( $raw['access_plans'] as $plan ) {
                $this->create_access_plan( $plan, $course->get( 'id' ), $course->get( 'author' ) );
            }
        }

        // Create all sections.
        if ( isset( $raw['sections'] ) ) {
            foreach ( $raw['sections'] as $order => $section ) {
                $this->create_section( $section, ++$order, $course->get( 'id' ), $course->get( 'author' ) );
            }
        }

        /**
         * Action triggered immediately following generation of a new course
         *
         * @since 3.30.2
         *
         * @param LLMS_Course    $course    Generated course object.
         * @param array          $raw       Original raw course data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        do_action( 'llms_generator_new_course', $course, $raw, $this );

        return $course->get( 'id' );

    }

    /**
     * Create a new lesson
     *
     * @since 3.3.0
     * @since 3.30.2 Added hooks.
     * @since 4.3.3 Use an empty string in favor of `null` for empty `post_content` and `post_excerpt` fields.
     * @since 4.7.0 Import images and reusable blocks found in the post's content and use `create_post()` from abstract.
     *
     * @param array $raw                Raw lesson data.
     * @param int   $order              Lesson order within the section (starts at 1).
     * @param int   $section_id         WP Post ID of the lesson's parent section.
     * @param int   $course_id          WP Post ID of the lesson's parent course.
     * @param int   $fallback_author_id Optional. Author ID to use as a fallback if no raw author data supplied for the lesson. Default is `null`.
     *                                  When not supplied the fall back will be on the current user ID.
     * @return int
     *
     * @throws Exception When an error is encountered during post creation.
     */
    protected function create_lesson( $raw, $order, $section_id, $course_id, $fallback_author_id = null ) {

        /**
         * Filter raw lesson import data prior to generation
         *
         * @since 3.30.2
         *
         * @param array          $raw                Raw lesson data array.
         * @param int            $order              Lesson order within the section (starts at 1).
         * @param int            $section_id         WP Post ID of the lesson's parent section.
         * @param int            $course_id          WP Post ID of the lesson's parent course.
         * @param int            $fallback_author_id Optional author ID to use as a fallback if no raw author data supplied for the lesson.
         * @param LLMS_Generator $generator          Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_lesson', $raw, $order, $section_id, $course_id, $fallback_author_id, $this );

        // Force some data.
        $raw['parent_course']  = $course_id;
        $raw['parent_section'] = $section_id;
        $raw['order']          = $order;

        $raw_quiz = ! empty( $raw['quiz'] ) ? $raw['quiz'] : false;
        unset( $raw['quiz'] );

        $lesson = $this->create_post( 'lesson', $raw, $fallback_author_id );

        if ( $raw_quiz ) {
            $raw_quiz['lesson_id'] = $lesson->get( 'id' );
            $lesson->set( 'quiz', $this->create_quiz( $raw_quiz, $lesson->get( 'author' ) ) );
        }

        /**
         * Action triggered immediately following generation of a new lesson
         *
         * @since 3.30.2
         *
         * @param LLMS_Lesson    $lesson    Generated lesson object.
         * @param array          $raw       Original raw lesson data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        do_action( 'llms_generator_new_lesson', $lesson, $raw, $this );

        return $lesson->get( 'id' );

    }

    /**
     * Creates a new quiz
     * Creates all questions within the quiz as well
     *
     * @since 3.3.0
     * @since 3.30.2 Added hooks.
     * @since 4.3.3 Use an empty string in favor of `null` for an empty `post_content` field.
     * @since 4.7.0 Sideload images attached to the post  and use `create_post()` from abstract.
     *
     * @param array $raw                Raw quiz data.
     * @param int   $fallback_author_id Optional. Author ID to use as a fallback if no raw author data supplied for the quiz. Default is `null`.
     *                                  When not supplied the fall back will be on the current user ID.
     * @return int
     *
     * @throws Exception When an error is encountered during post creation.
     */
    protected function create_quiz( $raw, $fallback_author_id = null ) {

        /**
         * Filter raw quiz import data prior to generation
         *
         * @since 3.30.2
         *
         * @param array          $raw                Raw quiz data array.
         * @param int            $fallback_author_id Optional author ID to use as a fallback if no raw author data supplied for the quiz.
         * @param LLMS_Generator $generator          Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_quiz', $raw, $fallback_author_id, $this );

        $quiz = $this->create_post( 'quiz', $raw, $fallback_author_id );

        if ( isset( $raw['questions'] ) ) {
            $manager = $quiz->questions();
            foreach ( $raw['questions'] as $question ) {
                $this->create_question( $question, $manager, $quiz->get( 'author' ) );
            }
        }

        /**
         * Action triggered immediately following generation of a new quiz
         *
         * @since 3.30.2
         *
         * @param LLMS_Quiz      $quiz      Generated quiz object.
         * @param array          $raw       Original raw quiz data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        do_action( 'llms_generator_new_quiz', $quiz, $raw, $this );

        return $quiz->get( 'id' );

    }

    /**
     * Creates a new question
     *
     * @since 3.3.0
     * @since 3.30.2 Added hooks.
     * @since 4.7.0 Attempt to sideload images found in the imported post's content and image choices.
     *
     * @param array $raw       Raw question data.
     * @param obj   $manager   Question manager instance.
     * @param int   $author_id Optional. Author ID to use as a fallback if no raw author data supplied for the question. Default is `null`.
     *                         When not supplied the fall back will be on the current user ID.
     * @return int
     *
     * @throws Exception When an error is encountered during course creation.
     */
    protected function create_question( $raw, $manager, $author_id ) {

        /**
         * Filter raw question import data prior to generation
         *
         * @since 3.30.2
         *
         * @param array          $raw       Raw quiz data array.
         * @param obj            $manager   Question manager instance.
         * @param int            $author_id Optional author ID to use as a fallback if no raw author data supplied for the question.
         * @param LLMS_Generator $generator Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_question', $raw, $manager, $author_id, $this );

        unset( $raw['parent_id'] );

        $question_id = $manager->create_question(
            array_merge(
                array(
                    'post_status' => 'publish',
                    'post_author' => $author_id,
                ),
                $raw
            )
        );

        if ( ! $question_id ) {
            throw new Exception( __( 'Error creating the question post object.', 'lifterlms' ), self::ERROR_CREATE_POST );
        }

        $question = llms_get_post( $question_id );

        $this->store_temp_id( $raw, $question );

        if ( isset( $raw['choices'] ) ) {
            foreach ( $raw['choices'] as $choice ) {
                unset( $choice['question_id'] );
                $question->create_choice( $this->maybe_sideload_choice_image( $choice, $question_id ) );
            }
        }

        // Set all metadata.
        foreach ( array_keys( $question->get_properties() ) as $key ) {
            if ( isset( $raw[ $key ] ) ) {
                $question->set( $key, $raw[ $key ] );
            }
        }

        $this->sideload_images( $question, $raw );

        /**
         * Action triggered immediately following generation of a new question
         *
         * @since 3.30.2
         *
         * @param LLMS_Question  $question  Generated question object.
         * @param array          $raw       Original raw question data array.
         * @param obj            $manager   Question manager instance.
         * @param LLMS_Generator $generator Generator instance.
         */
        do_action( 'llms_generator_new_question', $question, $raw, $manager, $this );

        return $question->get( 'id' );

    }

    /**
     * Creates a new section
     *
     * Creates all lessons within the section data.
     *
     * @since 3.3.0
     * @since 3.30.2 Added hooks.
     * @since 4.7.0 Use `create_post()` from abstract.
     *
     * @param array $raw                Raw section data.
     * @param int   $order              Order within the course (starts at 1).
     * @param int   $course_id          WP Post ID of the parent course.
     * @param int   $fallback_author_id Optional. Author ID to use as a fallback if no raw author data supplied for the section.
     *                                  When not supplied the fall back will be on the current user ID.
     * @return int
     *
     * @throws Exception When an error is encountered during course creation.
     */
    protected function create_section( $raw, $order, $course_id, $fallback_author_id = null ) {

        /**
         * Filter raw section import data prior to generation
         *
         * @since 3.30.2
         *
         * @param array          $raw                Raw quiz data array.
         * @param int            $order              Order within the course (starts at 1).
         * @param int            $course_id          WP Post ID of the parent course.
         * @param int            $fallback_author_id Optional author ID to use as a fallback if no raw author data supplied for the section.
         * @param LLMS_Generator $generator          Generator instance.
         */
        $raw = apply_filters( 'llms_generator_before_new_section', $raw, $order, $course_id, $fallback_author_id, $this );

        $raw['parent_course'] = $course_id;
        $raw['order']         = $order;

        $section = $this->create_post( 'section', $raw, $fallback_author_id );

        if ( isset( $raw['lessons'] ) ) {
            foreach ( $raw['lessons'] as $lesson_order => $lesson ) {
                $this->create_lesson( $lesson, ++$lesson_order, $section->get( 'id' ), $course_id, $section->get( 'author' ) );
            }
        }

        /**
         * Action triggered immediately following generation of a new section
         *
         * @since 3.30.2
         *
         * @param LLMS_Section   $section   Generated section object.
         * @param array          $raw       Original raw section data array.
         * @param LLMS_Generator $generator Generator instance.
         */
        do_action( 'llms_generator_new_section', $section, $raw, $this );

        return $section->get( 'id' );

    }

    /**
     * Updates course and lesson prerequisites
     *
     * If the prerequisite was included in the import, updates to the new imported version.
     *
     * If the prereq is not included but the source matches, leaves the prereq intact as long as the prereq exists.
     *
     * Otherwise removes prerequisite data from the new course / lesson.
     *
     * Removes prereq track associations if there's no source or source doesn't match
     * or if the track doesn't exist.
     *
     * @since 3.3.0
     * @since 3.24.0 Unknown.
     *
     * @return void
     */
    protected function handle_prerequisites() {

        foreach ( array( 'course', 'lesson' ) as $obj_type ) {

            $ids = ! empty( $this->tempids[ $obj_type ] ) ? $this->tempids[ $obj_type ] : array();

            // Courses have two kinds of prereqs.
            $has_prereq_param = ( 'course' === $obj_type ) ? 'course' : null;

            // Loop through all then created lessons.
            foreach ( $ids as $old_id => $new_id ) {

                // Instantiate the new instance of the object.
                $obj = llms_get_post( $new_id );

                // If this is a course and there isn't a source or the source doesn't match the current site.
                // We should remove the track prerequisites.
                if ( 'course' === $obj_type && ( ! isset( $raw['_source'] ) || get_site_url() !== $raw['_source'] ) ) {

                    // Remove prereq track settings.
                    if ( $obj->has_prerequisite( 'course_track' ) ) {
                        $obj->set( 'prerequisite_track', 0 );
                        if ( ! $obj->has_prerequisite( 'course' ) ) {
                            $obj->set( 'has_prerequisite', 'no' );
                        }
                    }
                }

                // If the object has a prereq.
                if ( $obj->has_prerequisite( $has_prereq_param ) ) {

                    // Get the old preqeq's id.
                    $old_prereq = $obj->get( 'prerequisite' );

                    // If the old prereq is a key in the array of created objects.
                    // We can replace it with the new id.
                    if ( in_array( $old_prereq, array_keys( $ids ) ) ) {

                        $obj->set( 'prerequisite', $ids[ $old_prereq ] );

                    } elseif ( ! isset( $raw['_source'] ) || get_site_url() !== $raw['_source'] ) {

                        $obj->set( 'has_prerequisite', 'no' );
                        $obj->set( 'prerequisite', 0 );

                    } else {
                        $post = get_post( $old_prereq );
                        // Post doesn't exist or the post type doesn't match, get rid of it.
                        if ( ! $post || $obj_type !== $post->post_type ) {

                            $obj->set( 'has_prerequisite', 'no' );
                            $obj->set( 'prerequisite', 0 );

                        }
                    }
                }
            }
        }

    }

    /**
     * Determines if a raw question choice object contains image data that should be sideloaded
     *
     * @since 4.7.0
     *
     * @param array $choice      Raw choice data array.
     * @param int   $question_id WP_Post ID of the parent question.
     * @return array Choice data array.
     */
    protected function maybe_sideload_choice_image( $choice, $question_id ) {

        if ( empty( $choice['choice_type'] ) || 'image' !== $choice['choice_type'] || ! $this->is_image_sideloading_enabled() ) {
            return $choice;
        }

        $id = $this->sideload_image( $question_id, $choice['choice']['src'], 'id' );
        if ( is_wp_error( $id ) ) {
            return $choice;
        }

        $choice['choice']['id']  = $id;
        $choice['choice']['src'] = wp_get_attachment_url( $id );

        return $choice;

    }


    /**
     * Modifies incoming raw data when creating a clone of a course or lesson
     *
     * When a clone is created, it will automatically have "(Clone)" appended to the existing title
     * and will be created with the "Draft" status.
     *
     * @since 4.13.0
     *
     * @param array $raw Raw data array for the course or lesson.
     * @return array
     */
    protected function setup_raw_for_clone( $raw ) {

        /**
         * Filters the suffix appended to the WP_Post title of a duplicated post when cloning a course or lesson
         *
         * @since 4.13.0
         *
         * @param string         $status    The WP_Post status to use for the duplicate of the post. Default: "draft".
         * @param array          $raw       Raw data array passed into the generator.
         * @param LLMS_Generator $generator Generator instance.
         */
        $raw['title'] .= apply_filters( 'llms_generator_cloned_post_title_suffix', sprintf( ' (%s)', __( 'Clone', 'lifterlms' ) ), $raw, $this );

        /**
         * Filters the WP_Post status used for the duplicated post when cloning a course or lesson
         *
         * @since 4.13.0
         *
         * @param string         $status    The WP_Post status to use for the duplicate of the post. Default: "draft".
         * @param array          $raw       Raw data array passed into the generator.
         * @param LLMS_Generator $generator Generator instance.
         */
        $raw['status'] = apply_filters( 'llms_generator_cloned_post_status', 'draft', $raw, $this );

        return $raw;

    }

    /**
     * Set all metadata for a given post object.
     *
     * This method will only set metadata for registered LLMS_Post_Model properties.
     *
     * @since 7.1.0
     *
     * @param LLMS_Post_Model $post An LLMS post object.
     * @param array           $raw  Array of raw data.
     * @return void
     */
    protected function set_metadata( $post, $raw ) {

        $generated_from_id = $post->get( 'generated_from_id' );

        if ( $generated_from_id ) {
            $replace_id_props = array(
                'course_closed_message',
                'course_opens_message',
                'enrollment_closed_message',
                'enrollment_opens_message',
            );

            $find    = '#(.*id=["\'])' . $generated_from_id . '(["\'].*)#';
            $replace = '${1}' . $post->get( 'id' ) . '${2}';

            /**
             * Replace old post ID with new cloned post ID in course/enrollment
             * message shortcodes.
             */
            foreach ( $replace_id_props as $key ) {
                if ( isset( $raw[ $key ] ) ) {
                    $raw[ $key ] = preg_replace( $find, $replace, $raw[ $key ] );
                }
            }
        }

        return parent::set_metadata( $post, $raw );

    }

}