gocodebox/lifterlms

View on GitHub
includes/abstracts/abstract.llms.post.model.php

Summary

Maintainability
F
4 days
Test Coverage
B
85%
<?php
/**
 * LLMS_Post_Model abstract class file
 *
 * @package LifterLMS/Abstracts/Classes
 *
 * @since 3.0.0
 * @version 6.5.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Post_Model abstract class.
 *
 * Defines base methods and properties for programmatically interfacing with LifterLMS Custom Post Types.
 *
 * @property      string  $author           ID of post author.
 * @property      string  $content          The post's content.
 * @property      string  $date             The post's local publication time.
 * @property-read string  $db_post_type     Name of the post type as stored in the database
 *                                          This will be prefixed (where applicable)
 *                                          ie: "llms_order" for the "llms_order" post type
 * @property      string  $excerpt          The post's excerpt.
 * @property-read int     $id               Post ID.
 * @property      int     $menu_order       A field used for ordering posts.
 * @property-read string  $meta_prefix      A prefix to add to all meta properties
 *                                          Child classes can redefine this
 * @property-read string  $model_post_type  Define this in extending classes
 *                                          Allows models to use unprefixed post type names for filters and more
 *                                          ie: "order" for the "llms_order" post type
 * @property      string  $modified         The post's local modified time.
 * @property      string  $name             The post's slug.
 * @property      int     $parent           WP_Post ID of the post's parent post.
 * @property-read WP_Post $post             Instance of WP_Post
 * @property      string  $status           The post's status.
 * @property      string  $title            The post's title.
 * @property      string  $type             The post's type, like post or page.
 *
 * @since 3.0.0
 * @since 3.30.0 Improve handling of custom field data to `toArrayCustom()`.
 * @since 3.30.2 Add filter to allow 3rd parties to prevent a field from being added to the custom field array.
 * @since 3.30.3 Use `wp_slash()` when creating new posts.
 * @since 3.31.0 Treat `post_excerpt` fields as HTML instead of plain text.
 * @since 3.34.0 Add parameter to the `get()` method in order to get raw properties.
 * @since 3.34.0 Add `comment_status`, `ping_status`, `date_gmt`, `modified_gmt`, `menu_order`, 'post_password` as gettable\settable post properties.
 * @since 3.34.0 Add `set_bulk()` method that will allow to update an object at once given an array of properties.
 * @since 3.34.0 Refresh the whole $post property with the just updated instance of WP_Post after updating it.
 * @since 3.36.1 In `set_bulk()` method, use WP_Error::$errors in place of WP_Error::has_errors() to support WordPress version prior to 5.1.
 */
abstract class LLMS_Post_Model implements JsonSerializable {

    /**
     * Name of the post type as stored in the database
     * This will be prefixed (where applicable)
     * ie: "llms_order" for the "llms_order" post type
     *
     * @var string
     * @since 3.0.0
     */
    protected $db_post_type;

    /**
     * WP Post ID
     *
     * @var int
     * @since 3.0.0
     */
    protected $id;

    /**
     * Define this in extending classes
     *
     * Allows models to use unprefixed post type names for filters and more
     * ie: "order" for the "llms_order" post type.
     *
     * @var string
     * @since 3.0.0
     */
    protected $model_post_type;

    /**
     * A prefix to add to all meta properties
     *
     * Child classes can redefine this.
     *
     * @var string
     * @since 3.0.0
     */
    protected $meta_prefix = '_llms_';

    /**
     * Instance of WP_Post
     *
     * @var WP_Post
     * @since 3.0.0
     */
    protected $post;

    /**
     * Array of meta properties and their property type
     *
     * @var array
     * @since 3.3.0
     */
    protected $properties = array();

    /**
     * Array of default property values
     *
     * In the form of key => default value.
     *
     * @var array
     * @since 3.24.0
     */
    protected $property_defaults = array();

    /**
     * Constructor
     *
     * Setup ID and related post property.
     *
     * @since 3.0.0
     * @since 3.13.0 Unknown.
     *
     * @param string|int|LLMS_Post_Model|WP_Post $model 'new', WP post id, instance of an extending class, instance of WP_Post.
     * @param array                              $args  Args to create the post, only applies when $model is 'new'.
     * @return void
     */
    public function __construct( $model, $args = array() ) {

        if ( 'new' === $model ) {
            $model = $this->create( $args );
            if ( ! is_wp_error( $model ) ) {
                $created = true;
            }
        } else {
            $created = false;
        }

        if ( empty( $model ) || is_wp_error( $model ) ) {
            return;
        }

        if ( is_numeric( $model ) ) {

            $this->id   = absint( $model );
            $this->post = get_post( $this->id );

        } elseif ( is_subclass_of( $model, 'LLMS_Post_Model' ) ) {

            $this->id   = absint( $model->id );
            $this->post = $model->post;

        } elseif ( $model instanceof WP_Post && isset( $model->ID ) ) {

            $this->id   = absint( $model->ID );
            $this->post = $model;

        }

        if ( $created ) {
            $this->after_create();
        }

    }


    /**
     * Magic Getter
     *
     * @since 3.0.0
     *
     * @param string $key Key to retrieve.
     * @return mixed
     */
    public function __get( $key ) {
        return $this->___get( $key );
    }

    /**
     * Magic Isset
     *
     * @since 3.0.0
     *
     * @param string $key Check if a key exists in the database.
     * @return boolean
     */
    public function __isset( $key ) {
        return metadata_exists( 'post', $this->id, $this->meta_prefix . $key );
    }

    /**
     * Magic Setter
     *
     * @since 3.0.0
     *
     * @param string $key Key of the property.
     * @param mixed  $val Value to set the property with.
     * @return void
     */
    public function __set( $key, $val ) {
        $this->$key = $val;
    }

    /**
     * Allow extending classes to add custom meta properties to the object
     *
     * @since 3.16.0
     *
     * @param array $props Key val array of prop key => prop type (see $this->properties).
     */
    protected function add_properties( $props = array() ) {

        $this->properties = array_merge( $this->properties, $props );

    }

    /**
     * Modify allowed post tags for wp_kses for this post
     *
     * @since 3.19.2
     *
     * @return void
     */
    protected function allowed_post_tags_set() {
        global $allowedposttags;
        $allowedposttags['iframe'] = array(
            'allowfullscreen' => true,
            'frameborder'     => true,
            'height'          => true,
            'src'             => true,
            'width'           => true,
            'style'           => true,
        );
    }

