HEX
Server: LiteSpeed
System: Linux server315.web-hosting.com 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
User: globfdxw (6114)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: /home/globfdxw/public_html/wp-content/plugins/mediavine-create/class-plugin.php
<?php
namespace Mediavine\Create;

use Mediavine\MV_DBI;
use Mediavine\Create\Settings\Advanced;
use Mediavine\Create\Settings\Affiliates;
use Mediavine\Create\Settings\Control_Panel;
use Mediavine\Create\Settings\Dev;
use Mediavine\Create\Settings\Appearance;
use Mediavine\Create\Settings\List_Ads;
use Mediavine\Create\Settings\Lists;
use Mediavine\Create\Settings\Reader_Experience;
use Mediavine\Create\Settings\Recipes;
use Mediavine\Settings;
use Mediavine\Create\Importers\Importers;

/**
 * Plugin bootstrap class
 */
class Plugin {
	const VERSION = '2.4.3';

	const DB_VERSION = '2.4.1';

	const TEXT_DOMAIN = 'mediavine';

	const PLUGIN_DOMAIN = 'mv_create';

	const PREFIX = '_mv_';

	const PLUGIN_FILE_PATH = __FILE__;

	const PLUGIN_ACTIVATION_FILE = 'mediavine-create.php';

	const REQUIRED_IMPORTER_VERSION = '0.10.3';

	public $api_route = 'mv-create';

	public $api_version = 'v1';

	public static $services_api_url = 'https://create.studio/api/v2';
	
	// Important: keep the trailing slash
	public static $js_services_api_url = 'https://create.studio/api/v2/';

	public static $create_studio_base_url = 'https://create.studio';

	public static $db_interface = null;

	public static $views = null;

	public static $api_services = null;

	public static $models = null;

	public static $models_v2 = null;

	public static $custom_content = null;

	public static $settings = null;

	public static $settings_group = 'mv_create';

	public static $shapes = null;

	public static $mcp_enabled = false;

	public static $create_settings_slugs = [
		'mv_create_affiliate_message',
		'mv_create_copyright_attribution',
	];

	public $rest_response = null;

