diff --git a/.travis.yml b/.travis.yml index 476b28f..af0ab16 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,8 +12,7 @@ node_js: - 0.10 env: - - WP_VERSION=latest WP_MULTISITE=0 - - WP_VERSION=latest WP_MULTISITE=1 + - WP_VERSION=trunk WP_MULTISITE=1 before_script: - export DEV_LIB_PATH=dev-lib diff --git a/dev-lib b/dev-lib index 7f29252..9ae7dbe 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit 7f29252a6a7db75d3ae2cda367229184c34220d6 +Subproject commit 9ae7dbef266d21c7d16fa8d8b1d7a3538dba5587 diff --git a/php/class-base-test-case.php b/php/class-base-test-case.php index 72f81dc..e06198e 100644 --- a/php/class-base-test-case.php +++ b/php/class-base-test-case.php @@ -18,11 +18,12 @@ function setUp() { } function clean_up_global_scope() { - global $wp_registered_sidebars, $wp_registered_widgets, $wp_registered_widget_controls, $wp_registered_widget_updates; + global $wp_registered_sidebars, $wp_registered_widgets, $wp_registered_widget_controls, $wp_registered_widget_updates, $wp_post_types; // @codingStandardsIgnoreStart $wp_registered_sidebars = $wp_registered_widgets = $wp_registered_widget_controls = $wp_registered_widget_updates = array(); // @codingStandardsIgnoreEnd $this->plugin->widget_factory->widgets = array(); + unset( $wp_post_types[ Widget_Posts::INSTANCE_POST_TYPE ] ); parent::clean_up_global_scope(); } diff --git a/php/class-efficient-multidimensional-setting-sanitizing.php b/php/class-efficient-multidimensional-setting-sanitizing.php index 880517c..38ad285 100644 --- a/php/class-efficient-multidimensional-setting-sanitizing.php +++ b/php/class-efficient-multidimensional-setting-sanitizing.php @@ -50,6 +50,11 @@ class Efficient_Multidimensional_Setting_Sanitizing { */ public $disabled_filtering_pre_option_widget_settings = false; + /** + * @var \WP_Widget[] + */ + public $widget_objs; + /** * @param Plugin $plugin * @param \WP_Customize_Manager $manager @@ -60,7 +65,10 @@ function __construct( Plugin $plugin, \WP_Customize_Manager $manager ) { $this->manager->efficient_multidimensional_setting_sanitizing = $this; add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 2 ); + $priority = 92; // Because Widget_Posts::prepare_widget_data() happens at 91. + add_action( 'widgets_init', array( $this, 'capture_widget_instance_data' ), $priority ); + // Note that customize_register happens at wp_loaded, so we register the settings just before $priority = has_action( 'wp_loaded', array( $this->manager, 'wp_loaded' ) ); $priority -= 1; add_action( 'wp_loaded', array( $this, 'register_widget_instance_settings_early' ), $priority ); @@ -69,6 +77,52 @@ function __construct( Plugin $plugin, \WP_Customize_Manager $manager ) { add_action( 'customize_save_after', array( $this, 'enable_filtering_pre_option_widget_settings' ) ); } + /** + * Since at widgets_init,100 the single instances of widgets get copied out + * to the many instances in $wp_registered_widgets, we capture all of the + * registered widgets up front so we don't have to search through the big + * list later. At this time we also add the pre_option filter to use the + * settings as retrieved once from the database, and then manipulated in a + * pre-unserialized data structure ready to be re-serialized once when the + * Customizer setting is saved. + * + * @see WP_Customize_Widget_Setting::__construct() + * @see Widget_Posts::filter_pre_option_widget_settings() + */ + function capture_widget_instance_data() { + foreach ( $this->plugin->widget_factory->widgets as $widget_obj ) { + /** @var \WP_Widget $widget_obj */ + $this->widget_objs[ $widget_obj->id_base ] = $widget_obj; + + // Store the widget settings once, after Widget_Posts::prepare_widget_data() has run, so we can get Widget_Settings ArrayIterators. + $this->current_widget_type_values[ $widget_obj->id_base ] = $widget_obj->get_settings(); + + // Note that this happens _before_ Widget_Posts::filter_pre_option_widget_settings(). + add_filter( "pre_option_widget_{$widget_obj->id_base}", function ( $pre_value ) use ( $widget_obj ) { + return $this->filter_pre_option_widget_settings( $pre_value, $widget_obj ); + } ); + } + } + + /** + * Pre-option filter which intercepts the expensive WP_Customize_Setting::_preview_filter(). + * + * @see WP_Customize_Setting::_preview_filter() + * @param null|array $pre_value Value that, if set, short-circuits the normal get_option() return value. + * @param \WP_Widget $widget_obj + * @return array + * @throws Exception + */ + function filter_pre_option_widget_settings( $pre_value, $widget_obj ) { + if ( ! $this->disabled_filtering_pre_option_widget_settings ) { + if ( ! isset( $this->current_widget_type_values[ $widget_obj->id_base ] ) ) { + throw new Exception( "current_widget_type_values not set yet for $widget_obj->id_base" ); + } + $pre_value = $this->current_widget_type_values[ $widget_obj->id_base ]; + } + return $pre_value; + } + /** * Ensure that dynamic settings for widgets use the proper class. * @@ -120,6 +174,7 @@ function register_widget_instance_settings_early() { function register_widget_settings() { global $wp_registered_widgets; if ( empty( $wp_registered_widgets ) ) { + $this->plugin->trigger_warning( '$wp_registered_widgets is empty.' ); return; } diff --git a/php/class-plugin.php b/php/class-plugin.php index c77057d..94e753f 100644 --- a/php/class-plugin.php +++ b/php/class-plugin.php @@ -36,6 +36,11 @@ class Plugin extends Plugin_Base { */ public $https_resource_proxy; + /** + * @var Widget_Posts + */ + public $widget_posts; + /** * @var Efficient_Multidimensional_Setting_Sanitizing */ @@ -73,9 +78,14 @@ public function __construct( $config = array() ) { 'widget_number_incrementing' => true, 'https_resource_proxy' => true, + 'widget_posts' => true, 'efficient_multidimensional_setting_sanitizing' => true, ), 'https_resource_proxy' => HTTPS_Resource_Proxy::default_config(), + 'widget_posts' => Widget_Posts::default_config(), + + 'memory_limit' => '256M', + 'max_memory_usage_percentage' => 0.75, ); $this->config = array_merge( $default_config, $config ); @@ -103,6 +113,13 @@ function init() { $this->widget_factory = $wp_widget_factory; $this->config = apply_filters( 'customize_widgets_plus_plugin_config', $this->config, $this ); + // Handle conflicting modules. + if ( $this->config['active_modules']['widget_posts'] ) { + $this->config['active_modules']['non_autoloaded_widget_options'] = false; // The widget_posts module makes this obsolete. + $this->config['active_modules']['widget_number_incrementing'] = true; // Dependency. + // @todo $this->config['active_modules']['efficient_multidimensional_setting_sanitizing'] = true; // ? + } + add_action( 'wp_default_scripts', array( $this, 'register_scripts' ), 11 ); add_action( 'wp_default_styles', array( $this, 'register_styles' ), 11 ); add_action( 'customize_controls_enqueue_scripts', array( $this, 'customize_controls_enqueue_scripts' ) ); @@ -128,6 +145,9 @@ function init() { if ( $this->is_module_active( 'https_resource_proxy' ) ) { $this->https_resource_proxy = new HTTPS_Resource_Proxy( $this ); } + if ( $this->is_module_active( 'widget_posts' ) ) { + $this->widget_posts = new Widget_Posts( $this ); + } if ( $this->is_module_active( 'efficient_multidimensional_setting_sanitizing' ) && ! empty( $wp_customize ) ) { $this->efficient_multidimensional_setting_sanitizing = new Efficient_Multidimensional_Setting_Sanitizing( $this, $wp_customize ); } @@ -325,4 +345,95 @@ function is_normal_multi_widget( $widget_obj ) { } return true; } + + + /** + * Get the memory limit in bytes. + * + * Uses memory_limit from php.ini, WP_MAX_MEMORY_LIMIT, and memory_limit + * plugin config, whichever is smallest. Note that -1 is considered infinity. + * + * @return int + */ + function get_memory_limit() { + $memory_limit = $this->parse_byte_size( ini_get( 'memory_limit' ) ); + if ( $memory_limit <= 0 ) { + $memory_limit = $this->parse_byte_size( \WP_MAX_MEMORY_LIMIT ); + } + if ( $memory_limit <= 0 ) { + $memory_limit = \PHP_INT_MAX; + } + $memory_limit = min( + $memory_limit, + $this->parse_byte_size( $this->config['memory_limit'] ) + ); + return $memory_limit; + } + + /** + * Clear all of the caches for memory management + * + * Adapted from WPCOM_VIP_CLI_Command. + * + * @see \WPCOM_VIP_CLI_Command::stop_the_insanity() + * + * @return bool Whether memory was garbage-collected. + */ + function stop_the_insanity() { + + $memory_limit = $this->get_memory_limit(); + $used_memory = memory_get_usage(); + $used_memory_percentage = (float) $used_memory / $memory_limit; + + // Do nothing if we haven't reached the memory limit threshold + if ( $used_memory_percentage < $this->config['max_memory_usage_percentage'] ) { + return false; + } + + /** + * @var \WP_Object_Cache $wp_object_cache + * @var \wpdb $wpdb + */ + global $wpdb, $wp_object_cache; + + $wpdb->queries = array(); // or define( 'WP_IMPORTING', true ); + + if ( is_object( $wp_object_cache ) ) { + $wp_object_cache->group_ops = array(); + $wp_object_cache->stats = array(); + $wp_object_cache->memcache_debug = array(); + $wp_object_cache->cache = array(); + + if ( method_exists( $wp_object_cache, '__remoteset' ) ) { + $wp_object_cache->__remoteset(); // important + } + } + + return true; + } + + /** + * Obtain an integer byte size from a byte string like 1024K, 65M, 1G. + * + * @param int|string $bytes Integer or string like "1024K", "64M" or "1G" + * @return int|null Number of bytes, or null if parse error + */ + public function parse_byte_size( $bytes ) { + if ( is_int( $bytes ) ) { + return $bytes; // already bytes, so no-op + } + if ( ! preg_match( '/^(-?\d+)([BKMG])?$/', strtoupper( $bytes ), $matches ) ) { + return null; + } + $value = intval( $matches[1] ); + $unit = empty( $matches[2] ) ? 'B' : $matches[2]; + if ( 'K' === $unit ) { + $value *= 1024; + } else if ( 'M' === $unit ) { + $value *= pow( 1024, 2 ); + } else if ( 'G' === $unit ) { + $value *= pow( 1024, 3 ); + } + return $value; + } } diff --git a/php/class-widget-number-incrementing.php b/php/class-widget-number-incrementing.php index 88d6e04..44ff5da 100644 --- a/php/class-widget-number-incrementing.php +++ b/php/class-widget-number-incrementing.php @@ -25,7 +25,7 @@ class Widget_Number_Incrementing { /** * @var \WP_Widget[] */ - public $widget_objs = array(); + public $widget_objs; /** * @param Plugin $plugin @@ -33,12 +33,27 @@ class Widget_Number_Incrementing { function __construct( Plugin $plugin ) { $this->plugin = $plugin; + add_action( 'widgets_init', array( $this, 'store_widget_objects' ), 90 ); add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'ajax_incr_widget_number' ) ); add_action( 'customize_controls_enqueue_scripts', array( $this, 'customize_controls_enqueue_scripts' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); add_filter( 'customize_refresh_nonces', array( $this, 'filter_customize_refresh_nonces' ) ); } + /** + * @action widgets_init, 90 + */ + function store_widget_objects() { + $this->widget_objs = array(); + foreach ( $this->plugin->widget_factory->widgets as $widget_obj ) { + /** @var \WP_Widget $widget_obj */ + if ( "widget_{$widget_obj->id_base}" !== $widget_obj->option_name ) { + continue; + } + $this->widget_objs[ $widget_obj->id_base ] = $widget_obj; + } + } + /** * @see Widget_Management::gather_registered_widget_types() * @see Widget_Management::get_widget_number() @@ -65,10 +80,16 @@ protected function get_option_key_for_widget_number( $id_base ) { * @return int */ function get_max_existing_widget_number( $id_base ) { - $widget_objs = $this->plugin->get_registered_widget_objects(); - $widget_obj = $widget_objs[ $id_base ]; + $widget_obj = $this->widget_objs[ $id_base ]; // @todo There should be a pre_existing_widget_numbers, pre_max_existing_widget_number filter, or pre_existing_widget_ids to short circuit the expensive WP_Widget::get_settings() - $widget_numbers = array_keys( $widget_obj->get_settings() ); + + $settings = $widget_obj->get_settings(); + if ( $settings instanceof \ArrayAccess && method_exists( $settings, 'getArrayCopy' ) ) { + /** @see Widget_Settings */ + $settings = $settings->getArrayCopy( $settings ); + } + + $widget_numbers = array_keys( $settings ); $widget_numbers[] = 2; // multi-widgets start numbering at 2 return max( $widget_numbers ); } @@ -114,13 +135,16 @@ function get_widget_number( $id_base ) { */ function set_widget_number( $id_base, $number ) { $this->add_widget_number_option( $id_base ); + $existing_number = $this->get_widget_number( $id_base ); $number = max( 2, // multi-widget numbering starts here $this->get_widget_number( $id_base ), $this->get_max_existing_widget_number( $id_base ), $number ); - update_option( $this->get_option_key_for_widget_number( $id_base ), $number ); + if ( $existing_number !== $number ) { + update_option( $this->get_option_key_for_widget_number( $id_base ), $number ); + } return $number; } diff --git a/php/class-widget-posts-cli-command.php b/php/class-widget-posts-cli-command.php new file mode 100644 index 0000000..628137c --- /dev/null +++ b/php/class-widget-posts-cli-command.php @@ -0,0 +1,392 @@ +widget_posts ) ) { + static::$plugin_instance->widget_posts = new Widget_Posts( static::$plugin_instance ); + } + return static::$plugin_instance->widget_posts; + } + + /** + * Enable looking for widgets in posts instead of options. You should run migrate first. + */ + public function enable() { + if ( $this->get_widget_posts()->is_enabled() ) { + \WP_CLI::warning( 'Widget Posts already enabled.' ); + } else { + $result = $this->get_widget_posts()->enable(); + if ( $result ) { + \WP_CLI::success( 'Widget Posts enabled.' ); + } else { + \WP_CLI::error( 'Failed to enable Widget Posts.' ); + } + } + } + + /** + * Disable looking for widgets in posts instead of options. + */ + public function disable() { + if ( ! $this->get_widget_posts()->is_enabled() ) { + \WP_CLI::warning( 'Widget Posts already disabled.' ); + } else { + $result = $this->get_widget_posts()->disable(); + if ( $result ) { + \WP_CLI::success( 'Widget Posts disabled.' ); + } else { + \WP_CLI::error( 'Failed to disable Widget Posts.' ); + } + } + } + + /** + * Number of widgets updated. + * + * @var int + */ + protected $updated_count = 0; + + /** + * Number of widgets skipped. + * + * @var int + */ + protected $skipped_count = 0; + + /** + * Number of widgets inserted. + * + * @var int + */ + protected $inserted_count = 0; + + /** + * Number of import-failed widgets. + * + * @var int + */ + protected $failed_count = 0; + + /** + * @param array $options + */ + protected function add_import_actions( $options ) { + $this->updated_count = 0; + $this->skipped_count = 0; + $this->inserted_count = 0; + $this->failed_count = 0; + + add_action( 'widget_posts_import_skip_existing', function ( $context ) use ( $options ) { + // $context is compact( 'widget_id', 'instance', 'widget_number', 'id_base' ) + \WP_CLI::line( "Skipping already-imported widget $context[widget_id] (to update, call with --update)." ); + $this->skipped_count += 1; + } ); + + add_action( 'widget_posts_import_success', function ( $context ) use ( $options ) { + // $context is compact( 'widget_id', 'post', 'instance', 'widget_number', 'id_base', 'update' ) + if ( $context['update'] ) { + $message = "Updated widget $context[widget_id]."; + $this->updated_count += 1; + } else { + $message = "Inserted widget $context[widget_id]."; + $this->inserted_count += 1; + } + if ( $options['dry-run'] ) { + $message .= ' (DRY RUN)'; + } + \WP_CLI::success( $message ); + } ); + + add_action( 'widget_posts_import_failure', function ( $context ) { + // $context is compact( 'widget_id', 'exception', 'instance', 'widget_number', 'id_base', 'update' ) + /** @var Exception $exception */ + $exception = $context['exception']; + \WP_CLI::warning( "Failed to import $context[widget_id]: " . $exception->getMessage() ); + $this->failed_count += 1; + } ); + } + + /** + * Remove actions added by add_import_actions(). + * + * @see Widget_Posts_CLI_Command::add_import_actions() + */ + protected function remove_import_actions() { + remove_all_actions( 'widget_posts_import_skip_existing' ); + remove_all_actions( 'widget_posts_import_success' ); + remove_all_actions( 'widget_posts_import_failure' ); + } + + /** + * Write out summary of data collected by actions in add_import_actions(). + * + * @see Widget_Posts_CLI_Command::add_import_actions() + */ + protected function write_import_summary() { + \WP_CLI::line(); + \WP_CLI::line( "Skipped: $this->skipped_count" ); + \WP_CLI::line( "Updated: $this->updated_count" ); + \WP_CLI::line( "Inserted: $this->inserted_count" ); + \WP_CLI::line( "Failed: $this->failed_count" ); + } + + /** + * Migrate widget instances from options into posts. Posts that already exist for given widget IDs will not be-imported unless --update is supplied. + * + * ## OPTIONS + * + * --update + * : Update any widget instance posts already migrated/imported. This would override any changes made since the last migration. + * + * --dry-run + * : Show what would be migrated. + * + * --verbose + * : Show more info about what is going on. + * + * @param array [$id_bases] + * @param array $options + * @synopsis [...] [--dry-run] [--update] [--verbose] + */ + public function migrate( $id_bases, $options ) { + try { + if ( ! defined( 'WP_IMPORTING' ) ) { + define( 'WP_IMPORTING', true ); + } + $widget_posts = $this->get_widget_posts(); + + $options = array_merge( + array( + 'update' => false, + 'verbose' => false, + 'dry-run' => false, + ), + $options + ); + if ( empty( $id_bases ) ) { + $id_bases = array_keys( $widget_posts->widget_objs ); + } else { + foreach ( $id_bases as $id_base ) { + if ( ! array_key_exists( $id_base, $widget_posts->widget_objs ) ) { + \WP_CLI::error( "Unrecognized id_base: $id_base" ); + } + } + } + + $this->add_import_actions( $options ); + + // Note we disable the pre_option filters because we need to get the underlying wp_options. + $widget_posts->pre_option_filters_disabled = true; + foreach ( $id_bases as $id_base ) { + $widget_obj = $widget_posts->widget_objs[ $id_base ]; + $instances = $widget_obj->get_settings(); + $widget_posts->import_widget_instances( $id_base, $instances, $options ); + } + $widget_posts->pre_option_filters_disabled = false; + + $this->write_import_summary(); + $this->remove_import_actions(); + + } catch ( \Exception $e ) { + \WP_CLI::error( sprintf( '%s: %s', get_class( $e ), $e->getMessage() ) ); + } + } + + /** + * Import widget instances from a JSON dump. + * + * JSON may be in either of two formats: + * {"search-123":{"title":"Buscar"}} + * or + * {"search":{"123":{"title":"Buscar"}}} + * or + * {"widget_search":{"123":{"title":"Buscar"}}} + * or + * {"version":5,"options":{"widget_search":"a:1:{i:123;a:1:{s:5:\"title\";s:6:\"Buscar\";}}"}} + * + * Posts that already exist for given widget IDs will not be-imported unless --update is supplied. + * + * ## OPTIONS + * + * --update + * : Update any widget instance posts already migrated/imported. This would override any changes made since the last migration. + * + * --dry-run + * : Show what would be migrated. + * + * --verbose + * : Show more info about what is going on. + * + * @param array [$args] + * @param array $options + * @synopsis [] [--dry-run] [--update] [--verbose] + */ + public function import( $args, $options ) { + try { + if ( ! defined( 'WP_IMPORTING' ) ) { + define( 'WP_IMPORTING', true ); + } + $widget_posts = $this->get_widget_posts(); + + $file = array_shift( $args ); + if ( '-' === $file ) { + $file = 'php://stdin'; + } + $options = array_merge( + array( + 'update' => false, + 'verbose' => false, + 'dry-run' => false, + ), + $options + ); + + // @codingStandardsIgnoreStart + $json = file_get_contents( $file ); + // @codingStandardsIgnoreSEnd + if ( false === $json ) { + throw new Exception( "$file could not be read" ); + } + + $data = json_decode( $json, true ); + if ( json_last_error() ) { + throw new Exception( 'JSON parse error, code: ' . json_last_error() ); + } + if ( ! is_array( $data ) ) { + throw new Exception( 'Expected array JSON to be an array.' ); + } + + $this->add_import_actions( $options ); + + // Reformat the data structure into a format that import_widget_instances() accepts. + $first_key = key( $data ); + if ( ! filter_var( $first_key, FILTER_VALIDATE_INT ) ) { + $is_options_export = ( + isset( $data['version'] ) + && + 5 === $data['version'] + && + isset( $data['options'] ) + && + is_array( $data['options'] ) + ); + if ( $is_options_export ) { + // Format: {"version":5,"options":{"widget_search":"a:1:{i:123;a:1:{s:5:\"title\";s:6:\"Buscar\";}}"}}. + $instances_by_type = array(); + foreach ( $data['options'] as $option_name => $option_value ) { + if ( ! preg_match( '/^widget_(?P.+)/', $option_name, $matches ) ) { + continue; + } + if ( ! is_serialized( $option_value, true ) ) { + \WP_CLI::warning( "Option $option_name is not valid serialized data as expected." ); + continue; + } + $instances_by_type[ $matches['id_base'] ] = unserialize( $option_value ); + } + + } else if ( array_key_exists( $first_key, $widget_posts->widget_objs ) ) { + // Format: {"search":{"123":{"title":"Buscar"}}}. + $instances_by_type = $data; + + } else { + // Format: {"widget_search":{"123":{"title":"Buscar"}}}. + $instances_by_type = array(); + foreach ( $data as $key => $value ) { + if ( ! preg_match( '/^widget_(?P.+)/', $key, $matches ) ) { + throw new Exception( "Unexpected key: $key" ); + } + $instances_by_type[ $matches['id_base'] ] = $value; + } + } + } else { + // Format: {"search-123":{"title":"Buscar"}}. + $instances_by_type = array(); + foreach ( $data as $widget_id => $instance ) { + $parsed_widget_id = $widget_posts->plugin->parse_widget_id( $widget_id ); + if ( empty( $parsed_widget_id ) || empty( $parsed_widget_id['widget_number'] ) ) { + \WP_CLI::warning( "Rejecting instance with invalid widget ID: $widget_id" ); + continue; + } + if ( ! isset( $instances_by_type[ $parsed_widget_id['id_base'] ] ) ) { + $instances_by_type[ $parsed_widget_id['id_base'] ] = array(); + } + $instances_by_type[ $parsed_widget_id['id_base'] ][ $parsed_widget_id['widget_number'] ] = $instance; + } + } + + // Import each of the instances. + foreach ( $instances_by_type as $id_base => $instances ) { + if ( ! is_array( $instances ) ) { + \WP_CLI::warning( "Expected array for $id_base instances. Skipping unknown number of widgets." ); + continue; + } + try { + $widget_posts->import_widget_instances( $id_base, $instances, $options ); + } catch ( Exception $e ) { + \WP_CLI::warning( 'Skipping: ' . $e->getMessage() ); + if ( is_array( $instances ) ) { + $this->skipped_count += count( $instances ); + } + } + } + + $this->write_import_summary(); + $this->remove_import_actions(); + } catch ( \Exception $e ) { + \WP_CLI::error( sprintf( '%s: %s', get_class( $e ), $e->getMessage() ) ); + } + } + + /** + * Show the instance data for the given widget ID in JSON format. + * + * ## OPTIONS + * + * @param array [$args] + * @param array $assoc_args + * @synopsis + * @alias show + */ + public function get( $args, $assoc_args ) { + try { + $widget_id = array_shift( $args ); + unset( $assoc_args ); + + $widget_posts = $this->get_widget_posts(); + $post = $widget_posts->get_widget_post( $widget_id ); + if ( ! $post ) { + \WP_CLI::warning( "Widget post $widget_id does not exist." ); + } else { + $data = $widget_posts->get_widget_instance_data( $post ); + echo json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; + } + } catch ( \Exception $e ) { + \WP_CLI::error( sprintf( '%s: %s', get_class( $e ), $e->getMessage() ) ); + } + } + +} diff --git a/php/class-widget-posts.php b/php/class-widget-posts.php new file mode 100644 index 0000000..af07a6f --- /dev/null +++ b/php/class-widget-posts.php @@ -0,0 +1,806 @@ +plugin->config[ static::MODULE_SLUG ]; + } else if ( isset( $this->plugin->config[ static::MODULE_SLUG ][ $key ] ) ) { + return $this->plugin->config[ static::MODULE_SLUG ][ $key ]; + } else { + return null; + } + } + + /** + * @param Plugin $plugin + */ + function __construct( Plugin $plugin ) { + $this->plugin = $plugin; + + add_option( self::ENABLED_FLAG_OPTION_NAME, 'no', '', 'yes' ); + add_action( 'widgets_init', array( $this, 'store_widget_objects' ), 90 ); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + Widget_Posts_CLI_Command::$plugin_instance = $this->plugin; + \WP_CLI::add_command( 'widget-posts', __NAMESPACE__ . '\\Widget_Posts_CLI_Command' ); + + register_shutdown_function( function () { + $last_error = error_get_last(); + if ( ! empty( $last_error ) && in_array( $last_error['type'], array( \E_ERROR, \E_USER_ERROR, \E_RECOVERABLE_ERROR ) ) ) { + \WP_CLI::warning( sprintf( '%s (type: %d, line: %d, file: %s)', $last_error['message'], $last_error['type'], $last_error['line'], $last_error['file'] ) ); + } + } ); + } + + if ( $this->is_enabled() ) { + $this->init(); + } + } + + /** + * Whether the functionality is enabled. + * + * @return bool Enabled. + */ + function is_enabled() { + return 'yes' === get_option( self::ENABLED_FLAG_OPTION_NAME ); + } + + /** + * Enable the functionality (for the next load). + * + * @return bool Whether it was able to update the enabled state. + */ + function enable() { + return update_option( self::ENABLED_FLAG_OPTION_NAME, 'yes' ); + } + + /** + * Disable the functionality (for the next load). + * + * @return bool Whether it was able to update the enabled state. + */ + function disable() { + return update_option( self::ENABLED_FLAG_OPTION_NAME, 'no' ); + } + + /** + * Add the hooks for the primary functionality. + */ + function init() { + add_action( 'widgets_init', array( $this, 'prepare_widget_data' ), 91 ); + add_action( 'init', array( $this, 'register_instance_post_type' ) ); + } + + /** + * Register the widget_instance post type. + * + * @action init + * @return object The post type object. + * @throws Exception + */ + function register_instance_post_type() { + + // Add required filters and actions for this post type. + $hooks = array( + 'wp_insert_post_data' => array( $this, 'preserve_content_filtered' ), + 'delete_post' => array( $this, 'flush_widget_instance_numbers_cache' ), + 'save_post_' . static::INSTANCE_POST_TYPE => array( $this, 'flush_widget_instance_numbers_cache' ), + 'export_wp' => array( $this, 'setup_export' ), + 'wp_import_post_data_processed' => array( $this, 'filter_wp_import_post_data_processed' ), + ); + foreach ( $hooks as $hook => $callback ) { + // Note that add_action() and has_action() is an aliases for add_filter() and has_filter() + if ( ! has_filter( $hook, $callback ) ) { + add_filter( $hook, $callback, 10, PHP_INT_MAX ); + } + } + + $post_type_object = get_post_type_object( static::INSTANCE_POST_TYPE ); + if ( $post_type_object ) { + return $post_type_object; + } + + $labels = array( + 'name' => _x( 'Widget Instances', 'post type general name', 'mandatory-widgets' ), + 'singular_name' => _x( 'Widget Instance', 'post type singular name', 'mandatory-widgets' ), + 'menu_name' => _x( 'Widget Instances', 'admin menu', 'mandatory-widgets' ), + 'name_admin_bar' => _x( 'Widget Instance', 'add new on admin bar', 'mandatory-widgets' ), + 'add_new' => _x( 'Add New', 'Widget', 'mandatory-widgets' ), + 'add_new_item' => __( 'Add New Widget Instance', 'mandatory-widgets' ), + 'new_item' => __( 'New Widget Instance', 'mandatory-widgets' ), + 'edit_item' => __( 'Edit Widget Instance', 'mandatory-widgets' ), + 'view_item' => __( 'View Widget Instance', 'mandatory-widgets' ), + 'all_items' => __( 'All Widget Instances', 'mandatory-widgets' ), + 'search_items' => __( 'Search Widget instances', 'mandatory-widgets' ), + 'not_found' => __( 'No widget instances found.', 'mandatory-widgets' ), + 'not_found_in_trash' => __( 'No widget instances found in Trash.', 'mandatory-widgets' ), + ); + + $args = array( + 'labels' => $labels, + 'public' => false, + 'capability_type' => static::INSTANCE_POST_TYPE, + 'map_meta_cap' => true, + 'hierarchical' => false, + 'delete_with_user' => false, + 'menu_position' => null, + 'supports' => array( 'none' ), // @todo 'revisions' when there is a UI. + ); + $r = register_post_type( static::INSTANCE_POST_TYPE, $args ); + if ( is_wp_error( $r ) ) { + throw new Exception( $r->get_error_message() ); + } + + return $r; + } + + /** + * When exporting widget instance posts from WordPress, export the post_content_filtered as the post_content. + * + * @see Widget_Posts::filter_wp_import_post_data_processed() + * + * @action export_wp + */ + function setup_export() { + add_action( 'the_post', function ( $post ) { + if ( static::INSTANCE_POST_TYPE === $post->post_type ) { + $post->post_content = $post->post_content_filtered; + } + } ); + } + + /** + * Restore post_content into post_content_filtered when importing via WordPress Importer plugin. + * + * @see Widget_Posts::setup_export() + * @filter wp_import_post_data_processed + * + * @param array $postdata + * @return array + */ + function filter_wp_import_post_data_processed( $postdata ) { + if ( static::INSTANCE_POST_TYPE === $postdata['post_type'] ) { + $postdata['post_content_filtered'] = $postdata['post_content']; + $instance = Widget_Posts::parse_post_content_filtered( $postdata['post_content'] ); + $postdata['post_content'] = $postdata['post_content'] = wp_json_encode( $instance, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );; + } + return $postdata; + } + + /** + * @see \WP_Widget::get_settings() + * + * @param string $id_base Widget ID Base. + * @param array $instances Mapping of widget numbers to instance arrays. + * @param array [$options] + * @throws Exception + */ + function import_widget_instances( $id_base, array $instances, array $options = array() ) { + if ( ! array_key_exists( $id_base, $this->widget_objs ) ) { + throw new Exception( "Unrecognized or unsupported widget type: $id_base" ); + } + $this->register_instance_post_type(); // In case the Widget Posts is not enabled, or init hasn't fired. + + $options = wp_parse_args( $options, array( + 'update' => false, + 'dry-run' => false, + ) ); + unset( $instances['_multiwidget'] ); + foreach ( $instances as $key => $instance ) { + try { + $widget_number = intval( $key ); + $widget_id = "$id_base-$widget_number"; + if ( ! $widget_number || $widget_number < 2 ) { + throw new Exception( "Expected array key to be integer >= 2, but got $key" ); + } + + if ( ! is_array( $instance ) ) { + throw new Exception( "Instance data for $widget_id is not an array." ); + } + + $existing_post = $this->get_widget_post( $widget_id ); + if ( ! $options['update'] && $existing_post ) { + do_action( 'widget_posts_import_skip_existing', compact( 'widget_id', 'instance', 'widget_number', 'id_base' ) ); + continue; + } + $update = ! empty( $existing_post ); + + $post = null; + if ( ! $options['dry-run'] ) { + // When importing we assume already sanitized so that the update() callback won't be called with an empty $old_instance. + $post = $this->update_widget( $widget_id, $instance, array( 'needs_sanitization' => false ) ); + } + do_action( 'widget_posts_import_success', compact( 'widget_id', 'post', 'instance', 'widget_number', 'id_base', 'update' ) ); + } catch ( Exception $exception ) { + do_action( 'widget_posts_import_failure', compact( 'widget_id', 'exception', 'instance', 'widget_number', 'id_base', 'update' ) ); + } + $this->plugin->stop_the_insanity(); + } + } + + /** + * Whether or not filter_pre_option_widget_settings() and + * filter_pre_update_option_widget_settings() should no-op. + * + * @see Widget_Posts::migrate_widgets_from_options() + * @see Widget_Posts::import_widget_instances() + * + * @var bool + */ + public $pre_option_filters_disabled = false; + + /** + * Import instances from each registered widget. + * + * @param array $options + */ + function migrate_widgets_from_options( $options = array() ) { + $options = wp_parse_args( $options, array( + 'update' => false, + ) ); + + if ( ! $this->plugin->is_running_unit_tests() && ! defined( 'WP_IMPORTING' ) ) { + define( 'WP_IMPORTING', true ); + } + $this->pre_option_filters_disabled = true; + foreach ( $this->widget_objs as $id_base => $widget_obj ) { + $instances = $widget_obj->get_settings(); // Note that $this->pre_option_filters_disabled must be true so this returns an array, not a Widget_Settings. + $this->import_widget_instances( $id_base, $instances, $options ); + } + $this->pre_option_filters_disabled = false; + } + + /** + * + * @action widgets_init, 90 + */ + function store_widget_objects() { + $this->widget_objs = array(); + foreach ( $this->plugin->widget_factory->widgets as $widget_obj ) { + /** @var \WP_Widget $widget_obj */ + if ( "widget_{$widget_obj->id_base}" !== $widget_obj->option_name ) { + continue; + } + $this->widget_objs[ $widget_obj->id_base ] = $widget_obj; + } + } + + /** + * This happens before Efficient_Multidimensional_Setting_Sanitizing::capture_widget_instance_data() + * so that we have a chance to inject Widget_Settings ArrayIterators populated with data + * from the widget_instance post type. + * + * @see Efficient_Multidimensional_Setting_Sanitizing::capture_widget_instance_data() + * @action widgets_init, 91 + */ + function prepare_widget_data() { + foreach ( $this->widget_objs as $id_base => $widget_obj ) { + add_filter( "pre_option_{$widget_obj->option_name}", array( $this, 'filter_pre_option_widget_settings' ), 20 ); + add_filter( "pre_update_option_{$widget_obj->option_name}", array( $this, 'filter_pre_update_option_widget_settings' ), 20, 2 ); + } + } + + /** + * Return the Widget_Settings ArrayObject on the pre_option filter for the widget option. + * + * @param null|array $pre + * + * Note that this is after 10 so it is compatible with WP_Customize_Widgets::capture_filter_pre_get_option() + * + * @see WP_Customize_Widgets::capture_filter_pre_get_option() + * @see Efficient_Multidimensional_Setting_Sanitizing::capture_widget_instance_data() + * @filter pre_option_{WP_Widget::$option_name}, 20 + * + * @return Widget_Settings|mixed + */ + function filter_pre_option_widget_settings( $pre ) { + $matches = array(); + $should_filter = ( + ! $this->pre_option_filters_disabled + && + preg_match( '/^pre_option_widget_(.+)/', current_filter(), $matches ) + ); + if ( ! $should_filter ) { + return $pre; + } + $id_base = $matches[1]; + + if ( false === $pre ) { + $instances = $this->get_widget_instance_numbers( $id_base ); // A.K.A. shallow widget instances. + $settings = new Widget_Settings( $instances ); + } else if ( is_array( $pre ) ) { + $settings = new Widget_Settings( $pre ); + } else if ( $pre instanceof Widget_Settings ) { + $settings = $pre; + } else { + return $pre; + } + + if ( has_filter( "option_widget_{$id_base}" ) ) { + $filtered_settings = apply_filters( "option_widget_{$id_base}", $settings ); + + // Detect when a filter blew away our nice ArrayIterator. + if ( $filtered_settings !== $settings ) { + $settings = new Widget_Settings( $filtered_settings ); + } + } + + return $settings; + } + + /** + * Note that this that this is designed to not conflict with Widget Customizer, + * and the capturing of update_option() calls. + * + * @see WP_Customize_Widgets::capture_filter_pre_update_option() + * + * @param Widget_Settings|array $value + * @param Widget_Settings|array $old_value + * @return array + */ + function filter_pre_update_option_widget_settings( $value, $old_value ) { + global $wp_customize; + + $is_widget_customizer_short_circuiting = ( + isset( $wp_customize ) + && + $wp_customize instanceof \WP_Customize_Manager + && + // Because we do not have access to $wp_customize->widgets->_is_capturing_option_updates. + has_filter( 'pre_update_option', array( $wp_customize->widgets, 'capture_filter_pre_update_option' ), 10, 3 ) + ); + if ( $is_widget_customizer_short_circuiting ) { + return $value; + } + + // Get literal arrays for comparison since two separate instances can never be identical (===) + $value_array = ( $value instanceof Widget_Settings ? $value->getArrayCopy() : $value ); + $old_value_array = ( $old_value instanceof Widget_Settings ? $old_value->getArrayCopy() : $old_value ); + + $matches = array(); + $should_filter = ( + ! $this->pre_option_filters_disabled + && + ( $value_array !== $old_value_array ) + && + ( $value instanceof Widget_Settings ) + && + preg_match( '/pre_update_option_widget_(.+)/', current_filter(), $matches ) + ); + if ( ! $should_filter ) { + return $value; + } + $id_base = $matches[1]; + $widget_settings = $value; + + $instances = $widget_settings->getArrayCopy(); + foreach ( $instances as $widget_number => $instance ) { + $widget_number = intval( $widget_number ); + if ( ! $widget_number || $widget_number < 2 || ! is_array( $instance ) ) { // Note that non-arrays are most likely widget_instance post IDs. + continue; + } + $widget_id = "$id_base-$widget_number"; + + // Note that sanitization isn't needed because the widget's update callback has already been called. + $this->update_widget( $widget_id, $instance, array( 'needs_sanitization' => false ) ); + // @todo catch exception. + } + + foreach ( $widget_settings->unset_widget_numbers as $widget_number ) { + $widget_id = "$id_base-$widget_number"; + $post = $this->get_widget_post( $widget_id ); + if ( $post ) { + // @todo eventually we should allow trashing + wp_delete_post( $post->ID, true ); + } + } + $widget_settings->unset_widget_numbers = array(); + + /* + * We return the old value so that update_option() short circuits, + * in the same way that WP_Customize_Widgets::start_capturing_option_updates() works. + * So note that we only do this if Widget Customizer isn't already short-circuiting things. + */ + return $old_value; + } + + /** + * Get all numbers for widget instances for the given $id_base mapped to the widget_instance post IDs. + * + * @param string $id_base + * + * @return int[] Mapping of widget instance numbers to post IDs. + */ + function get_widget_instance_numbers( $id_base ) { + /** @var \wpdb $wpdb */ + global $wpdb; + $numbers = wp_cache_get( $id_base, 'widget_instance_numbers' ); + if ( false === $numbers ) { + $post_type = static::INSTANCE_POST_TYPE; + $widget_id_hyphen_strpos = strlen( $id_base ) + 1; // Start at the last hyphen in the widget ID + + /* + * Get all widget numbers from widget_instance posts, where the post_name + * fields consist of $id_base-$widget_number, such as "text-123". + * Note that there is no LIMIT on this query, but it is reasoned that + * this is not a risk since the amount of data returned is very small (an array of digits), + * and we're interacting with post_type and post_name fields, which are both + * indexed in MySQL. Note also that the pattern in the post_name LIKE condition does + * not start with % so it will not do a full table scan. + */ + $results = $wpdb->get_results( $wpdb->prepare( + " + SELECT ID as post_id, SUBSTRING( post_name, %d + 1 ) as widget_number + FROM $wpdb->posts + WHERE + post_type = %s + AND + post_name LIKE %s + AND + SUBSTRING( post_name, %d + 1 ) REGEXP '^[0-9]+$' + ", + array( + $widget_id_hyphen_strpos, + $post_type, + $wpdb->esc_like( $id_base . '-' ) . '%', + $widget_id_hyphen_strpos, + ) + ) ); // WPCS: db call ok. + + $numbers = array(); + foreach ( $results as $result ) { + $numbers[ intval( $result->widget_number ) ] = intval( $result->post_id ); + } + + wp_cache_set( $id_base, $numbers, 'widget_instance_numbers' ); + } + + // Widget numbers start at 2, so ensure this is the case. + unset( $numbers[0] ); + unset( $numbers[1] ); + + return $numbers; + } + + /** + * Get the widget instance post associated with a given widget ID. + * + * @param string $widget_id + * @return \WP_Post|null + */ + function get_widget_post( $widget_id ) { + + $parsed_widget_id = $this->plugin->parse_widget_id( $widget_id ); + if ( ! $parsed_widget_id ) { + return null; + } + if ( ! $parsed_widget_id['widget_number'] || $parsed_widget_id['widget_number'] < 2 ) { + return null; + } + + // @todo it may be better to just do a SQL query here: $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type = %s AND post_name = %s", array( static::INSTANCE_POST_TYPE, $widget_id ) ) ) + // @todo we can do object cache for the widget ID to post ID lookup; remember to clear when the post gets deleted + + $post_stati = array_merge( + array( 'any' ), + array_values( get_post_stati( array( 'exclude_from_search' => true ) ) ) // otherwise, we could get duplicates + ); + + // Force is_single and is_page to be false, so that draft and scheduled posts can be queried by name + $married = function ( $q ) { + $q->is_single = false; + }; + add_action( 'pre_get_posts', $married ); + + $query = new \WP_Query( array( + 'post_status' => $post_stati, + 'posts_per_page' => 1, + 'post_type' => static::INSTANCE_POST_TYPE, + 'name' => $widget_id, + ) ); + + remove_action( 'pre_get_posts', $married ); + + $post = array_shift( $query->posts ); + return $post; + } + + /** + * Get the instance data associated with a widget post. + * + * @param int|\WP_Post|string $post Widget ID, post, or widget_ID string. + * @return array + */ + function get_widget_instance_data( $post ) { + if ( is_string( $post ) ) { + $post = $this->get_widget_post( $post ); + } else { + $post = get_post( $post ); + } + + if ( empty( $post ) ) { + $instance = array(); + } else { + $instance = static::get_post_content_filtered( $post ); + } + return $instance; + } + + /** + * Encode a widget instance array for storage in post_content_filtered. + * + * We use base64-encoding to prevent WordPress slashing to corrupt the + * serialized string. + * + * @param array $instance + * @return string base64-encoded PHP-serialized string + */ + static function encode_post_content_filtered( array $instance ) { + return base64_encode( serialize( $instance ) ); + } + + /** + * Parse the post_content_filtered, which is a base64-encoded PHP-serialized string. + * + * @param \WP_Post $post + * @return array + */ + static function get_post_content_filtered( \WP_Post $post ) { + if ( static::INSTANCE_POST_TYPE !== $post->post_type ) { + return array(); + } + if ( empty( $post->post_content_filtered ) ) { + return array(); + } + return static::parse_post_content_filtered( $post->post_content_filtered ); + } + + /** + * Parse the post_content_filtered from its base64-encodd PHP-serialized string. + * + * @param string $post_content_filtered + * + * @return array + */ + static function parse_post_content_filtered( $post_content_filtered ) { + $decoded_instance = base64_decode( $post_content_filtered, true ); + if ( false !== $decoded_instance ) { + $instance = unserialize( $decoded_instance ); + } else if ( is_serialized( $post_content_filtered, true ) ) { + $instance = unserialize( $post_content_filtered ); + } else { + $instance = array(); + } + if ( ! is_array( $instance ) ) { + $instance = array(); + } + return $instance; + } + + /** + * Sanitize a widget's instance array by passing it through the widget's update callback. + * + * @see \WP_Widget::update() + * + * @param string $id_base + * @param array $new_instance + * @param array $old_instance + * @return array + * + * @throws Exception + */ + public function sanitize_instance( $id_base, $new_instance, $old_instance = array() ) { + if ( ! array_key_exists( $id_base, $this->widget_objs ) ) { + throw new Exception( "Unrecognized widget id_base: $id_base" ); + } + if ( ! is_array( $new_instance ) ) { + throw new Exception( 'new_instance data must be an array' ); + } + if ( ! is_array( $old_instance ) ) { + throw new Exception( 'old_instance data must be an array' ); + } + $widget_obj = $this->widget_objs[ $id_base ]; + // @codingStandardsIgnoreStart + $instance = @ $widget_obj->update( $new_instance, $old_instance ); // silencing errors because we can get undefined index notices + // @codingStandardsIgnoreEnd + return $instance; + } + + /** + * Create a new widget instance. + * + * @param string $id_base + * @param array $instance + * @param array [$options] { + * @type bool $needs_sanitization + * } + * @return \WP_Post + * + * @throws Exception + */ + function insert_widget( $id_base, $instance = array(), $options = array() ) { + if ( ! array_key_exists( $id_base, $this->widget_objs ) ) { + throw new Exception( "Unrecognized widget id_base: $id_base" ); + } + $options = wp_parse_args( $options, array( + 'needs_sanitization' => true, + ) ); + + if ( $options['needs_sanitization'] ) { + $instance = $this->sanitize_instance( $id_base, $instance ); + } + + $widget_number = $this->plugin->widget_number_incrementing->incr_widget_number( $id_base ); + $post_arr = array( + 'post_name' => "$id_base-$widget_number", + 'post_content' => wp_json_encode( $instance, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), // For search indexing and post revision UI. + 'post_content_filtered' => static::encode_post_content_filtered( $instance ), + 'post_status' => 'publish', + 'post_type' => static::INSTANCE_POST_TYPE, + ); + $r = wp_insert_post( $post_arr, true ); + if ( is_wp_error( $r ) ) { + throw new Exception( sprintf( + 'Failed to insert/update widget instance "%s": %s', + $post_arr['post_name'], + $r->get_error_code() + ) ); + } + $post = get_post( $r ); + return $post; + } + + /** + * Update an existing widget. + * + * @param string $widget_id + * @param array $instance + * @param array [$options] { + * @type bool $needs_sanitization + * } + * @throws Exception + * @return \WP_Post + */ + function update_widget( $widget_id, $instance = array(), $options = array() ) { + $options = wp_parse_args( $options, array( + 'needs_sanitization' => true, + ) ); + + $parsed_widget_id = $this->plugin->parse_widget_id( $widget_id ); + if ( empty( $parsed_widget_id ) ) { + throw new Exception( "Invalid widget_id: $widget_id" ); + } + if ( ! $parsed_widget_id['widget_number'] || $parsed_widget_id['widget_number'] < 2 ) { + throw new Exception( "Widgets must start numbering at 2: $widget_id" ); + } + + $post_id = null; + $post = $this->get_widget_post( $widget_id ); + if ( $post ) { + $post_id = $post->ID; + } + if ( $options['needs_sanitization'] ) { + if ( $post ) { + $old_instance = $this->get_widget_instance_data( $post ); + } else { + $old_instance = array(); + } + $instance = $this->sanitize_instance( $parsed_widget_id['id_base'], $instance, $old_instance ); + } + + // Make sure that we have the max stored. + $this->plugin->widget_number_incrementing->set_widget_number( $parsed_widget_id['id_base'], $parsed_widget_id['widget_number'] ); + + $post_arr = array( + 'post_title' => ! empty( $instance['title'] ) ? $instance['title'] : '', + 'post_name' => $widget_id, + 'post_content' => wp_json_encode( $instance, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), // For search indexing and post revision UI. + 'post_content_filtered' => static::encode_post_content_filtered( $instance ), + 'post_status' => 'publish', + 'post_type' => static::INSTANCE_POST_TYPE, + ); + if ( $post_id ) { + $post_arr['ID'] = $post_id; + } + + $r = wp_insert_post( $post_arr, true ); + if ( is_wp_error( $r ) ) { + throw new Exception( sprintf( + 'Failed to insert/update widget instance "%s": %s', + $post_arr['post_name'], + $r->get_error_code() + ) ); + } + $post = get_post( (int) $r ); + return $post; + } + + /** + * Make sure that wp_update_post() doesn't clear out our content. + * + * @param array $data An array of slashed post data. + * @param array $postarr An array of sanitized, but otherwise unmodified post data. + * @return array + * + * @filter wp_insert_post_data + */ + function preserve_content_filtered( $data, $postarr ) { + $should_preserve = ( + ! empty( $postarr['ID'] ) + && + static::INSTANCE_POST_TYPE === $postarr['post_type'] + && + empty( $data['post_content_filtered'] ) + ); + if ( $should_preserve ) { + $previous_content_filtered = get_post( $postarr['ID'] )->post_content_filtered; + $data['post_content_filtered'] = $previous_content_filtered; + } + return $data; + } + + /** + * Flush the widget instance numbers cache when a post is updated or deleted. + * + * @param int $post_id + * @action save_post_widget_instance + * @action delete_post + */ + function flush_widget_instance_numbers_cache( $post_id ) { + $post = get_post( $post_id ); + if ( static::INSTANCE_POST_TYPE !== $post->post_type ) { + return; + } + $parsed_widget_id = $this->plugin->parse_widget_id( $post->post_name ); + if ( empty( $parsed_widget_id['id_base'] ) ) { + return; + } + wp_cache_delete( $parsed_widget_id['id_base'], 'widget_instance_numbers' ); + } + +} diff --git a/php/class-widget-settings.php b/php/class-widget-settings.php new file mode 100644 index 0000000..9cdc8b3 --- /dev/null +++ b/php/class-widget-settings.php @@ -0,0 +1,152 @@ +get_settings(); + * \WP_Widget::update_callback(): $this->save_settings($all_instances); + * \WP_Widget::form_callback(): $all_instances = $this->get_settings(); + * \WP_Widget::_register(): if ( is_array($settings) ) { + * \WP_Widget::_register(): foreach ( array_keys($settings) as $number ) { + * \WP_Widget::display_callback(): $instance = $this->get_settings(); + * + * @package CustomizeWidgetsPlus + */ +class Widget_Settings extends \ArrayIterator { + + /** + * Keep track of all widget instances that were unset, as they will be deleted. + * + * @var int[] $unset_widget_numbers + */ + public $unset_widget_numbers = array(); + + /** + * @param array $array + * @throws Exception + */ + function __construct( $array ) { + // Widget numbers start at 2. + unset( $array[0] ); + unset( $array[1] ); + + parent::__construct( $array ); + } + + /** + * \WP_Widget::update_callback(): $old_instance = isset($all_instances[$number]) ? $all_instances[$number] : array(); + * \WP_Widget::display_callback(): if ( array_key_exists( $this->number, $instance ) ) { + * \WP_Widget::get_settings(): if ( !empty($settings) && !array_key_exists('_multiwidget', $settings) ) { + * + * @param int|string $key Array key. + * @return bool + */ + public function offsetExists( $key ) { + if ( '_multiwidget' === $key ) { + return true; + } + return parent::offsetExists( $key ); + } + + /** + * \WP_Widget::update_callback(): $old_instance = isset($all_instances[$number]) ? $all_instances[$number] : array(); + * \WP_Widget::display_callback(): $instance = $instance[$this->number]; + * \WP_Widget::form_callback(): $instance = $all_instances[ $widget_args['number'] ]; + * + * @param int|string $key Array key. + * @return array|int|null + */ + public function offsetGet( $key ) { + if ( '_multiwidget' === $key ) { + return 1; + } + if ( ! $this->offsetExists( $key ) ) { + return null; + } + $value = parent::offsetGet( $key ); + if ( is_int( $value ) ) { + // Fetch the widget post_content_filtered and store it in the array. + $post = get_post( $value ); + $value = Widget_Posts::get_post_content_filtered( $post ); + $this->offsetSet( $key, $value ); + } + return $value; + } + + /** + * \WP_Widget::update_callback(): $all_instances[$number] = $instance; + * \WP_Widget::save_settings(): $settings['_multiwidget'] = 1; + * + * @param int|string $key Array key. + * @param mixed $value The array item value. + */ + public function offsetSet( $key, $value ) { + if ( '_multiwidget' === $key || '__i__' === $key ) { + return; + } + $key = filter_var( $key, FILTER_VALIDATE_INT ); + if ( ! is_int( $key ) ) { + // @todo _doing_it_wrong()? + return; + } + if ( $key < 2 ) { + // @todo _doing_it_wrong()? + return; + } + if ( ! is_array( $value ) ) { + // @todo _doing_it_wrong()? + return; + } + parent::offsetSet( $key, $value ); + } + + /** + * \WP_Widget::update_callback(): unset($all_instances[$number]); + * \WP_Widget::get_settings(): unset($settings['_multiwidget'], $settings['__i__']); + * + * @param int|string $key Array key. + */ + public function offsetUnset( $key ) { + if ( '_multiwidget' === $key || '__i__' === $key ) { + return; + } + $key = filter_var( $key, FILTER_VALIDATE_INT ); + if ( $key < 2 ) { + return; + } + $this->unset_widget_numbers[] = $key; + parent::offsetUnset( $key ); + } + + /** + * @return array + */ + public function current() { + return $this->offsetGet( $this->key() ); + } + + /** + * Serialize the settings into an array. + * + * @return string + */ + public function serialize() { + return serialize( $this->getArrayCopy() ); + } + +} diff --git a/php/class-wp-customize-widget-setting.php b/php/class-wp-customize-widget-setting.php index 70e333c..620c83d 100644 --- a/php/class-wp-customize-widget-setting.php +++ b/php/class-wp-customize-widget-setting.php @@ -1,28 +1,40 @@ widget_(?P.+?))(?:\[(?P\d+)\])?$/'; /** + * Setting type. + * * @var string */ public $type = 'widget'; /** + * Widget ID Base. + * + * @see \WP_Widget::$id_base + * * @var string */ public $widget_id_base; /** + * The multi widget number for this setting. + * * @var int */ public $widget_number; @@ -37,6 +49,8 @@ class WP_Customize_Widget_Setting extends \WP_Customize_Setting { public $is_previewed = false; /** + * Plugin's instance of Efficient_Multidimensional_Setting_Sanitizing. + * * @var Efficient_Multidimensional_Setting_Sanitizing */ public $efficient_multidimensional_setting_sanitizing; @@ -46,13 +60,13 @@ class WP_Customize_Widget_Setting extends \WP_Customize_Setting { * * Any supplied $args override class property defaults. * - * @param \WP_Customize_Manager $manager - * @param string $id An specific ID of the setting. Can be a - * theme mod or option name. - * @param array $args Setting arguments. - * @throws Exception if $id is not valid for a widget + * @param \WP_Customize_Manager $manager Manager instance. + * @param string $id An specific ID of the setting. Can be a + * theme mod or option name. + * @param array $args Setting arguments. + * @throws Exception If $id is not valid for a widget. */ - public function __construct( $manager, $id, $args = array() ) { + public function __construct( \WP_Customize_Manager $manager, $id, array $args = array() ) { unset( $args['type'] ); if ( empty( $manager->efficient_multidimensional_setting_sanitizing ) ) { @@ -63,93 +77,109 @@ public function __construct( $manager, $id, $args = array() ) { if ( ! preg_match( static::WIDGET_SETTING_ID_PATTERN, $id, $matches ) ) { throw new Exception( "Illegal widget setting ID: $id" ); } - // @todo validate that the $id_base is for a valid WP_Widget? $this->widget_id_base = $matches['widget_id_base']; + if ( isset( $matches['widget_number'] ) ) { $this->widget_number = intval( $matches['widget_number'] ); } - if ( ! array_key_exists( $this->widget_id_base, $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values ) ) { - $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ] = get_option( "widget_{$this->widget_id_base}", array() ); - add_filter( "pre_option_widget_{$this->widget_id_base}", array( $this, 'filter_pre_option_widget_settings' ) ); - } - parent::__construct( $manager, $id, $args ); } - /** - * Pre-option filter which intercepts the expensive WP_Customize_Setting::_preview_filter(). - * - * @see WP_Customize_Setting::_preview_filter() - * @param null|array $pre_value - * @return array - */ - function filter_pre_option_widget_settings( $pre_value ) { - if ( ! $this->efficient_multidimensional_setting_sanitizing->disabled_filtering_pre_option_widget_settings ) { - $pre_value = $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ]; - } - return $pre_value; - } - /** * Get the instance data for a given widget setting. * * @return string + * @throws Exception */ public function value() { $value = $this->default; - if ( array_key_exists( $this->widget_number, $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ] ) ) { - $value = $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ]; + $sanitizing = $this->efficient_multidimensional_setting_sanitizing; + if ( ! isset( $sanitizing->current_widget_type_values[ $this->widget_id_base ] ) ) { + throw new Exception( "current_widget_type_values not set yet for $this->widget_id_base. Current action: " . current_action() ); + } + if ( isset( $sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ] ) ) { + $value = $sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ]; } return $value; } /** * Handle previewing the widget setting. + * + * Because this can be called very early in the WordPress execution flow, as + * early as after_setup_theme, if widgets_init hasn't been called yet then + * the preview logic is deferred to the widgets_init action. + * + * @see WP_Customize_Widget_Setting::apply_preview() + * + * @return void */ public function preview() { if ( $this->is_previewed ) { return; } + $this->is_previewed = true; + + if ( did_action( 'widgets_init' ) ) { + $this->apply_preview(); + } else { + $priority = 93; // Because Efficient_Multidimensional_Setting_Sanitizing::capture_widget_instance_data() happens at 92. + add_action( 'widgets_init', array( $this, 'apply_preview' ), $priority ); + } + } + + /** + * Optionally-deferred continuation logic from the preview method. + * + * @see WP_Customize_Widget_Setting::preview() + * + * @throws Exception + * @return void + */ + public function apply_preview() { if ( ! isset( $this->_original_value ) ) { $this->_original_value = $this->value(); } if ( ! isset( $this->_previewed_blog_id ) ) { $this->_previewed_blog_id = get_current_blog_id(); } + $sanitizing = $this->efficient_multidimensional_setting_sanitizing; $value = $this->post_value(); $is_null_because_previewing_new_widget = ( is_null( $value ) && $this->manager->doing_ajax( 'update-widget' ) && - isset( $_REQUEST['widget-id'] ) // input var okay + isset( $_REQUEST['widget-id'] ) // WPCS: input var okay. && - ( $this->id === $this->manager->widgets->get_setting_id( wp_unslash( sanitize_text_field( $_REQUEST['widget-id'] ) ) ) ) // input var okay + ( $this->id === $this->manager->widgets->get_setting_id( wp_unslash( sanitize_text_field( $_REQUEST['widget-id'] ) ) ) ) // WPCS: input var okay. ); if ( $is_null_because_previewing_new_widget ) { $value = array(); } + $sanitizing = $this->efficient_multidimensional_setting_sanitizing; if ( ! is_null( $value ) ) { - $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ] = $value; + $sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ] = $value; } - $this->is_previewed = true; } /** * Save the value of the widget setting. * - * @param array $value The value to update. - * @return mixed The result of saving the value. + * @param array|mixed $value The value to update. + * @return void */ protected function update( $value ) { + // @todo Better $this->efficient_multidimensional_setting_sanitizing->widget_objs[ $this->widget_id_base ]->get_settings() $option_name = "widget_{$this->widget_id_base}"; $option_value = get_option( $option_name, array() ); $option_value[ $this->widget_number ] = $value; update_option( $option_name, $option_value ); if ( ! $this->is_previewed ) { - $this->efficient_multidimensional_setting_sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ] = $value; + $sanitizing = $this->efficient_multidimensional_setting_sanitizing; + $sanitizing->current_widget_type_values[ $this->widget_id_base ][ $this->widget_number ] = $value; } } } diff --git a/tests/test-class-widget-posts.php b/tests/test-class-widget-posts.php new file mode 100644 index 0000000..a329c72 --- /dev/null +++ b/tests/test-class-widget-posts.php @@ -0,0 +1,118 @@ +plugin->widget_number_incrementing = new Widget_Number_Incrementing( $this->plugin ); + } + + function init_customizer() { + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + $this->wp_customize_manager = new \WP_Customize_Manager(); + } + + /** + * @see Widget_Posts::__construct() + */ + function test_construct_unmigrated() { + $this->assertNull( get_option( Widget_Posts::ENABLED_FLAG_OPTION_NAME, null ) ); + $instance = new Widget_Posts( $this->plugin ); + $this->assertEquals( 'no', get_option( Widget_Posts::ENABLED_FLAG_OPTION_NAME, null ) ); + wp_widgets_init(); + $this->assertNotEmpty( $instance->widget_objs ); + $this->assertFalse( has_action( 'widgets_init', array( $instance, 'prepare_widget_data' ) ) ); + } + + /** + * @see Widget_Posts::__construct() + */ + function test_construct_migrated() { + update_option( Widget_Posts::ENABLED_FLAG_OPTION_NAME, 'yes' ); + $instance = new Widget_Posts( $this->plugin ); + $this->assertEquals( 90, has_action( 'widgets_init', array( $instance, 'store_widget_objects' ) ) ); + } + + /** + * @see Widget_Posts::init() + */ + function test_init() { + $instance = new Widget_Posts( $this->plugin ); + $instance->init(); + $this->assertEquals( 90, has_action( 'widgets_init', array( $instance, 'store_widget_objects' ) ) ); + $this->assertEquals( 91, has_action( 'widgets_init', array( $instance, 'prepare_widget_data' ) ) ); + $this->assertEquals( 10, has_action( 'init', array( $instance, 'register_instance_post_type' ) ) ); + } + + /** + * @see Widget_Posts::register_instance_post_type() + */ + function test_register_instance_post_type() { + $instance = new Widget_Posts( $this->plugin ); + $post_type_obj = $instance->register_instance_post_type(); + $this->assertInternalType( 'object', $post_type_obj ); + $this->assertNotEmpty( get_post_type_object( Widget_Posts::INSTANCE_POST_TYPE ) ); + $this->assertEquals( 10, has_filter( 'wp_insert_post_data', array( $instance, 'preserve_content_filtered' ) ) ); + $this->assertEquals( 10, has_action( 'delete_post', array( $instance, 'flush_widget_instance_numbers_cache' ) ) ); + $this->assertEquals( 10, has_action( 'save_post_' . Widget_Posts::INSTANCE_POST_TYPE, array( $instance, 'flush_widget_instance_numbers_cache' ) ) ); + } + + /** + * @see Widget_Posts::migrate_widgets_from_options() + */ + function test_migrate_widgets_from_options() { + $id_base = 'meta'; + $original_instances = get_option( "widget_{$id_base}" ); + unset( $original_instances['_multiwidget'] ); + + $instance = new Widget_Posts( $this->plugin ); + $instance->init(); + wp_widgets_init(); + $instance->register_instance_post_type(); + + $this->assertEmpty( $instance->get_widget_instance_numbers( $id_base ) ); + $instance->migrate_widgets_from_options(); + // @todo this should be getting called! wp_cache_delete( $id_base, 'widget_instance_numbers' ); + $this->assertGreaterThan( 0, did_action( 'widget_posts_import_success' ) ); + $shallow_instances = $instance->get_widget_instance_numbers( $id_base ); + $this->assertNotEmpty( $shallow_instances ); + + $this->assertEqualSets( array_keys( $original_instances ), array_keys( $shallow_instances ) ); + + foreach ( $shallow_instances as $widget_number => $post_id ) { + $this->assertInternalType( 'int', $widget_number ); + $this->assertInternalType( 'int', $post_id ); + + $post = get_post( $post_id ); + $this->assertEquals( Widget_Posts::INSTANCE_POST_TYPE, $post->post_type ); + $this->assertEquals( "$id_base-$widget_number", $post->post_name ); + + $this->assertEquals( $original_instances[ $widget_number ], Widget_Posts::get_post_content_filtered( $post ) ); + } + + // Before any Widget_Settings::offsetGet() gets called, try iterating + $settings = new Widget_Settings( $shallow_instances ); + foreach ( $settings as $widget_number => $hydrated_instance ) { + $this->assertEquals( $original_instances[ $widget_number ], $hydrated_instance ); + } + + // Now make sure that offsetGet() also does the right thing. + $settings = new Widget_Settings( $shallow_instances ); + foreach ( $original_instances as $widget_number => $original_instance ) { + $this->assertArrayHasKey( $widget_number, $settings ); + $this->assertEquals( $original_instance, $settings[ $widget_number ] ); + } + + $this->assertEquals( 0, did_action( 'update_option_widget_meta' ), 'Expected update_option( "widget_meta" ) to short-circuit.' ); + } + +} diff --git a/tests/test-class-widget-settings.php b/tests/test-class-widget-settings.php new file mode 100644 index 0000000..5f5d231 --- /dev/null +++ b/tests/test-class-widget-settings.php @@ -0,0 +1,114 @@ +plugin->widget_number_incrementing = new Widget_Number_Incrementing( $this->plugin ); + $this->plugin->widget_posts = new Widget_Posts( $this->plugin ); + wp_widgets_init(); + } + + /** + * @param string $id_base + * @param int $count + * + * @return Widget_Settings + */ + function create_widget_settings( $id_base, $count = 3 ) { + $instances = array(); + for ( $i = 2; $i < 2 + $count; $i += 1 ) { + $post = $this->plugin->widget_posts->insert_widget( $id_base, array( + 'title' => "Hello world for widget_posts $i", + ) ); + $instances[ $i ] = $post->ID; + } + $settings = new Widget_Settings( $instances ); + return $settings; + } + + /** + * @see Widget_Settings::offsetGet() + */ + function test_offset_get() { + $settings = $this->create_widget_settings( 'meta', 3 ); + $shallow_settings = $settings->getArrayCopy(); + foreach ( $shallow_settings as $widget_number => $post_id ) { + $this->assertInternalType( 'int', $widget_number ); + $this->assertInternalType( 'int', $post_id ); + } + + $this->assertEquals( 1, $settings['_multiwidget'] ); + $this->assertNull( $settings[100] ); + $instance = $settings[2]; + $this->assertInternalType( 'array', $instance ); + $this->assertContains( 'widget_posts', $instance['title'] ); + + foreach ( array_keys( $shallow_settings ) as $widget_number ) { + $instance = $settings[ $widget_number ]; + $this->assertInternalType( 'array', $instance ); + $this->assertContains( 'widget_posts', $instance['title'] ); + } + } + + /** + * @see Widget_Settings::offsetSet() + */ + function test_offset_set() { + $settings = $this->create_widget_settings( 'meta', 3 ); + $before = $settings->getArrayCopy(); + $settings['_multiwidget'] = 1; + $this->assertEquals( $before, $settings->getArrayCopy() ); + + $before = $settings->getArrayCopy(); + $settings['sdasd'] = array( 'title' => 'as' ); + $this->assertEquals( $before, $settings->getArrayCopy() ); + + $before = $settings->getArrayCopy(); + $settings[4] = 'asdasd'; + $this->assertEquals( $before, $settings->getArrayCopy() ); + + $before = $settings->getArrayCopy(); + $settings[50] = array( 'title' => 'Set' ); + $this->assertNotEquals( $before, $settings->getArrayCopy() ); + } + + /** + * @see Widget_Settings::offsetExists() + */ + function test_exists() { + $settings = $this->create_widget_settings( 'meta', 3 ); + $this->assertFalse( isset( $settings[1000] ) ); + $this->assertTrue( isset( $settings['_multiwidget'] ) ); + $this->assertTrue( isset( $settings[2] ) ); + $this->assertFalse( isset( $settings[100] ) ); + } + + /** + * @see Widget_Settings::offsetUnset() + */ + function test_offset_unset() { + $settings = $this->create_widget_settings( 'meta', 3 ); + $this->assertTrue( isset( $settings['_multiwidget'] ) ); + $before = $settings->getArrayCopy(); + unset( $settings['_multiwidget'] ); // A no-op. + $this->assertTrue( isset( $settings['_multiwidget'] ) ); + $this->assertEquals( $before, $settings->getArrayCopy() ); + } + + /** + * @see Widget_Settings::current() + */ + function test_current() { + $settings = $this->create_widget_settings( 'meta', 3 ); + $shallow_settings = $settings->getArrayCopy(); + + foreach ( $settings as $widget_number => $instance ) { + $this->assertInternalType( 'array', $instance ); + $this->assertContains( 'widget_posts', $instance['title'] ); + $this->assertEquals( $instance, Widget_Posts::get_post_content_filtered( get_post( $shallow_settings[ $widget_number ] ) ) ); + } + } +} diff --git a/tests/test-core-customize-widgets-with-widget-posts.php b/tests/test-core-customize-widgets-with-widget-posts.php new file mode 100644 index 0000000..02ea4d3 --- /dev/null +++ b/tests/test-core-customize-widgets-with-widget-posts.php @@ -0,0 +1,54 @@ +plugin = new Plugin(); + $this->plugin->widget_factory = $wp_widget_factory; + $this->plugin->widget_number_incrementing = new Widget_Number_Incrementing( $this->plugin ); + $this->plugin->widget_posts = new Widget_Posts( $this->plugin ); + $this->plugin->efficient_multidimensional_setting_sanitizing = new Efficient_Multidimensional_Setting_Sanitizing( $this->plugin, $this->manager ); + + $widgets_init_hook = 'widgets_init'; + $callable = array( $wp_widget_factory, '_register_widgets' ); + $priority = has_action( $widgets_init_hook, $callable ); + if ( false !== $priority ) { + remove_action( $widgets_init_hook, $callable, $priority ); + } + wp_widgets_init(); + if ( false !== $priority ) { + add_action( $widgets_init_hook, $callable, $priority ); + } + + $this->plugin->widget_posts->enable(); + $this->plugin->widget_posts->migrate_widgets_from_options(); + $this->plugin->widget_posts->init(); + $this->plugin->widget_posts->prepare_widget_data(); // Has to be called here because of wp_widgets_init() footwork done above. + $this->plugin->widget_posts->register_instance_post_type(); // Normally called at init action. + } + + function test_register_settings() { + wp_widgets_init(); + parent::test_register_settings(); + $this->assertInstanceOf( __NAMESPACE__ . '\\WP_Customize_Widget_Setting', $this->manager->get_setting( 'widget_categories[2]' ) ); + $this->assertEquals( 'widget', $this->manager->get_setting( 'widget_categories[2]' )->type ); + + $this->assertInstanceOf( __NAMESPACE__ . '\\Widget_Settings', get_option( 'widget_categories' ) ); + } + +} diff --git a/tests/test-core-widgets-with-widget-posts.php b/tests/test-core-widgets-with-widget-posts.php new file mode 100644 index 0000000..4c49400 --- /dev/null +++ b/tests/test-core-widgets-with-widget-posts.php @@ -0,0 +1,99 @@ +plugin = new Plugin(); + $this->plugin->widget_factory = $wp_widget_factory; + $this->plugin->widget_number_incrementing = new Widget_Number_Incrementing( $this->plugin ); + $this->plugin->widget_posts = new Widget_Posts( $this->plugin ); + + $widgets_init_hook = 'widgets_init'; + $callable = array( $wp_widget_factory, '_register_widgets' ); + $priority = has_action( $widgets_init_hook, $callable ); + if ( false !== $priority ) { + remove_action( $widgets_init_hook, $callable, $priority ); + } + wp_widgets_init(); + if ( false !== $priority ) { + add_action( $widgets_init_hook, $callable, $priority ); + } + + $this->plugin->widget_posts->migrate_widgets_from_options(); + $this->plugin->widget_posts->init(); + $this->plugin->widget_posts->prepare_widget_data(); // Has to be called here because of wp_widgets_init() footwork done above. + $this->plugin->widget_posts->register_instance_post_type(); // Normally called at init action. + } + + /** + * @see \Tests_Widgets::test_wp_widget_get_settings() + * @link https://github.com/xwp/wordpress-develop/pull/85 + */ + function test_wp_widget_get_settings() { + if ( ! method_exists( '\Tests_Widgets', 'test_wp_widget_get_settings' ) ) { + $this->markTestSkipped( 'Test requires Core patch from https://github.com/xwp/wordpress-develop/pull/85 to be applied.' ); + return; + } + + add_filter( 'pre_option_widget_search', function ( $value ) { + $this->assertNotFalse( $value, 'Expected widget_search option to have been short-circuited.' ); + return $value; + }, 1000 ); + + parent::test_wp_widget_get_settings(); + } + + /** + * @see \Tests_Widgets::test_wp_widget_save_settings() + * @link https://github.com/xwp/wordpress-develop/pull/85 + */ + function test_wp_widget_save_settings() { + if ( ! method_exists( '\Tests_Widgets', 'test_wp_widget_save_settings' ) ) { + $this->markTestSkipped( 'Test requires Core patch from https://github.com/xwp/wordpress-develop/pull/85 to be applied.' ); + return; + } + + parent::test_wp_widget_save_settings(); + + $this->assertEquals( 0, did_action( 'update_option_widget_search' ), 'Expected update_option( "widget_meta" ) to short-circuit.' ); + } + + /** + * @see \Tests_Widgets::test_wp_widget_save_settings_delete() + * @link https://github.com/xwp/wordpress-develop/pull/85 + */ + function test_wp_widget_save_settings_delete() { + if ( ! method_exists( '\Tests_Widgets', 'test_wp_widget_save_settings_delete' ) ) { + $this->markTestSkipped( 'Test requires Core patch from https://github.com/xwp/wordpress-develop/pull/85 to be applied.' ); + return; + } + + $deleted_widget_id = null; + add_action( 'delete_post', function ( $post_id ) use ( &$deleted_widget_id ) { + $post = get_post( $post_id ); + $this->assertEquals( Widget_Posts::INSTANCE_POST_TYPE, $post->post_type ); + $deleted_widget_id = $post->post_name; + }, 10, 2 ); + + parent::test_wp_widget_save_settings_delete(); + + $this->assertEquals( 'search-2', $deleted_widget_id ); + } + +}