    /**
     * Remove modified allowed post tags for wp_kses for this post
     *
     * @since 3.19.2
     *
     * @return void
     */
    protected function allowed_post_tags_unset() {
        global $allowedposttags;
        unset( $allowedposttags['iframe'] );
    }

    /**
     * Wrapper for $this-get() which allows translation of the database value before outputting on screen
     *
     * Extending classes should define this and translate any possible strings
     * with a switch statement or something.
     * This will return the untranslated string if a translation isn't defined.
     *
     * @since 3.0.0
     *
     * @param string $key Key to retrieve.
     * @return string
     */
    public function translate( $key ) {
        $val = $this->get( $key );
        // ******* example *******
        // switch( $key ) {
        // case 'example_key':
        // if ( 'example-val' === $val ) {
        // return translate( 'Example Key', 'lifterlms' );
        // }
        // break;
        // default:
        // return $val;
        // }
        // ******* example *******
        return $val;
    }

    /**
     * Wrapper for the $this->translate() that echos the result rather than returning it
     *
     * @since 3.0.0
     *
     * @param string $key Key to translate.
     * @return void
     */
    public function _e( $key ) { // phpcs:ignore -- This is to mimic localization functions.
        echo $this->translate( $key );
    }

    /**
     * Called immediately after creating / inserting a new post into the database
     *
     * This stub can be overwritten by child classes.
     *
     * @since 3.0.0
     *
     * @return  void
     */
    protected function after_create() {}

    /**
     * Create a new post of the Instantiated Model
     *
     * This can be called by instantiating an instance with "new"
     * as the value passed to the constructor.
     *
     * @since 3.0.0
     * @since 3.30.3 Use `wp_slash()` for the post title.
     *
     * @param string $title Title to create the post with.
     * @return int WP Post ID of the new Post on success or 0 on error.
     */
    private function create( $title = '' ) {
        return wp_insert_post(
            wp_slash(
                /**
                 * Filters the creation arguments used to create a new post.
                 *
                 * The return array is passed through {@see wp_slash} and ultimately
                 * passed directly to {@see wp_insert_post}.
                 *
                 * The dynamic portion of this hook, `{$this->model_post_type}`, refers to the post
                 * model's `$model_post_type` property.
                 *
                 * @since 3.0.0
                 *
                 * @param array $creation_args An array of arguments passed.
                 */
                apply_filters(
                    "llms_new_{$this->model_post_type}",
                    $this->get_creation_args( $title )
                )
            ),
            true
        );
    }

    /**
     * Clones the Post if the post is cloneable
     *
     * @since 3.3.0
     * @since 4.7.0 Use `LLMS_Generator::get_generated_content()` in favor of deprecated `LLMS_Generator::get_generated_posts()`.
     *
     * @return WP_Error|int|null WP_Error, WP Post ID of the clone (new) post, or null if post is not cloneable.
     */
    public function clone_post() {

        // If post type doesn't support cloning, don't proceed.
        if ( ! $this->is_cloneable() ) {
            return null;
        }

        $this->allowed_post_tags_set();

        $generator = new LLMS_Generator( $this->toArray() );
        $generator->set_generator( 'LifterLMS/Single' . ucwords( $this->model_post_type ) . 'Cloner' );
        if ( ! $generator->is_error() ) {
            $generator->generate();
        }

        $this->allowed_post_tags_unset();

        $generated = $generator->get_generated_content();
        if ( isset( $generated[ $this->db_post_type ] ) ) {
            return $generated[ $this->db_post_type ][0];
        }

        return new WP_Error( 'generator-error', __( 'An unknown error occurred during post cloning. Please try again.', 'lifterlms' ) );

    }

