Source: includes/admin/content-duplicators/class-post-duplicator.php

<?php
/**
 * File containing the Post_Duplicator class.
 *
 * @package sensei
 */

namespace Sensei\Admin\Content_Duplicators;

use WP_Post;

/**
 * Class Post_Publicator
 *
 * @since 4.21.0
 */
class Post_Duplicator {
	/**
	 * Duplicate post.
	 *
	 * @param  WP_Post     $post          Post to be duplicated.
	 * @param  string|null $suffix        Suffix for duplicated post title. Default: null.
	 * @param  boolean     $ignore_course Ignore lesson course when dulicating. Default: false.
	 * @return WP_Post|null Duplicate post object.
	 */
	public function duplicate( WP_Post $post, ?string $suffix = null, bool $ignore_course = false ): ?WP_Post {
		$new_post = array();

		$fields = array( 'ID', 'post_status', 'post_date', 'post_date_gmt', 'post_name', 'post_modified', 'post_modified_gmt', 'guid', 'comment_count' );
		foreach ( $post as $field => $value ) {
			if ( ! in_array( $field, $fields, true ) ) {
				$new_post[ $field ] = $value;
			}
		}

		$new_post['post_title']       .= $suffix;
		$new_post['post_date']         = current_time( 'mysql' );
		$new_post['post_date_gmt']     = get_gmt_from_date( $new_post['post_date'] );
		$new_post['post_modified']     = $new_post['post_date'];
		$new_post['post_modified_gmt'] = $new_post['post_date_gmt'];

		switch ( $post->post_type ) {
			case 'course':
				$new_post['post_status'] = 'draft';
				break;
			case 'lesson':
				$new_post['post_status'] = 'draft';
				break;
			case 'quiz':
				$new_post['post_status'] = 'publish';
				break;
			case 'question':
				$new_post['post_status'] = 'publish';
				break;
		}

		// As per wp_update_post() we need to escape the data from the db.
		$new_post = wp_slash( $new_post );

		/**
		 * Filter arguments for `wp_insert_post` when duplicating a Sensei post.
		 * This may be a Course, Lesson, or Quiz.
		 *
		 * @since 3.11.0
		 *
		 * @hook  sensei_duplicate_post_args
		 *
		 * @param {array}   $new_post The arguments for duplicating the post.
		 * @param {WP_Post} $post     The original post being duplicated.
		 * @return {array}  The new arguments to be handed to `wp_insert_post`.
		 */
		$new_post = apply_filters( 'sensei_duplicate_post_args', $new_post, $post );

		$new_post_id = wp_insert_post( $new_post );

		if ( is_wp_error( $new_post_id ) || 0 === $new_post_id ) {
			return null;
		}

		$post_meta = get_post_custom( $post->ID );
		if ( $post_meta ) {
			$ignore_meta_keys = array( '_quiz_lesson', '_quiz_id', '_lesson_quiz', '_lesson_prerequisite' );

			/**
			 * Ignored meta fields when duplicating a post.
			 *
			 * @since 3.7.0
			 *
			 * @hook  sensei_duplicate_post_ignore_meta
			 *
			 * @param {array}   $meta_keys The meta keys to be ignored.
			 * @param {WP_Post} $new_post  The new duplicate post.
			 * @param {WP_Post} $post      The original post that's being duplicated.
			 * @return {array} $meta_keys The meta keys to be ignored.
			 */
			$ignore_meta = apply_filters( 'sensei_duplicate_post_ignore_meta', $ignore_meta_keys, $new_post, $post );

			if ( $ignore_course ) {
				$ignore_meta[] = '_lesson_course';
			}

			foreach ( $post_meta as $key => $meta ) {
				foreach ( $meta as $value ) {
					$value = maybe_unserialize( $value );
					if ( ! in_array( $key, $ignore_meta, true ) ) {
						add_post_meta( $new_post_id, $key, $value );
					}
				}
			}
		}

		add_post_meta( $new_post_id, '_duplicate', $post->ID );

		$taxonomies = get_object_taxonomies( $post->post_type, 'objects' );

		foreach ( $taxonomies as $slug => $tax ) {
			$terms = get_the_terms( $post->ID, $slug );
			if ( is_array( $terms ) && 0 < count( $terms ) ) {
				foreach ( $terms as $term ) {
					wp_set_object_terms( $new_post_id, $term->term_id, $slug, true );
				}
			}
		}

		$new_post = get_post( $new_post_id );
		if ( ! $new_post instanceof \WP_Post ) {
			return null;
		}

		return $new_post;
	}
}