	private static $instance = null;

	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
			self::$instance->init();
		}

		return self::$instance;
	}

	public static function assets_url() {
			return MV_CREATE_URL;
	}

	/**
	 * Get MCP site id if it exists
	 *
	 * @return  string|false  Site id if it exists and MCP is active, or false
	 */
	public static function get_mcp_data() {
		$mcp_data = false;
		if ( self::$mcp_enabled ) {
			// TODO: Check if video support exists and is authorized with identity service
			$mcp_data = [
				'site_id' => get_option( 'MVCP_site_id' ),
				'version' => get_option( 'mv_mcp_version', null ),
			];
		}

		return $mcp_data;
	}

	/**
	 * Return image size value and label
	 *
	 * @return array
	 */
	public static function get_image_size_values() {
		$image_sizes                   = Creations::get_image_sizes();
		$image_sizes_values_and_labels = [];

		// @TODO add a filter for adding size names to exclude from disable list?
		$exclude = [
			'mv_create_no_ratio',
			'mv_create_vert',
		];

		foreach ( $image_sizes as $size => $size_data ) {
			if ( strpos( $size, '_high_res' ) || in_array( $size, $exclude, true ) ) {
				continue;
			}

			$image_sizes_values_and_labels[ $size ] = $size_data['name'];
		}

		return $image_sizes_values_and_labels;
	}

	/**
	 * Modify setting array to include custom post types
	 *
	 * @param array $settings array of current settings to be modified with addition of custom post types
	 *
	 * @return array
	 */
	public function set_custom_post_type_option_value( $settings ) {
		$allowed_post_types = $this->get_custom_post_types();

		$cpt_field = array_search( self::$settings_group . '_allowed_cpt_types', wp_list_pluck( $settings, 'slug' ), true );

		if ( empty( $allowed_post_types ) ) {
			$settings[ $cpt_field ]->value = []; // reset value
			return $settings;
		}

		if ( ! empty( $settings[ $cpt_field ] ) ) {
			// two things need to happen:
			// 1. CPTs that no longer exist need to be removed as a saved value
			// 2. New CPTs need to be added to options but NOT to saved values — this should already be handled by $this->get_custom_post_types()

			$values        = array_keys( $allowed_post_types );
			$stored_values = json_decode($settings[ $cpt_field ]->value ?: '{}');

			if ( ! is_array( $stored_values ) ) {
				return $settings;
			}

			$keys_to_remove = array_diff( $stored_values, $values ); // CPTs that were removed

			// remove invalid CPTs from saved values
			$new_values = array_filter(
				$stored_values, function ( $i ) use ( $keys_to_remove ) {
				return ! in_array( $i, $keys_to_remove, true );
				}
			);

			$settings[ $cpt_field ]->data['options'] = $allowed_post_types;
			$settings[ $cpt_field ]->value           = json_encode( array_values( $new_values ) );
		}

		return $settings;
	}

	public static function get_activation_path() {
		return MV_CREATE_DIR . '/' . self::PLUGIN_ACTIVATION_FILE;
	}

	public function load_models() {
		$models_loader = new \stdClass();

		return $models_loader;
	}

	/**
	 * Runs hook at plugin activation.
	 *
	 * The update hook will run a bit later through its own hook
	 *
	 * @return void
	 */
	public function plugin_activation() {
		do_action( self::PLUGIN_DOMAIN . '_plugin_activated' );
	}

	/**
	 * Runs hook at plugin update.
	 *
	 * This runs after all plugins are loaded so it can run after update. It also performs a
	 * check based on version number, just in case someone updates in a non-conventional way.
	 * After completing hooks, Create version number is updated in the db.
	 *
	 * @return void
	 */
	public function plugin_update_check() {
		if ( get_option( 'mv_create_version' ) === self::VERSION ) {
			return;
		}

		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		/**
		 * Runs just before the plugin saves its new version to the database.
		 *
		 * @param $last_plugin_version The last version the plugin was on. If this is a new
		 *              install, the last plugin version will be the current version.
		 */
		do_action( self::PLUGIN_DOMAIN . '_plugin_updated', $last_plugin_version );

		update_option( 'mv_create_version', self::VERSION );
		flush_rewrite_rules();
	}

	/**
	 * Runs hook at plugin deactivation and flushes rewrite rules.
	 *
	 * @return void
	 */
	public function plugin_deactivation() {
		do_action( self::PLUGIN_DOMAIN . '_plugin_deactivated' );
		flush_rewrite_rules();
	}

	public function generate_tables() {
		\Mediavine\MV_DBI::upgrade_database_check( self::PLUGIN_DOMAIN, self::DB_VERSION );
	}

	/**
	 * Determine whether Mediavine Control Panel is enabled.
	 */
	public function set_mcp_status() {
		if (
			(
				Plugin_Checker::is_mcp_active()
			) && get_option( 'MVCP_site_id' )
		) {
			self::$mcp_enabled = true;
		}
	}

	/**
	 * Bootstrap the plugin.
	 */
	public function init() {
		self::$models = new \stdClass();

		$this->set_mcp_status();

		// initialize Admin_Notices
		\Mediavine\Create\Admin_Notices::get_instance();

		// Initialize Welcome Notice (handles 2.0 upgrade welcome screen)
		\Mediavine\Create\Welcome_Notice::get_instance();

		// Initialize Broadcast Notice (shows Studio broadcast banners in WP admin)
		\Mediavine\Create\Broadcast_Notice::get_instance();

		// Initialize Admin Bar (adds quick edit links for Create cards)
		\Mediavine\Create\Admin_Bar::get_instance();

		$dev_mode = json_decode( get_option( 'mediavine_devmode', '[]' ), true );
		if ( isset( $dev_mode['create'] ) && $dev_mode['create'] === 'on' ) {
			self::$services_api_url = 'https://cs.test/api/v2';
			// Important: keep the trailing slash
			self::$js_services_api_url = 'https://cs.test/api/v2/';
			self::$create_studio_base_url = 'https://cs.test';
		}

		self::$views        = \Mediavine\View_Loader::get_instance( MV_CREATE_DIR );
		self::$api_services = \Mediavine\Create\API_Services::get_instance();
		\Mediavine\Cache_Manager::init();
		\Mediavine\Create\REST_Redirect_Guard::init();
		self::$models_v2    = \Mediavine\MV_DBI::get_models(
			[
				'mv_images',
				'mv_nutrition',
				'mv_products',
				'mv_products_map',
				'mv_reviews',
				'mv_reviews_responses',
				'mv_creations',
				'mv_supplies',
				'mv_relations',
				'mv_revisions',
				'posts',
			]
		);

		// Register feature flags early.
		add_action( 'after_setup_theme', '\Mediavine\Create\register_flags' );

		$this->register_custom_fields();

		// Register default Create settings for Creation published data
		add_filter(
			'mv_publish_create_settings', function ( $arr ) {
			// Get the authenticated user to assign the copyright attribution if none has been set in settings.
			$user = wp_get_current_user();
			$arr[ \Mediavine\Create\Plugin::$settings_group . '_copyright_attribution' ] = $user->display_name;

			// Assign the default settings. These can be overwritten by using this filter.
			foreach ( \Mediavine\Create\Plugin::$create_settings_slugs as $slug ) {
				$setting = \Mediavine\Settings::get_setting( $slug );
				if ( $setting ) {
					$arr[ $slug ] = $setting;
				}
			}
			return $arr;
			}
		);

		self::$custom_content = Custom_Content::make( 'mv-create', 'Create' );

		// Connect GateKeeper subscription tier to mv_create_is_pro filter
		add_filter( 'mv_create_is_pro', [ 'Mediavine\Create\GateKeeper', 'is_pro_or_higher' ] );

		register_activation_hook( self::get_activation_path(), [ $this, 'plugin_activation' ] );
		register_deactivation_hook( self::get_activation_path(), [ $this, 'plugin_deactivation' ] );

		add_filter(
			'mv_wp_router_config', function( $config ) {
				$config->set(
					'api', [
						'namespace'            => 'mv-create',
						'version'              => 'v1',
						'controller_namespace' => 'Mediavine\\Create\\Controllers\\',
					]
				);
			return $config;
			}
		);

		// Load translations and run upgrade check at init. The upgrade hooks
		// (create_settings, etc.) use __() so textdomain must load first.
		add_action( 'init', 'mv_create_load_plugin_textdomain', 0 );
		add_action( 'init', [ $this, 'plugin_update_check' ], 1 );
		add_action( 'init', [ $this, 'init_translatable_data' ], 2 );

		// Activations hooks, forcing order
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'generate_tables' ], 20 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'delete_old_settings' ], 21 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'migrate_reader_experience_settings' ], 25 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'migrate_interactive_mode_settings' ], 26 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'migrate_card_style_preview_images' ], 27 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'migrate_hands_free_to_reader_experience' ], 28 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'create_settings' ], 30 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'create_shapes' ], 35 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'republish_queue' ], 40 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'update_reviews_table' ], 50 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'importer_admin_notice' ], 60 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'fix_cloned_ratings' ], 70 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'fix_cookbook_canonical_post_ids' ], 80 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'add_initial_revision_to_cards' ], 85 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'queue_existing_amazon_products' ], 90 );
		add_action( self::PLUGIN_DOMAIN . '_plugin_updated', [ $this, 'update_services_api' ], 95 );

		// Fixes
		add_action( 'mv_fix_video_description_queue_action', [ $this, 'fix_video_description' ] );

		// Shortcodes
		add_shortcode( 'mv_img', [ $this, 'mv_img_shortcode' ] );
		add_shortcode( 'mvc_ad', [ $this, 'mvc_ad_shortcode' ] );
		add_shortcode( 'mv_schema_meta', [ $this, 'mv_schema_meta_shortcode' ] );

		// For pubs that were beta-testing Indexes — hides shortcode output
		add_shortcode( 'mv_index', '__return_false' );

		add_filter( 'rest_prepare_post', [ $this, 'rest_prepare_post' ], 10, 3 );

		add_filter( 'mv_create_paapi_access_key_settings_value', 'trim', 10 );
		add_filter( 'mv_create_paapi_secret_key_settings_value', 'trim', 10 );
		add_filter( 'mv_create_paapi_tag_settings_value', 'trim', 10 );
		add_filter( 'mv_create_localized_admin_settings', [ $this, 'set_custom_post_type_option_value' ], 10 );
		$Images = new Images();
		$Images->init();

		$Settings = new \Mediavine\Settings();
		$Settings->init();

		$Nutrition = new Nutrition();
		$Nutrition->init();

		$Products = new Products();
		$Products->init();

		$Products_Map = new Products_Map();
		$Products_Map->init();

		$Relations = new Relations();
		$Relations->init();

		$Reviews_Models = new Reviews_Models();
		$Reviews_Models->init();

		$Reviews_API = new Reviews_API();
		$Reviews_API->init();

		$Reviews = new Reviews();
		$Reviews->init();

		$Featured_Review = new Featured_Review();
		$Featured_Review->init();

		$Featured_Review_API = Featured_Review_API::get_instance();
		$Featured_Review_API->init();

		$Featured_Review_Block = new Featured_Review_Block();
		$Featured_Review_Block->init();

		Shapes::get_instance();
		Creations::get_instance();
		Supplies::get_instance();

		$Images->step_queue();

		Revisions::get_instance();

		$JSON_LD = JSON_LD::get_instance();

		\Mediavine\API_Services::get_instance();

		$Dashboard_API = new Dashboard_API();
		$Dashboard_API->init();

		$Admin_Init = new Admin_Init();
		$Admin_Init->init();

		$Site_Verification = new Site_Verification();
		$Site_Verification->init();

		$User_Verification = new User_Verification();
		$User_Verification->init();

		// Initialize GateKeeper for subscription-based feature gating
		GateKeeper::init();

		// Initialize webhook handler for Create Studio → plugin communication.
		Webhook_Handler::init();

		// Initialize Feedback API for error reporting to Create Studio.
		Feedback_API::init();

		// Initialize Trial API for trial extension proxy.
		Trial_API::init();

		// Initialize Subscription API for user-invoked subscription sync.
		Subscription_API::init();

		// Initialize Bulk Scrape API for list bulk import feature
		$Bulk_Scrape_API = new Bulk_Scrape_API();
		$Bulk_Scrape_API->init();

		// Initialize Recipe Importers feature
		$Importers = Importers::get_instance();
		$Importers->init();

		Plugin_Checker::get_instance();
		Theme_Checker::get_instance();

		// Version-specific feature registration.
		if ( defined( 'MV_CREATE_IS_PRO' ) ) {
			$this->register_pro_features();
		} else {
			$this->register_free_features();
		}
	}

	/**
	 * Whether or not this instance of the plugin is Pro.
	 *
	 * @return bool
	 */
	public static function is_pro(): bool {
		return (bool) apply_filters( 'mv_create_is_pro', false );
	}

	/**
	 * Check if Create dev mode is enabled.
	 *
	 * Dev mode is enabled when:
	 * 1. The mediavine_devmode option has create set to 'on', OR
	 * 2. The mv_create_dev_mode filter returns true
	 *
	 * @return bool True if dev mode is enabled.
	 */
	public static function is_dev_mode(): bool {
		// Check the mediavine_devmode option
		$dev_mode = json_decode( get_option( 'mediavine_devmode', '[]' ), true );
		if ( isset( $dev_mode['create'] ) && 'on' === $dev_mode['create'] ) {
			return true;
		}

		// Check the filter (used by class-admin-init.php for localhost dev server)
		if ( apply_filters( 'mv_create_dev_mode', false ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Register Pro-only features.
	 */
	public function register_pro_features() {
		add_filter( 'mv_create_is_pro', '__return_true' );

		$unit_conversion = Unit_Conversion::get_instance();
		$unit_conversion->init();
	}

	/**
	 * Register Free-only features.
	 */
	public function register_free_features() {
		// Do nothing.
	}

	/**
	 * Initialize data that requires translated strings.
	 * Hooked to 'init' so the textdomain is loaded first.
	 */
	public function init_translatable_data() {
		self::$settings = self::get_settings();
		self::$shapes   = self::get_shapes_data();
	}

	/**
	 * Handle registration of all settings classes and pass complete array
	 * @return array
	 */
	public static function get_settings() {
		// Settings classes divided by groups
		$settings = [
			[
				'slug'  => self::$settings_group . '_secondary_color',
				'value' => null,
				'group' => 'mv_create_hidden',
				'order' => '0',
				'data'  => [],
			],
			[
				'slug'  => self::$settings_group . '_api_token',
				'value' => null,
				'group' => self::$settings_group . '_api',
				'order' => 105,
				'data'  => [
					'type'         => 'api_authentication',
					'label'        => __( 'Product Registration', 'mediavine' ),
					'instructions' => __( 'In order to use services like nutrition calculation or link scraping, you must register an account. This is a free, one-time action that will grant access to all of our external APIs.', 'mediavine' ),
				],
			],
			[
				'slug'  => self::$settings_group . '_api_user_id',
				'value' => false,
				'group' => 'hidden',
				'order' => 105,
				'data'  => [],
			],
		];

		$settings = array_merge(
			Advanced::settings(),
			Dev::settings(),
			Appearance::settings(),
			Recipes::settings(),
			Lists::settings(),
			List_Ads::settings(),
			Reader_Experience::settings(),
			Affiliates::settings(),
			Control_Panel::settings(),
			$settings
		);

		return apply_filters( 'mv_create_init_settings', $settings );
	}

	/**
	 * Updates Create Services Site ID with php, create and wp versions
	 *
	 * @return void
	 */
	/**
	 * Send version info to Create Studio API after a plugin update.
	 *
	 * Called via the mv_create_plugin_updated action hook.
	 * Sends current PHP, WP, and Create versions to the Studio API,
	 * and logs the version transition if the plugin was actually updated.
	 *
	 * @param string $last_plugin_version The previous plugin version before the update.
	 */
	function update_services_api( $last_plugin_version = '' ) {
		global $wp_version;

		$site_id = Create_Studio_Client::get_site_id();

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

		// Send current version info
		Create_Studio_Client::request( 'POST', '/sites/' . $site_id, [
			'php_version'    => PHP_VERSION,
			'wp_version'     => $wp_version,
			'create_version' => self::VERSION,
		] );

		// Log the version transition if this is an actual update (not a fresh install)
		if ( ! empty( $last_plugin_version ) && $last_plugin_version !== self::VERSION ) {
			Create_Studio_Client::request( 'POST', '/sites/' . $site_id . '/version-log', [
				'from' => $last_plugin_version,
				'to'   => self::VERSION,
			] );
		}
	}

	public function mv_schema_meta_shortcode( $atts ) {
		if ( isset( $atts['name'] ) ) {
			return '<span data-schema-name="' . esc_attr( $atts['name'] ) . '" style="display: none;"></span>';
		}
		return '';
	}

	public function mv_img_shortcode( $atts ) {
		$a = shortcode_atts(
			[
				'id'      => null,
				'options' => null,
				'no-pin'  => null, // @todo check for option to turn pinning on or off
			], $atts
		);

		if ( isset( $a['id'] ) ) {
			$attr = [];
			if ( isset( $a['no-pin'] ) ) {
				$attr['data-pin-nopin'] = $a['no-pin'];
			}

			if ( isset( $a['options'] ) ) {
				$meta    = wp_prepare_attachment_for_js( $a['id'] );
				$alt     = $meta['alt'];
				$title   = $meta['title'];
				$options = json_decode($a['options'] ?: '{}');

				$class = 'align' . esc_attr( $options->alignment ) . ' size-' . esc_attr( $options->size ) . ' wp-image-' . $a['id'];
				$class = apply_filters( 'get_image_tag_class', $class, $a['id'], $options->alignment, $options->size );

				$attr = [
					'alt'   => $alt,
					'title' => $title,
					'class' => $class,
				];
			}

			$img = wp_get_attachment_image( $a['id'], '', false, $attr );

			return $img;
		}
		return '';
	}

	/**
	 * In 1.4.12, we moved ad insertion logic from the admin UI to the client, see #2860.
	 * This shortcode output is intentionally left empty to provide backwards compatibility
	 * with content that includes the old ad target shortcode.
	 */
	public function mvc_ad_shortcode() {
		return '';
	}

	public function create_settings() {
		if ( null === self::$settings ) {
			self::$settings = self::get_settings();
		}
		$settings = $this->update_settings( self::$settings );
		\Mediavine\Settings::create_settings_filter( $settings );
	}

	public function create_shapes() {
		if ( null === self::$shapes ) {
			self::$shapes = self::get_shapes_data();
		}
		$shape_dbi = new \Mediavine\MV_DBI( 'mv_shapes' );

		foreach ( self::$shapes as $shape ) {
			$shape_dbi->upsert( $shape );
		}
	}

	/**
	 * Migrates old settings to newer versions within settings table
	 *
	 * Always check for less than current version as this is run before the version is updated
	 * Add estimated removal date (6 months) so we don't clutter code with future publishes
	 * Remove code within this function, but don't remove this function
	 *
	 * Example usage:
	 * ```
	 * if ( version_compare( $last_plugin_version, '1.0.0', '<' ) ) {
	 *     $settings = \Mediavine\Settings::migrate_setting_value( $settings, self::$settings_group . '_slug', 'old_value', 'new_value' );
	 *     $settings = \Mediavine\Settings::migrate_setting_slug( $settings, self::$settings_group . '_old_slug', self::$settings_group . '_new_slug' );
	 * }
	 * ```
	 *
	 * @param   array $settings  Current list of settings before running create settings
	 * @return  array             List of settings after migrated changes made
	 */
	public function update_settings( $settings ) {
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		// Update incorrect card style slug of mv_create to square (Remove Jan 2020)
		if ( version_compare( $last_plugin_version, '1.4.8', '<' ) ) {
			$settings = \Mediavine\Settings::migrate_setting_value( $settings, self::$settings_group . '_card_style', 'mv_create', 'square' );
		}

		return $settings;
	}

	public function fix_video_description( $id ) {
		// fix the video description
		$creations = new \Mediavine\MV_DBI( 'mv_creations' );
		$creation  = $creations->find_one_by_id( $id );

		if ( ! empty( $creation->video ) ) {
			$video_data         = json_decode($creation->video ?: '{}');
			$make_the_call      = false;
			$video_data_changed = false;
			$update_data        = [
				'id' => $creation->id,
			];

			if ( empty( $video_data->description ) ) {
				if (
					! empty( $video_data->rawData ) &&
					! empty( $video_data->rawData->description )
				) {
					$video_data->description = $video_data->rawData->description;
					$video_data_changed      = true;
				} else {
					$make_the_call = true;
				}
			}

			if ( empty( $video_data->duration ) ) {
				if (
					! empty( $video_data->rawData ) &&
					! empty( $video_data->rawData->duration )
				) {
					$video_data->duration = 'PT' . $video_data->rawData->duration . 'S';
					$video_data_changed   = true;
				} else {
					$make_the_call = true;
				}
			}

			if ( $make_the_call && $video_data->slug ) {
				$api_data = file_get_contents( 'https://embed.mediavine.com/oembed/?url=https%3A%2F%2Fvideo.mediavine.com%2Fvideos%2F' . $video_data->slug );
				if ( $api_data ) {
					$new_video_data = json_decode($api_data ?: '{}');

					if ( ! empty( $new_video_data->duration ) ) {
						$video_data->duration = 'PT' . $new_video_data->duration . 'S';
						$video_data_changed   = true;
					}

					if ( ! empty( $new_video_data->description ) ) {
						$video_data->description = $new_video_data->description;
						$video_data_changed      = true;
					}

					if ( ! empty( $new_video_data->keywords ) ) {
						$video_data->keywords = $new_video_data->keywords;
						$video_data_changed   = true;
					}
				}
			}

			if ( $video_data_changed ) {
				$creation->video      = wp_json_encode( $video_data );
				$update_data['video'] = $creation->video;
				if ( ! empty( $creation->json_ld ) ) {
					$json_ld     = json_decode($creation->json_ld ?: '{}');
					$upload_date = $json_ld->video->uploadDate;
					if ( ! empty( $video_data->rawData->uploadDate ) ) {
						$upload_date = $video_data->rawData->uploadDate;
					}

					$json_ld->video         = [
						'@type'        => 'VideoObject',
						'name'         => $json_ld->video->name,
						'description'  => $video_data->description,
						'thumbnailUrl' => $json_ld->video->thumbnailUrl,
						'contentUrl'   => $json_ld->video->contentUrl,
						'duration'     => $video_data->duration,
						'uploadDate'   => $upload_date,
					];
					$creation->json_ld      = wp_json_encode( $json_ld );
					$update_data['json_ld'] = $creation->json_ld;

					if ( ! empty( $creation->published ) ) {
						$published_data           = json_decode($creation->published ?: '{}');
						$published_data->video    = $creation->json_ld;
						$update_data['published'] = wp_json_encode( $published_data );
					}
				}

				$creations->update( $update_data );

			}
		}
	}

	/**
	 * Republishes create cards depending on plugin version
	 *
	 * Always check for less than current version as this is run before the version is updated
	 * Add estimated removal date (6 months) so we don't clutter code with future publishes
	 * Remove code within this function, but don't remove this function
	 *
	 * @return  void
	 */
	public function republish_queue() {
		global $wpdb;
		$creations = new \Mediavine\MV_DBI( 'mv_creations' );
		$creations->set_limit( 10000 );
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );
		$republish_ids       = [];

		// Republish cards with instructions that contain HTML entities (Remove January 2026)
		if ( version_compare( $last_plugin_version, '1.9.14', '<' ) ) {
			$cards = $creations->where( [ 'published', 'LIKE', '%&lt;%' ] );
			$republish_ids = array_merge( $republish_ids, array_values( wp_list_pluck( $cards, 'id' ) ) );
		}

		// Republish cards with rating_count > 0 (Remove January 2026)
		if ( version_compare( $last_plugin_version, '1.9.12', '<' ) ) {
			$cards = $creations->where( [ 'rating_count', '>', 0 ] );
			$republish_ids = array_merge( $republish_ids, array_values( wp_list_pluck( $cards, 'id' ) ) );
		}

		// Republish cards with orphaned <li> tags in instructions (Remove February 2026)
		if ( version_compare( $last_plugin_version, '1.10.1', '<' ) ) {
			// Query for cards where published instructions start with <li> (orphaned list items)
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$orphaned_list_cards = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT id FROM {$wpdb->prefix}mv_creations
					WHERE published IS NOT NULL
					AND published != ''
					AND JSON_EXTRACT(published, '$.instructions') LIKE %s
					AND (
						JSON_EXTRACT(published, '$.instructions') NOT LIKE %s
						OR LOCATE('<li', JSON_UNQUOTE(JSON_EXTRACT(published, '$.instructions'))) <
							COALESCE(NULLIF(LOCATE('<ol', JSON_UNQUOTE(JSON_EXTRACT(published, '$.instructions'))), 0), 999999)
					)
					AND (
						JSON_EXTRACT(published, '$.instructions') NOT LIKE %s
						OR LOCATE('<li', JSON_UNQUOTE(JSON_EXTRACT(published, '$.instructions'))) <
							COALESCE(NULLIF(LOCATE('<ul', JSON_UNQUOTE(JSON_EXTRACT(published, '$.instructions'))), 0), 999999)
					)",
					'%<li%',
					'%<ol%',
					'%<ul%'
				)
			);
			if ( ! empty( $orphaned_list_cards ) ) {
				$republish_ids = array_merge( $republish_ids, array_values( wp_list_pluck( $orphaned_list_cards, 'id' ) ) );
			}
		}
		if ( ! empty( $republish_ids ) ) {
			\Mediavine\Create\Publish::update_publish_queue( $republish_ids );
		}
	}

	/**
	 * Display importer admin notice
	 *
	 * @return void
	 */
	public function importer_admin_notice_display() {
		$settings_url = admin_url( 'options-general.php?page=mv_settings&setting=mv_create_enable_importers' );
		printf(
			'<div class="notice notice-info"><p><strong>%1$s</strong></p><p>%2$s</p></div>',
			wp_kses_post( __( 'Thanks for installing Create!', 'mediavine' ) ),
			wp_kses_post(
				sprintf(
					/* translators: %1$s: link to importer setting, %2$s: closing anchor tag */
					__( 'If you\'re moving from another recipe plugin, %1$senable the importer%2$s and breathe new life into your old recipes.', 'mediavine' ),
					'<a href="' . esc_url( $settings_url ) . '">',
					'</a>'
				)
			)
		);
	}

	/**
	 * Display importer admin notice if importers not enabled
	 *
	 * @return void
	 */
	public function importer_admin_notice() {
		if ( ! class_exists( 'Mediavine\Create\Importer\Plugin' ) ) {
			add_action( 'admin_notices', [ $this, 'importer_admin_notice_display' ] );
		}
	}

	/**
	 * Fixes reviews that were imported from other plugins.
	 *
	 * Importers were assigning a `recipe_id` to imported reviews instead of `creation`.
	 * This caused reviews to not show up, even though they'd been imported.
	 * This method fixes that by reassigning imported reviews.
	 *
	 * Remove Apr 2019
	 *
	 * @since 1.1.1
	 *
	 * @return {void}
	 */
	public function update_reviews_table() {
		global $wpdb;
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		if ( version_compare( $last_plugin_version, '1.2.0', '<' ) ) {
			// Not all users had the plugin when `recipe_id` was a column in the `mv_reviews` table.
			// Check for this column before trying to update it.
			// SECURITY CHECKED: Nothing in this query can be sanitized.
			$has_recipe_id_column_statement = "SHOW COLUMNS FROM {$wpdb->prefix}mv_reviews LIKE 'recipe_id'";
			$has_recipe_id_column           = $wpdb->get_row( $has_recipe_id_column_statement );
			if ( ! $has_recipe_id_column ) {
				return;
			}

			// SECURITY CHECKED: Nothing in this query can be sanitized.
			$statement = "UPDATE {$wpdb->prefix}mv_reviews a
							INNER JOIN {$wpdb->prefix}mv_reviews b on a.id = b.id
							SET a.creation = b.recipe_id
							WHERE b.recipe_id";
			$wpdb->query( $statement );
		}
	}

	/**
	 * Fixes cloned cards' ratings.
	 *
	 * Previously, cloned cards retained the originating card's `rating` and `rating_count`
	 * attributes, giving the client-facing card the appearance of its ratings having been
	 * duplicated. Resetting the count resolves this issue.
	 *
	 * Remove November 2019
	 *
	 * @since 1.3.20
	 *
	 * @return void
	 */
	public function fix_cloned_ratings() {
		global $wpdb;
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		if ( version_compare( $last_plugin_version, '1.3.20', '<' ) ) {
			// SECURITY CHECKED: Nothing in this query can be sanitized.
			$creations_with_ratings = $wpdb->get_results(
				"SELECT id as creation FROM {$wpdb->prefix}mv_creations WHERE rating AND rating_count;"
			);
			$model                  = new Reviews_Models();
			foreach ( $creations_with_ratings as $review ) {
				$model->update_creation_rating( $review );
			}
		}
	}

	/**
	 * Fixes canonical post ids of imported Cookbook recipes.
	 *
	 * Recipes imported from Cookbook were using the Cookbook recipe id as the canonical_post_id.
	 * Obviously, this was not good, so we need to fix that.
	 *
	 * Remove December 2019
	 *
	 * @since 1.4.6
	 *
	 * @return void
	 */
	public function fix_cookbook_canonical_post_ids() {
		global $wpdb;
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		if ( version_compare( $last_plugin_version, '1.4.6', '<' ) ) {
			// SECURITY CHECKED: Nothing in this query can be sanitized.
			$creations = $wpdb->get_results(
				"SELECT * FROM {$wpdb->prefix}mv_creations WHERE type='recipe' AND metadata LIKE '%cookbook%' AND metadata NOT LIKE '%fixed_canonical_post_id%'",
				ARRAY_A
			);
			$ids       = [];
			foreach ( $creations as $creation ) {
				$post     = get_post( $creation['canonical_post_id'] );
				$metadata = json_decode($creation['metadata'] ?: '{}');
				$posts    = json_decode($creation['associated_posts'] ?: '[]');
				if ( 'cookbook_recipe' === $post->post_type && ! empty( $posts ) ) {
					$creation['canonical_post_id'] = $posts[0];
				}
				$metadata->fixed_canonical_post_id = true;
				$creation['metadata']              = wp_json_encode( $metadata );
				self::$models_v2->mv_creations->update_without_modified_date( $creation );
				$ids[] = $creation->id;
			}
			\Mediavine\Create\Publish::update_publish_queue( $ids );
		}
	}

	/**
	 * Ensures all current versions of create cards store a revision.
	 *
	 * Remove January 2020
	 *
	 * @since 1.4.11
	 *
	 * @return void
	 */
	public function add_initial_revision_to_cards() {
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		if ( version_compare( $last_plugin_version, '1.4.11', '<' ) ) {
			Publish::add_all_to_publish_queue();
		}
	}

	/**
	 * Queues up all currently existing Amazon products after the API changes
	 *
	 * Remove May 2020
	 *
	 * @since 1.5.1
	 *
	 * @return void
	 */
	public function queue_existing_amazon_products() {
		$last_plugin_version = get_option( 'mv_create_version', self::VERSION );

		if ( version_compare( $last_plugin_version, '1.5.4', '<' ) ) {
			$Products = Products::get_instance();
			$Products->initial_queue_products();
		}
	}

	/**
	 * Sets the default JSON-LD Schema in Head setting to disabled for existing installs.
	 *
	 * Remove Sept 2021
	 *
	 * @since 1.6.7
	 *
	 * @return void
	 */
	public function set_default_schema_setting_on_existing_installs( $last_plugin_version ) {
		// Don't run new installs (previous version newer than 1.6.7).
		// We run the not (!) check because we sometimes give patch releases in an x.x.x.x format.
		if ( ! version_compare( $last_plugin_version, '1.6.7', '<' ) ) {
			return;
		}

		// Build mock setting for JSON-LD Schema in Head with disabled value.
		$fake_schema_setting = [
			[
				'slug'  => self::$settings_group . '_schema_in_head',
				'value' => false,
			],
		];

		// Create mock setting into database before real settings are updated.
		// This will keep the value of the fake setting, but everything else of the real setting.
		Settings::create_settings( $fake_schema_setting );
	}

	/**
	 * Deletes old settings that are no longer used in Create
	 *
	 * @return void
	 */
	public function delete_old_settings() {
		\Mediavine\Settings::delete_setting( self::$settings_group . '_enable_link_scraping' );
		\Mediavine\Settings::delete_setting( self::$settings_group . '_ad_density' );
		\Mediavine\Settings::delete_setting( self::$settings_group . '_measurement_system' );
	}

	/**
	 * Migrates settings to the new Reader Experience group.
	 *
	 * Moves settings from Pro and Advanced groups to the new reader_experience group
	 * as part of the Settings UI Redesign.
	 *
	 * Settings moved:
	 * - From Pro: Jump to Recipe, Social Footer settings
	 * - From Advanced: Checklists, Reviews, Ratings settings
	 *
	 * @since 2.1.0
	 * @param string $last_plugin_version The previous plugin version.
	 * @return void
	 */
	public function migrate_reader_experience_settings( $last_plugin_version ) {
		// Only run migration for versions before the settings redesign.
		if ( ! version_compare( $last_plugin_version, '2.1.0', '<' ) ) {
			return;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'mv_settings';

		// Move Jump to Recipe settings from Pro to reader_experience.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE slug LIKE %s OR slug LIKE %s",
				self::$settings_group . '_reader_experience',
				'%jump_to_recipe%',
				'%jump_to_how_to%'
			)
		);

		// Move Social Footer settings from Pro to reader_experience.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE slug LIKE %s OR slug LIKE %s OR slug LIKE %s OR slug LIKE %s OR slug LIKE %s",
				self::$settings_group . '_reader_experience',
				'%social_footer%',
				'%social_service%',
				'%facebook_username%',
				'%instagram_username%',
				'%pinterest_username%'
			)
		);

		// Move Checklists, Reviews, and Ratings settings from Advanced to reader_experience.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE slug LIKE %s OR slug LIKE %s OR slug LIKE %s",
				self::$settings_group . '_reader_experience',
				'%checklist%',
				'%review%',
				'%rating%'
			)
		);

		// Move Display settings to Appearance group.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE `group` = %s",
				self::$settings_group . '_appearance',
				self::$settings_group . '_display'
			)
		);
	}

	/**
	 * Migrates Interactive Mode settings to their own group.
	 *
	 * Moves Interactive Mode settings from reader_experience to interactive_mode
	 * group so they appear in their own settings section.
	 *
	 * @since 2.0.9
	 * @param string $last_plugin_version The previous plugin version.
	 * @return void
	 */
	public function migrate_interactive_mode_settings( $last_plugin_version ) {
		if ( ! version_compare( $last_plugin_version, '2.0.9', '<' ) ) {
			return;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'mv_settings';

		// Move Interactive Mode settings from reader_experience to interactive_mode.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE slug LIKE %s",
				self::$settings_group . '_interactive_mode',
				'%interactive_mode%'
			)
		);

		// Remove legacy debugging setting.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->delete( $table, [ 'slug' => self::$settings_group . '_enable_debugging' ] );
	}

	/**
	 * Migrates card style preview image URLs from PNG to WebP format.
	 *
	 * In 2.0.10, card style preview images were converted from PNG to WebP.
	 * The image URLs are stored as JSON in the settings data column and need
	 * to be updated so the theme selector doesn't show 404 images on upgrade.
	 *
	 * @since 2.0.10
	 *
	 * @param string $last_plugin_version The version being upgraded from.
	 */
	public function migrate_card_style_preview_images( $last_plugin_version ) {
		if ( ! version_compare( $last_plugin_version, '2.0.10', '<' ) ) {
			return;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'mv_settings';

		// Replace .png with .webp in the data JSON for card style settings.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			"UPDATE {$table} SET data = REPLACE(data, 'card-style-editorial.png', 'card-style-editorial.webp'),
				data = REPLACE(data, 'card-style-modern.png', 'card-style-modern.webp'),
				data = REPLACE(data, 'card-style-big-image.png', 'card-style-big-image.webp'),
				data = REPLACE(data, 'card-style-default.png', 'card-style-default.webp'),
				data = REPLACE(data, 'card-style-dark.png', 'card-style-dark.webp'),
				data = REPLACE(data, 'card-style-centered.png', 'card-style-centered.webp'),
				data = REPLACE(data, 'card-style-centered-dark.png', 'card-style-centered-dark.webp')
			WHERE slug LIKE '%_card_style' AND data LIKE '%card-style-%.png%'"
		);
	}

	/**
	 * Migrates Hands-free Mode setting from Advanced to Reader Experience.
	 *
	 * @since 2.0.12
	 * @param string $last_plugin_version The previous plugin version.
	 * @return void
	 */
	public function migrate_hands_free_to_reader_experience( $last_plugin_version ) {
		if ( ! version_compare( $last_plugin_version, '2.0.12', '<' ) ) {
			return;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'mv_settings';

		// Move Hands-free Mode from advanced to reader_experience.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET `group` = %s WHERE slug = %s",
				self::$settings_group . '_reader_experience',
				self::$settings_group . '_enable_hands_free_mode'
			)
		);
	}

	/**
	 * Extend default REST API with useful data.
	 *
	 * @param [object] $data the current object outbound to rest response
	 * @param [object] $post post object for use in the outbound response
	 * @param [object] $request the wp rest request object.
	 * @return [object] update $data object
	 */
	public function rest_prepare_post( $data, $post, $request ) {
		$_data                        = $data->data;
		$_data['mv']                  = [];
		$_data['mv']['thumbnail_id']  = null;
		$_data['mv']['thumbnail_uri'] = null;

		$thumbnail_id = get_post_thumbnail_id( $post->ID );

		if ( empty( $thumbnail_id ) ) {
			$data->data = $_data;
			return $data;
		}

		$thumbnail                   = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
		$_data['mv']['thumbnail_id'] = $thumbnail_id;

		if ( isset( $thumbnail[0] ) ) {
			$_data['mv']['thumbnail_uri'] = $thumbnail[0];
		}

		$data->data = $_data;
		return $data;
	}

	/**
	 * Retrieve an array of custom post-types that are public and not built-in
	 *
	 * @return array
	 */
	private function get_custom_post_types() {
		/**
		 * @var \WP_Post_Type[]
		 */
		$post_types = get_post_types(
			[
				'public'   => true,
				'_builtin' => false,
			], 'objects'
		);

		$allowed_post_types = [];
		foreach ( $post_types as $post_type ) {
			$post_type_label = $post_type->label;
			if ( ! empty( $post_type->labels->singular_name ) ) {
				$post_type_label = $post_type->labels->singular_name;
			}

			$allowed_post_types[ $post_type->name ] = $post_type_label;
		}

		return $allowed_post_types;
	}

	/**
	 * Register custom fields for users.
	 */
	public static function register_custom_fields() {
		add_filter(
			'mv_create_fields', function ( $arr ) {
			$arr[] = [
				'slug'         => 'class',
				'label'        => __( 'CSS Class', 'mediavine' ),
				'instructions' => __( 'Add an additional CSS class to this card.', 'mediavine' ),
				'type'         => 'text',
			];
			$arr[] = [
				'slug'         => 'mv_create_nutrition_disclaimer',
				'label'        => __( 'Custom Nutrition Disclaimer', 'mediavine' ),
				'instructions' => __( 'Example: Nutrition information isn\'t always accurate.', 'mediavine' ),
				'type'         => 'textarea',
				'card'         => 'recipe',
			];
			$arr[] = [
				'slug'         => 'mv_create_affiliate_message',
				'label'        => __( 'Custom Affiliate Message', 'mediavine' ),
				'instructions' => __( 'Override the default affiliate message for this card.', 'mediavine' ),
				'type'         => 'textarea',
				'card'         => [ 'recipe', 'diy', 'list' ],
			];
			$arr[] = [
				'slug'         => 'mv_create_show_list_affiliate_message',
				'label'        => __( 'Show Custom Affiliate Message', 'mediavine' ),
				'instructions' => __( 'Check this box to display an affiliate message on this List.', 'mediavine' ),
				'type'         => 'checkbox',
				'card'         => 'list',
			];

			// Social footer overrides
			if ( \Mediavine\Settings::get_setting( 'mv_create_social_footer', false ) ) {
				$arr[] = [
					'slug'         => 'mv_create_social_footer_icon',
					'label'        => __( 'Social Footer Icon', 'mediavine' ),
					'instructions' => __( 'Override the default social footer icon for this card.', 'mediavine' ),
					'type'         => 'select',
					'defaultValue' => 'default',
					'options'      => [
						'default'   => 'Use Default',
						'facebook'  => 'Facebook',
						'instagram' => 'Instagram',
						'pinterest' => 'Pinterest',
					],
					'card'         => [ 'recipe', 'diy' ],
				];
				$arr[] = [
					'slug'         => 'mv_create_social_footer_header',
					'label'        => __( 'Social Footer Heading', 'mediavine' ),
					'instructions' => __( 'Override the default social footer heading for this card.', 'mediavine' ),
					'type'         => 'text',
					'card'         => [ 'recipe', 'diy' ],
				];
				$arr[] = [
					'slug'         => 'mv_create_social_footer_content',
					'label'        => __( 'Social Footer Content', 'mediavine' ),
					'instructions' => __( 'Override the default social footer content for this card.', 'mediavine' ),
					'type'         => 'wysiwyg',
					'card'         => [ 'recipe', 'diy' ],
				];
			}

			return $arr;
			}
		);
	}

	/**
	 * @return array[]
	 */
	public static function get_shapes_data() {
		return [
			[
				'name'   => __( 'Recipe', 'mediavine' ),
				'plural' => __( 'Recipes', 'mediavine' ),
				'slug'   => 'recipe',
				'icon'   => 'carrot',
				'shape'  => file_get_contents( __DIR__ . '/shapes/recipe.json' ),
			],
			[
				'name'   => __( 'How-To', 'mediavine' ),
				'plural' => __( 'How-Tos', 'mediavine' ),
				'slug'   => 'diy',
				'icon'   => 'lightbulb',
				'shape'  => file_get_contents( __DIR__ . '/shapes/how-to.json' ),
			],
			[
				'name'   => __( 'List', 'mediavine' ),
				'plural' => __( 'Lists', 'mediavine' ),
				'slug'   => 'list',
				'icon'   => '',
				'shape'  => file_get_contents( __DIR__ . '/shapes/list.json' ),
			],
		];
	}
}