    /**
     * Trigger an export download of the given post type
     *
     * @since 3.3.0
     * @since 3.19.2 Unknown.
     * @since 4.8.0 Made sure extra data are added to the posts model array representation during export.
     *
     * @return void
     */
    public function export() {
        // If post type doesn't support exporting don't proceed.
        if ( ! $this->is_exportable() ) {
            return;
        }

        $title = str_replace( ' ', '-', $this->get( 'title' ) );
        $title = preg_replace( '/[^a-zA-Z0-9-]/', '', $title );

        /**
         * Filters the export file name
         *
         * @since Unknown
         *
         * @param string          $title     The exported file name. Doesn't include the extension.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        $filename = apply_filters( 'llms_post_model_export_filename', $title . '_' . current_time( 'Ymd' ), $this );

        header( 'Content-type: application/json' );
        header( 'Content-Disposition: attachment; filename="' . $filename . '.json"' );
        header( 'Pragma: no-cache' );
        header( 'Expires: 0' );

        $this->allowed_post_tags_set();

        add_filter( 'llms_post_model_to_array_add_extras', '__return_true', 99 );
        $arr = $this->toArray();
        remove_filter( 'llms_post_model_to_array_add_extras', '__return_true', 99 );

        $arr['_generator'] = 'LifterLMS/Single' . ucwords( $this->model_post_type ) . 'Exporter';
        $arr['_source']    = get_site_url();
        $arr['_version']   = llms()->version;

        ksort( $arr );

        echo json_encode( $arr );

        $this->allowed_post_tags_unset();

        die();

    }

    /**
     * Private getter.
     *
     * @since 3.34.0
     * @since 4.10.0 Add `post_name` as a property to skip scrubbing and add a filter on the list of properties to skip scrubbing.
     * @since 5.1.2 Pass second parameter to the `get_the_excerpt` filter hook (the WP_Post object), introduced in WordPress 4.5.0.
     *
     * @param string  $key The property key.
     * @param boolean $raw Optional. Whether or not we need to get the raw value. Default false.
     * @return mixed
     */
    private function ___get( $key, $raw = false ) {

        // Force numeric id and prevent filtering on the id.
        if ( 'id' === $key ) {

            return absint( $this->$key );

        } elseif ( in_array( $key, array_keys( $this->get_post_properties() ) ) ) {
            $post_key = 'post_' . $key;

            // Ensure post is set globally for filters below.
            global $post;
            $temp = $post;
            $post = $this->post;

            switch ( $key ) {

                case 'content':
                    $val = $raw ? $this->post->$post_key : llms_content( $this->post->$post_key );
                    break;

                case 'excerpt':
                    /* This is a WordPress filter. */
                    $val = $raw ? $this->post->$post_key : apply_filters( 'get_the_excerpt', $this->post->$post_key, $this->post );
                    break;

                case 'ping_status':
                case 'comment_status':
                case 'menu_order':
                    $val = $this->post->$key;
                    break;

                case 'title':
                    /* This is a WordPress filter. */
                    $val = $raw ? $this->post->$post_key : apply_filters( 'the_title', $this->post->$post_key, $this->get( 'id' ) );
                    break;

                default:
                    $val = $this->post->$post_key;

            }

            // Return the original global.
            $post = $temp;

        } elseif ( ! in_array( $key, $this->get_unsettable_properties() ) ) {

            if ( metadata_exists( 'post', $this->id, $this->meta_prefix . $key ) ) {
                $val = get_post_meta( $this->id, $this->meta_prefix . $key, true );
            } else {
                $val = $this->get_default_value( $key );
            }
        } else {

            return $this->$key;
        }

        // If we found a value, apply default llms get filter and return the value.
        if ( isset( $val ) ) {

            /**
             * Filters the list of properties which should be excluded from scrubbing during a property read.
             *
             * The dynamic portion of this hook, `{$this->model_post_type}`, refers to the post's model type,
             * for example "course" for an `LLMS_Course`, "membership" for an `LLMS_Membership`, etc...
             *
             * @since 4.10.0
             *
             * @param string[]        $props An array of property keys to be excluded from scrubbing.
             * @param LLMS_Post_Model $this  Instance of the post object.
             */
            $exclude = apply_filters( "llms_get_{$this->model_post_type}_no_scrub_props", array( 'content', 'name' ), $this );
            if ( ! $raw && ! in_array( $key, $exclude, true ) ) {
                $val = $this->scrub( $key, $val );
            }

            /**
             * Filters the property value
             *
             * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
             * "lesson", "membership", etc...
             * The second dynamic part of this hook, `$key`, refers to the property name.
             *
             * @since Unknown
             *
             * @param mixed           $val       The property value.
             * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
             */
            return apply_filters( "llms_get_{$this->model_post_type}_{$key}", $val, $this );

        }

        // Shouldn't ever get here.
        return false;

    }

    /**
     * Getter
     *
     * @since 3.0.0
     *
     * @param string  $key The property key.
     * @param boolean $raw Optional. Whether or not we need to get the raw value. Default is `false`.
     * @return mixed
     */
    public function get( $key, $raw = false ) {

        if ( $raw ) {
            return $this->___get( $key, $raw );
        }

        return $this->$key;

    }

    /**
     * Getter for array values
     *
     * Ensures that even empty values return an array.
     *
     * @since 3.0.0 Unknown.
     *
     * @param string $key Property key.
     * @return array
     */
    public function get_array( $key ) {
        $val = $this->get( $key );
        if ( ! is_array( $val ) ) {
            $val = array( $val );
        }
        return $val;
    }

    /**
     * Getter for date strings with optional date format conversion
     *
     * If no format is supplied, the default format available via $this->get_date_format() will be used.
     *
     * @since 3.0.0
     *
     * @param string $key    Property key.
     * @param string $format Any valid date format that can be passed to date().
     * @return string
     */
    public function get_date( $key, $format = null ) {
        $format = ( ! $format ) ? $this->get_date_format() : $format;
        $raw    = $this->get( $key );
        // Only convert the date if we actually have something stored, otherwise we'll return the current date, which we probably aren't expecting.
        $date = $raw ? date_i18n( $format, strtotime( $raw ) ) : '';

        /**
         * Filters the date(s)
         *
         * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         * The second dynamic part of this hook, `$key`, refers to the date property name.
         *
         * @since 3.0.0
         *
         * @param string          $date      The formatted date.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_{$key}_date", $date, $this );
    }

    /**
     * Retrieve the default date format for the post model
     *
     * This *can* be overridden by child classes if the post type requires a different default date format.
     *
     * If no format is supplied by the child class, the default WP date & time formats available
     * via General Settings will be combined and used.
     *
     * @since 3.0.0
     *
     * @return string
     */
    protected function get_date_format() {
        $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );

        /**
         * Filters the date format
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 3.0.0
         *
         * @param string $format The date format.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_date_format", $format );
    }

    /**
     * Get the default value of a property
     *
     * If defaults don't exist returns an empty string in accordance with the return of get_post_meta() when no metadata exists.
     *
     * @since 3.24.0
     *
     * @param string $key Property key/name.
     * @return mixed
     */
    public function get_default_value( $key ) {
        $defaults = $this->get_property_defaults();
        return isset( $defaults[ $key ] ) ? $defaults[ $key ] : '';
    }

    /**
     * Retrieve URL for an image associated with the post
     *
     * Currently, only retrieves the featured image if the post type supports it.
     * In the future, this will allow retrieval of custom post images as well.
     *
     * @since 3.3.0
     * @since 3.8.0 Unknown.
     *
     * @param string|array $size Registered image size or a numeric array with width/height.
     * @param string       $key  Currently unused but here for forward compatibility if
     *                           additional custom images are added
     * @return string Empty string if no image or not supported.
     */
    public function get_image( $size = 'full', $key = '' ) {
        if ( 'thumbnail' === $key && post_type_supports( $this->db_post_type, 'thumbnail' ) ) {
            $url = get_the_post_thumbnail_url( $this->get( 'id' ), $size );
        } else {
            $id = $this->get( $key );
            if ( is_numeric( $id ) ) {
                $src = wp_get_attachment_image_src( $id, $size );
                if ( $src ) {
                    $url = $src[0];
                }
            }
        }
        return ! empty( $url ) ? $url : '';
    }

    /**
     * Retrieve the Post's post type data object
     *
     * @since 3.0.0
     *
     * @return WP_Post_Type|null
     */
    public function get_post_type_data() {
        return get_post_type_object( $this->get( 'type' ) );
    }

    /**
     * Retrieve a label from the post type data object's labels object
     *
     * @since 3.0.0
     * @since 3.8.0 Unknown.
     *
     * @param string $label Key for the label.
     * @return string
     */
    public function get_post_type_label( $label = 'singular_name' ) {
        $obj = $this->get_post_type_data();
        if ( property_exists( $obj, 'labels' ) && property_exists( $obj->labels, $label ) ) {
            return $obj->labels->$label;
        }
        return '';
    }

    /**
     * Getter for price strings with optional formatting options
     *
     * @since 3.0.0
     * @since 3.7.0 Unknown.
     * @since 4.8.0 Use strict type comparison where possible.
     *
     * @param string $key        Property key.
     * @param array  $price_args Optional. Array of arguments that can be passed to llms_price(). Default is empty array.
     * @param string $format     Optional. Format conversion method [html|raw|float]. Default is 'html'.
     * @return mixed
     */
    public function get_price( $key, $price_args = array(), $format = 'html' ) {

        $price = $this->get( $key );

        // Handle empty or unset values gracefully.
        if ( '' === $price ) {
            $price = 0;
        }

        if ( 'html' === $format || 'raw' === $format ) {
            $price = llms_price( $price, $price_args );
            if ( 'raw' === $format ) {
                $price = strip_tags( $price );
            }
        } elseif ( 'float' === $format ) {
            $price = floatval( number_format( $price, get_lifterlms_decimals(), '.', '' ) );
        } else {
            /**
            * Allows applying custom formatting to price(s).
            *
            * This is only fired when the `get_price()`'s `$format` passed param is not one of html|raw|float.
            *
            * @since Unknown
            *
            * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
            * "lesson", "membership", etc...
            * The second dynamic part of this hook, `$key`, refers to the price property name.
            * The third dynamic part of this hook, `$format`, refers to the custom format conversion method.
            */
            $price = apply_filters( "llms_get_{$this->model_post_type}_{$key}_{$format}", $price, $key, $price_args, $format, $this );
        }

        /**
         * Filters the price(s)
         *
         * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         * The second dynamic part of this hook, `$key`, refers to the price property name.
         *
         * @since Unknown
         *
         * @param string          $price      The maybe formatted price.
         * @param string          $key        The price property name.
         * @param array           $price_args Array of arguments that can be passed to llms_price().
         * @param string          $format     Format conversion method.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_{$key}_price", $price, $key, $price_args, $format, $this );

    }

    /**
     * Retrieve the default values for properties
     *
     * @since 3.24.0
     *
     * @return array
     */
    public function get_property_defaults() {
        /**
         * Filters the defaults properties.
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 3.24.0
         *
         * @param array           $property_defaults Array of default property values.
         * @param LLMS_Post_Model $llms_post         The LLMS_Post_Model instance.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_property_defaults", $this->property_defaults, $this );
    }

    /**
     * An array of default arguments to pass to $this->create() when creating a new post
     *
     * This *should* be overridden by child classes.
     *
     * @since 3.0.0
     * @since 3.18.0 Unknown.
     *
     * @param array $args Args of data to be passed to wp_insert_post.
     * @return array
     */
    protected function get_creation_args( $args = null ) {

        // Allow nothing to be passed in.
        if ( empty( $args ) ) {
            $args = array();
        }

        // Backwards compat to original 3.0.0 format when just a title was passed in.
        if ( is_string( $args ) ) {
            $args = array(
                'post_title' => $args,
            );
        }

        $args = wp_parse_args(
            $args,
            array(
                'comment_status' => 'closed',
                'ping_status'    => 'closed',
                'post_author'    => get_current_user_id(),
                'post_content'   => '',
                'post_excerpt'   => '',
                'post_status'    => 'draft',
                'post_title'     => '',
                'post_type'      => $this->get( 'db_post_type' ),
            )
        );

        /**
         * Filters the llms post creation args
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 3.24.0
         *
         * @param array           $args      Array of default creation args to be passed to `wp_insert_post()`.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        return apply_filters( "llms_{$this->model_post_type}_get_creation_args", $args, $this );
    }

    /**
     * Get media embeds
     *
     * @since 3.17.0
     * @since 3.17.5 Unknown.
     *
     * @param string $type Optional. Embed type [video|audio]. Default is 'video'.
     * @param string $prop Optional. Postmeta property name. Default is empty string.
     *                     If not supplied it will default to {$type}_embed.
     * @return string
     */
    protected function get_embed( $type = 'video', $prop = '' ) {

        $ret = '';

        $prop = $prop ? $prop : $type . '_embed';
        $url  = $this->get( $prop );
        if ( $url ) {

            $ret = wp_oembed_get( $url );

            if ( ! $ret ) {

                $ret = do_shortcode( sprintf( '[%1$s src="%2$s"]', $type, $url ) );

            }
        }
        /**
         * Filters the embed html
         *
         * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         * The second dynamic portion of this hook, `$type`, refers to the embed type [video|audio].
         *
         * @since Unknown
         *
         * @param array           $embed     The embed html.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         * @param string          $type      Embed type [video|audio].
         * @param string          $prop      Postmeta property name.
         */
        return apply_filters( "llms_{$this->model_post_type}_{$type}", $ret, $this, $type, $prop );

    }

    /**
     * Get a property's data type for scrubbing
     *
     * Used by $this->scrub() to determine how to scrub the property.
     *
     * @since 3.3.0
     *
     * @param string $key Property key.
     * @return string
     */
    protected function get_property_type( $key ) {

        $props = $this->get_properties();

        // Check against the properties array.
        if ( in_array( $key, array_keys( $props ) ) ) {
            $type = $props[ $key ];
        } else {
            $type = 'text';
        }

        return $type;

    }

    /**
     * Retrieve an array of post properties
     *
     * These properties need to be get/set with alternate methods.
     *
     * @since 3.0.0
     * @since 3.31.0 Treat excerpts as HTML instead of plain text.
     * @since 3.34.0 Add date and modified dates GMT version, comment and ping status, post password and menu_order.
     *
     * @return array
     */
    protected function get_post_properties() {
        /**
         * Filters the properties of the model that are properties of WP_Post.
         *
         * @since Unknown
         *
         * @param array           $post_properties Associative array of the type $post_property_name => type.
         * @param LLMS_Post_Model $llms_post       The LLMS_Post_Model instance.
         */
        return apply_filters(
            'llms_post_model_get_post_properties',
            array(
                'author'         => 'absint',
                'content'        => 'html',
                'date'           => 'text',
                'date_gmt'       => 'text',
                'excerpt'        => 'html',
                'password'       => 'text',
                'parent'         => 'absint',
                'menu_order'     => 'absint',
                'modified'       => 'text',
                'modified_gmt'   => 'text',
                'name'           => 'text',
                'status'         => 'text',
                'title'          => 'text',
                'type'           => 'text',
                'comment_status' => 'text',
                'ping_status'    => 'text',
            ),
            $this
        );
    }

    /**
     * Retrieve an array of properties defined by the model
     *
     * @since 3.3.0
     * @since 3.16.0 Unknown.
     *
     * @return array
     */
    public function get_properties() {
        $props = array_merge( $this->get_post_properties(), $this->properties );
        /**
         * Filters the llms post properties
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since Unknown
         *
         * @param array           $properties Array of properties defined by the model
         * @param LLMS_Post_Model $llms_post  The LLMS_Post_Model instance.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_properties", $props, $this );
    }

    /**
     * Get the properties that will be used to generate the array representation of the model.
     *
     * @since 5.4.1
     *
     * @return string[] Array of property keys to be used by {@see toArray}.
     */
    protected function get_to_array_properties() {

        $all_props = array_keys( $this->get_properties() );

        /**
         * Filters the properties which will excluded form the array representation of the model
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since Unknown
         *
         * @param string[]        $excluded  Array of property names.
         * @param string[]        $all_props The full property list without the applied exclusions.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        $excluded = apply_filters(
            "llms_get_{$this->model_post_type}_excluded_to_array_properties",
            $this->get_to_array_excluded_properties(),
            $all_props,
            $this
        );

        $props = array_diff(
            $all_props,
            $excluded
        );

        /**
         * Filters the properties which will populate the array representation of the model.
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since Unknown
         *
         * @param string[]        $props     Array of property names.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        return apply_filters(
            "llms_get_{$this->model_post_type}_to_array_properties",
            $props,
            $this
        );

    }

    /**
     * Get the properties that will be explicitly excluded from the array representation of the model.
     *
     * This stub can be overloaded by an extending class and the property list is filterable via the
     * {@see llms_get_{$this->model_post_type}_excluded_to_array_properties} filter.
     *
     * @since 5.4.1
     *
     * @return string[]
     */
    protected function get_to_array_excluded_properties() {
        return array();
    }

    /**
     * Retrieve the registered Label of the post's current status
     *
     * @since 3.0.0
     *
     * @return string
     */
    public function get_status_name() {
        $obj = get_post_status_object( $this->get( 'status' ) );
        /**
         * Filters the registered label of the post's current status.
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 3.0.0
         *
         * @param string $label The registered label of the post's current status.
         */
        return apply_filters( "llms_get_{$this->model_post_type}_status_name", $obj->label );
    }

    /**
     * Get an array of terms for a given taxonomy for the post
     *
     * @since 3.8.0
     *
     * @param string  $tax    Taxonomy name.
     * @param boolean $single Return only one term as an int, useful for taxes which
     *                        Can only have one term (eg: visibilities and difficulties and such)
     * @return mixed When single a single term object or null.
     *               When not single an array of term objects.
     */
    public function get_terms( $tax, $single = false ) {

        $terms = get_the_terms( $this->get( 'id' ), $tax );

        if ( $single ) {
            return $terms ? $terms[0] : null;
        }

        return $terms ? $terms : array();

    }

    /**
     * Array of properties which *cannot* be set
     *
     * If a child class adds any properties which should not be settable
     * the class should override this property and add their custom
     * properties to the array.
     *
     * @since 3.0.0
     *
     * @return array
     */
    protected function get_unsettable_properties() {
        /**
         * Filters the properties of the model that *cannot* be set
         *
         * @since Unknown
         *
         * @param array           $unsettable_properties Array of property names.
         * @param LLMS_Post_Model $llms_post             The LLMS_Post_Model instance.
         */
        return apply_filters(
            'llms_post_model_get_unsettable_properties',
            array(
                'db_post_type',
                'id',
                'meta_prefix',
                'model_post_type',
                'post',
            ),
            $this
        );
    }

    /**
     * Determine if the associated post is exportable
     *
     * @since 3.3.0
     *
     * @return boolean
     */
    public function is_cloneable() {
        return post_type_supports( $this->db_post_type, 'llms-clone-post' );
    }

    /**
     * Determine if the associated post is exportable
     *
     * @since 3.3.0
     *
     * @return boolean
     */
    public function is_exportable() {
        return post_type_supports( $this->db_post_type, 'llms-export-post' );
    }

    /**
     * Format the object for json serialization
     *
     * Encodes the results of $this->toArray().
     *
     * @todo The `mixed` return type declared by the parent method, which should be defined here as well,
     *       is not available until PHP 8.0. Once support is dropped for 7.4 we can add the return type declaration
     *       and remove the `#[ReturnTypeWillChange]` attribute. This *must* happen before the release of PHP 9.0.
     *
     * @since 3.3.0
     *
     * @return array
     */
    #[ReturnTypeWillChange]
    public function jsonSerialize() {
        /**
         * Filters the properties of the model that *cannot* be set
         *
         * @since 3.3.0
         *
         * @param array           $model     Array representation of the LLMS_Post_Model object.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        return apply_filters( 'llms_post_model_json_serialize', $this->toArray(), $this );
    }

    /**
     * Scrub field according to it's type
     *
     * This is automatically called by set() method before anything is actually set.
     *
     * @since 3.0.0
     * @since 3.16.0 Unknown.
     *
     * @param string $key Property key.
     * @param mixed  $val Property value.
     * @return mixed
     */
    protected function scrub( $key, $val ) {
        /**
         * Filters the property type being scrubbed.
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since Unknown
         *
         * @param string          $type      The property type.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         */
        $type = apply_filters( "llms_get_{$this->model_post_type}_property_type", $this->get_property_type( $key ), $this );

        /**
         * Filters the scrubbed property.
         *
         * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         * The second dynamic part of this hook, `$key`, refers to the property name.
         *
         * @since Unknown
         *
         * @param mixed           $scrubbed  The scrubbed property value.
         * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
         * @param string          $key       The property name.
         * @param mixed           $val       The original property value.
         */
        return apply_filters( "llms_scrub_{$this->model_post_type}_field_{$key}", $this->scrub_field( $val, $type ), $this, $key, $val );

    }

    /**
     * Scrub fields according to datatype
     *
     * @since 3.0.0
     * @since 3.19.2 Unknown.
     * @since 5.9.0 Use `wp_strip_all_tags()` in favor of `strip_tags()`.
     *              Only strip tags from string values.
     *              Coerce `null` html input to an empty string.
     *
     * @param mixed  $val  Property value to scrub.
     * @param string $type Data type.
     * @return mixed
     */
    protected function scrub_field( $val, $type ) {

        if ( is_string( $val ) && 'html' !== $type ) {
            $val = wp_strip_all_tags( $val );
        }

        switch ( $type ) {

            case 'absint':
                $val = absint( $val );
                break;

            case 'array':
                if ( '' === $val ) {
                    $val = array();
                }
                $val = (array) $val;
                break;

            case 'bool':
            case 'boolean':
                $val = boolval( $val );
                break;

            case 'float':
                $val = floatval( $val );
                break;

            case 'html':
                $this->allowed_post_tags_set();
                $val = wp_kses_post( $val ?? '' );
                $this->allowed_post_tags_unset();
                break;

            case 'int':
                $val = intval( $val );
                break;

            case 'yesno':
                $val = 'yes' === $val ? 'yes' : 'no';
                break;

            case 'text':
            case 'string':
            default:
                $val = sanitize_text_field( $val );

        }

        return $val;

    }

    /**
     * Setter.
     *
     * @since 3.0.0
     * @since 3.30.3 Use `wp_slash()` when setting properties.
     * @since 3.34.0 Turned to be only a wrapper for the set_bulk() method.
     * @since 6.5.0 Introduced `$allow_same_meta_value` param.
     *
     * @param string|array $key_or_array          Key of the property or an associative array of key/val pairs.
     * @param mixed        $val                   Value to set the property with.
     *                                            This parameter will be ignored when the first parameter is an associative array of key/val pairs.
     * @param boolean      $allow_same_meta_value Whether or not updating a meta with the same value as stored in the db is allowed.
     * @return boolean true on success, false on error or if the submitted value is the same as what's in the database and `$allow_same_meta_value` is `false`.
     */
    public function set( $key_or_array, $val = '', $allow_same_meta_value = false ) {

        $model_array = $key_or_array;

        if ( ! is_array( $key_or_array ) ) {
            $model_array = array(
                $key_or_array => $val,
            );
        }

        return $this->set_bulk( $model_array, false, $allow_same_meta_value );

    }


    /**
     * Bulk setter.
     *
     * @since 3.34.0
     * @since 3.36.1 Use WP_Error::$errors in place of WP_Error::has_errors() to support WordPress version prior to 5.1.
     * @since 5.3.1 Fix quote slashing when the user is not an admin.
     * @since 6.5.0 Introduced `$allow_same_meta_value` param.
     *               Code reorganization.
     *
     * @param array   $model_array           Associative array of key/val pairs.
     * @param array   $wp_error              Whether or not return a WP_Error.
     * @param boolean $allow_same_meta_value Whether or not updating a meta with the same value as stored in the db is allowed.
     * @return boolean|WP_Error True on success. If the param $wp_error is set to false this will be false on error or if there was nothing to update.
     *                          Otherwise, this will be a WP_Error object collecting all the errors encountered along the way.
     */
    public function set_bulk( $model_array, $wp_error = false, $allow_same_meta_value = false ) {

        if ( empty( $model_array ) ) {
            return $wp_error ? new WP_Error( 'empty_data', __( 'Empty data', 'lifterlms' ) ) : false;
        }

        $llms_post = $this->parse_properties_to_set( $model_array );

        if ( empty( $llms_post ) ) {
            return $wp_error ? new WP_Error( 'invalid_data', __( 'Invalid data', 'lifterlms' ) ) : false;
        }

        $update_post_properties = $this->update_post_properties( $llms_post['post'] );
        $update_meta_properties = $this->update_meta_properties( $llms_post['meta'], $allow_same_meta_value );

        $error = is_wp_error( $update_post_properties ) ? $update_post_properties : new WP_Error();
        if ( is_wp_error( $update_meta_properties ) ) {
            foreach ( $update_meta_properties->get_error_messages( 'invalid_meta' ) as $message ) {
                $error->add( 'invalid_meta', $message );
            }
        }

        if ( ! empty( $error->has_errors() ) ) {
            return $wp_error ? $error : false;
        }

        return true;

    }

    /**
     * Parse the LifterLMS post properties to set.
     *
     * Logic moved from `set_bulk()` method.
     *
     * @since 6.5.0
     *
     * @param array $model_array Associative array of key/val pairs.
     * @return array|bool Returns `false` if nothing to set or an array that contains all the post properties and all the metas to set.
     */
    private function parse_properties_to_set( $model_array ) {

        $llms_post = array(
            'post' => array(),
            'meta' => array(),
        );

        $post_properties       = array_keys( $this->get_post_properties() );
        $unsettable_properties = $this->get_unsettable_properties();

        foreach ( $model_array as $key => $val ) {

            // Sanitize the post properties keys by removing the 'post_' prefix.
            if ( 'post_' === substr( $key, 0, 5 ) ) {
                $_key = substr( $key, 5 );
                if ( in_array( $_key, $post_properties, true ) ) {
                    $key = $_key;
                }
            }

            $val = $this->scrub( $key, $val );

            /**
             * WordPress Post properties to be updated using the wp_insert_post() function.
             *
             * The 'edit_date' must be passed to the wp_update_post() function in order
             * to allow 'drafty' posts' creation date to be modified.
             */
            if ( in_array( $key, $post_properties, true ) || 'edit_date' === $key ) {

                $type          = 'post';
                $llms_post_key = "post_{$key}";

                switch ( $key ) {

                    case 'content':
                        /** This is a WordPress core filter. {@see kses_init_filters()}*/
                        $val = stripslashes( apply_filters( 'content_save_pre', addslashes( $val ) ) );
                        break;

                    case 'excerpt':
                        /** This is a WordPress core filter. {@see kses_init_filters()}*/
                        $val = stripslashes( apply_filters( 'excerpt_save_pre', addslashes( $val ) ) );
                        break;

                    case 'edit_date':
                    case 'ping_status':
                    case 'comment_status':
                    case 'menu_order':
                        $llms_post_key = $key;
                        break;

                    case 'title':
                        /** This is a WordPress core filter. {@see kses_init_filters()}*/
                        $val = stripslashes( apply_filters( 'title_save_pre', addslashes( $val ) ) );
                        break;
                }
            } elseif ( ! in_array( $key, $unsettable_properties, true ) ) {
                $type          = 'meta';
                $llms_post_key = $key;
            } else {
                continue;
            }

            /**
             * Filters the property value prior to be set.
             *
             * The first dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
             * "lesson", "membership", etc...
             * The second dynamic part of this hook, `$key`, refers to the property name.
             *
             * @since Unknown
             *
             * @param mixed           $val       The property value.
             * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
             */
            $llms_post[ $type ][ $llms_post_key ] = apply_filters( "llms_set_{$this->model_post_type}_{$key}", $val, $this );

        }

        return empty( $llms_post['post'] ) && empty( $llms_post['meta'] ) ? false : $llms_post;
    }

    /**
     * Update post properties.
     *
     * Logic moved from `set_bulk()` method.
     *
     * @since 6.5.0
     *
     * @param array $post_properties Array of post properties to set.
     * @return void|WP_Error
     */
    private function update_post_properties( $post_properties ) {

        if ( empty( $post_properties ) ) {
            return;
        }

        $args = array_merge(
            $post_properties,
            array(
                'ID' => $this->get( 'id' ),
            )
        );

        $update_post = wp_update_post( wp_slash( $args ), true );

        if ( is_wp_error( $update_post ) ) {
            return $update_post;
        }

        // Update this post.
        $this->post = get_post( $this->get( 'id' ) );

    }


    /**
     * Update post meta properties.
     *
     * Logic moved from `set_bulk()` method.
     *
     * @param array   $post_meta_properties  Array of post meta properties to set.
     * @param boolean $allow_same_meta_value Whether or not updating a meta with the same value as stored in the db is allowed.
     *                                       By default `update_post_meta` doesn't allow that.
     * @return void|WP_Error
     */
    private function update_meta_properties( $post_meta_properties, $allow_same_meta_value ) {

        if ( empty( $post_meta_properties ) ) {
            return;
        }

        $error = new WP_Error();

        foreach ( $post_meta_properties as $key => $val ) {

            if ( $allow_same_meta_value ) {
                /**
                 * Do pretty much(*) the same check for a duplicate value as in `update_metadata()`
                 * to avoid `update_post_meta()` returning false.
                 * {@see WP_REST_Meta_Fields::update_meta_value()}.
                 *
                 * If the new value to be set equals the old one don't update it.
                 *
                 * (*) This is not exactly the same check you can find in `update_metadata()` as that
                 * account for multiple meta values for the same key, while we don't.
                 */
                $old_value = get_post_meta( $this->id, $this->meta_prefix . $key, true );
                if ( $this->is_meta_value_same_as_stored_value( $key, $old_value, $val ) ) {
                    continue;
                }
            }

            $u = update_post_meta( $this->id, $this->meta_prefix . $key, $val );

            if ( ! ( is_numeric( $u ) || true === $u ) ) {
                $error->add( 'invalid_meta', sprintf( __( 'Cannot insert/update the %s meta', 'lifterlms' ), $key ) );
            }
        }

        if ( $error->has_errors() ) {
            return $error;
        }

    }

    /**
     * Checks if the user provided value is equivalent to a stored value for the given meta key.
     *
     * {@see WP_REST_Meta_Fields::is_meta_value_same_as_stored_value()}.
     *
     * @param string $key          The un-prefixed meta key being checked.
     * @param mixed  $stored_value The currently stored value retrieved from get_metadata().
     * @param mixed  $new_value    The new value.
     * @return bool
     */
    private function is_meta_value_same_as_stored_value( $key, $stored_value, $new_value ) {

        $sanitized = sanitize_meta( $this->meta_prefix . $key, $new_value, 'post', $this->db_post_type );

        // The return value of get_metadata will always be a string for scalar types.
        $scalar_types = array(
            'string',
            'text',
            'absint',
            'yesno',
            'html',
            'float',
            'int',
            'bool',
            'boolean',
        );

        if ( in_array( $this->get_property_type( $key ), $scalar_types, true ) ) {
            $sanitized = (string) $sanitized;
        }

        return $sanitized === $stored_value;
    }

    /**
     * Update terms for the post for a given taxonomy
     *
     * @since 3.8.0
     *
     * @param array   $terms  Array of terms (name or ids).
     * @param string  $tax    The name of the tax.
     * @param boolean $append Optional. If true, will append the terms, false will replace existing terms. Default is `false`.
     * @return bool
     */
    public function set_terms( $terms, $tax, $append = false ) {
        $set = wp_set_object_terms( $this->get( 'id' ), $terms, $tax, $append );
        // wp_set_object_terms has 3 options when unsuccessful and only 1 for success
        // an array of terms when successful, let's keep it simple...
        return is_array( $set );
    }

    /**
     * Coverts the object to an associative array
     *
     * Any property returned by $this->get_properties() will be retrieved
     * via $this->get() and added to the array.
     *
     * Extending classes can add additional properties to the array
     * by overriding $this->toArrayAfter().
     *
     * This function is also utilized to serialize the object to JSON.
     *
     * @since 3.3.0
     * @since 3.17.0 Unknown.
     * @since 4.7.0 Add exporting of extra data (images and blocks).
     * @since 4.8.0 Exclude extra data by default. Added `llms_post_model_to_array_add_extras` filter.
     * @since 5.4.1 Load properties to be used to generate the array from the new `get_to_array_properties()` method.
     *
     * @return array
     */
    public function toArray() {

        $arr = array(
            'id' => $this->get( 'id' ),
        );

        foreach ( $this->get_to_array_properties() as $prop ) {

            if ( in_array( $prop, array( 'content', 'excerpt', 'title' ), true ) ) {
                $post_prop    = "post_{$prop}";
                $arr[ $prop ] = $this->post->$post_prop;
            } else {
                $arr[ $prop ] = $this->get( $prop );
            }
        }

        // Add the featured image if the post type supports it.
        if ( post_type_supports( $this->db_post_type, 'thumbnail' ) ) {
            $arr['featured_image'] = $this->get_image( 'full', 'thumbnail' );
        }

        // Expand instructors if instructors are supported.
        if ( ! empty( $arr['instructors'] ) && method_exists( $this, 'instructors' ) ) {

            foreach ( $arr['instructors'] as &$data ) {
                $instructor = llms_get_instructor( $data['id'] );
                if ( $instructor ) {
                    $data = array_merge( $data, $instructor->toArray() );
                }
            }
        } elseif ( ! empty( $arr['author'] ) ) {

            $instructor = llms_get_instructor( $arr['author'] );
            if ( $instructor ) {
                $arr['author'] = $instructor->toArray();
            }
        }

        /**
         * Filter whether or not "extra" content should be included in the post array
         *
         * `__return_true` (with priority 99) is used to force the filter on during exports.
         *
         * @since 4.8.0
         *
         * @param boolean         $include Whether or not to include extra data. Default is `false`, except on during exports.
         * @param LLMS_Post_Model $model   Post model instance.
         */
        $add_array_extra = apply_filters( 'llms_post_model_to_array_add_extras', false, $this );

        /**
         * Filter whether or not "extra" content should be included in the post array
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 4.7.0
         *
         * @param boolean         $include Whether or not to include extra data.
         * @param LLMS_Post_Model $model   Post model instance.
         */
        $add_array_extra = apply_filters( "llms_{$this->model_post_type}_to_array_add_extras", $add_array_extra, $this );

        if ( $add_array_extra ) {
            $arr = $this->to_array_extra( $arr );
        }

        // Add custom fields.
        $arr = $this->toArrayCustom( $arr );

        // Allow extending classes to add properties easily without overriding the class.
        $arr = $this->toArrayAfter( $arr );

        $cpt_data = $this->get_post_type_data();
        if ( $cpt_data->public ) {
            $arr['permalink'] = get_permalink( $this->get( 'id' ) );
        }

        ksort( $arr ); // Because i'm anal...

        /**
         * Filter the final post array created when converting the object to an array
         *
         * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
         * "lesson", "membership", etc...
         *
         * @since 4.7.0
         *
         * @param array           $arr   Associative array of the model.
         * @param LLMS_Post_Model $model Post model instance.
         */
        return apply_filters( "llms_{$this->model_post_type}_to_array", $arr, $this );

    }

    /**
     * Called before data is sorted and returned by $this->toArray()
     *
     * Extending classes should override this data if custom data should
     * be added when object is converted to an array or json.
     *
     * @since 3.3.0
     *
     * @param array $arr Array of data to be serialized.
     * @return array
     */
    protected function toArrayAfter( $arr ) {
        return $arr;
    }

    /**
     * Add "extra" data to the post array during export/serialization
     *
     * This method adds two arrays of data, "blocks" and "images".
     *
     * The "blocks" array is an array of reusable blocks used in the post's content. During
     * an import these blocks will be imported into the site.
     *
     * The "images" array is an array of image element source URLs found in the post's content. During
     * an import these images will be imported into the new site via media sideloading.
     *
     * @since 4.7.0
     *
     * @param array $arr Post array from `toArray()`.
     * @return array[]
     */
    protected function to_array_extra( $arr ) {

        $arr['_extras'] = array(
            'blocks' => empty( $arr['content'] ) ? array() : $this->to_array_extra_blocks( $arr['content'] ),
            'images' => empty( $arr['content'] ) ? array() : $this->to_array_extra_images( $arr['content'] ),
        );

        return $arr;

    }

    /**
     * Add reusable blocks found in the post's content to the post's array
     *
     * @since 4.7.0
     *
     * @param string $content Raw `post_content` string.
     * @return array[] {
     *     Array of reusable block information arrays. The array key is the WP_Post ID of the reusable block.
     *
     *     @type string $title   Reusable block title.
     *     @type string $content Reusable block content.
     * }
     */
    protected function to_array_extra_blocks( $content ) {

        $blocks = array();

        foreach ( parse_blocks( $content ) as $block ) {

            if ( 'core/block' !== $block['blockName'] ) {
                continue;
            }

            $post = get_post( $block['attrs']['ref'] );
            if ( ! $post ) {
                continue;
            }

            $blocks[ $post->ID ] = array(
                'title'   => $post->post_title,
                'content' => $post->post_content,
            );
        }

        return $blocks;

    }

    /**
     * Add images found in the post's content to the post's array
     *
     * @since 4.7.0
     *
     * @param string $content Raw `post_content` string.
     * @return string[] Array of image source URLs.
     */
    protected function to_array_extra_images( $content ) {

        $images = array();
        $dom    = llms_get_dom_document( $content );
        if ( is_wp_error( $dom ) ) {
            return $images;
        }

        $site_url = get_site_url();
        foreach ( $dom->getElementsByTagName( 'img' ) as $img ) {
            $src = $img->getAttribute( 'src' );
            // Only include images stored in this site's media library.
            if ( 0 !== strpos( $src, $site_url ) ) {
                continue;
            }
            $images[] = $src;
        }

        return array_values( array_unique( $images ) );

    }

    /**
     * Called by toArray to add custom fields via get_post_meta()
     *
     * Removes all custom props registered to the $this->properties automatically.
     * Also removes some fields used by the WordPress core that don't hold necessary data.
     * Extending classes may override this class to exclude, extend, or modify the custom fields for a post type.
     *
     * @since 3.16.11
     * @since 3.30.0 Use `maybe_unserialize()` to ensure array data is accessible as an array.
     * @since 3.30.2 Add filter to allow 3rd parties to prevent a field from being added to the custom field array.
     *
     * @param array $arr Existing post array.
     * @return array
     */
    protected function toArrayCustom( $arr ) {

        // Build an array of keys that are registered or can be excluded as a custom field.
        $props = array_keys( $this->get_properties() );
        foreach ( $props as &$prop ) {
            $prop = $this->meta_prefix . $prop;
        }
        $props[] = '_edit_lock';
        $props[] = '_edit_last';

        // Get all meta data.
        $custom = array();
        foreach ( get_post_meta( $this->get( 'id' ) ) as $key => $vals ) {

            // Skip registered fields or fields 3rd parties want to skip.
            /**
             * Filters whether the custom field should be excluded in the array representation of the post model
             *
             * The dynamic portion of this hook, `$this->model_post_type`, refers to the model's post type. For example "course",
             * "lesson", "membership", etc...
             *
             * @since 3.30.2
             *
             * @param boolean         $exclude   Whether the custom field should be excluded. Default is `false`.
             * @param string          $key       The custom field name.
             * @param LLMS_Post_Model $llms_post The LLMS_Post_Model instance.
             */
            if ( in_array( $key, $props, true ) || apply_filters( "llms_{$this->model_post_type}_skip_custom_field", false, $key, $this ) ) {
                continue;
            }

            // Add it.
            $custom[ $key ] = array_map( 'maybe_unserialize', $vals );

        }

        // Add the compiled custom array.
        $arr['custom'] = $custom;

        return $arr;
    }

}