whoami7 - Manager
:
/
home
/
ezdajrnh
/
www
/
wp-content
/
plugins
/
woocommerce
/
src
/
Internal
/
Features
/
Upload File:
files >> //home/ezdajrnh/www/wp-content/plugins/woocommerce/src/Internal/Features/FeaturesController.php
<?php /** * FeaturesController class file */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Features; use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview; use WC_Tracks; use WC_Site_Tracking; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Internal\Admin\Analytics; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Utilities\ArrayUtil; use Automattic\WooCommerce\Utilities\PluginUtil; use Automattic\WooCommerce\Enums\FeaturePluginCompatibility; defined( 'ABSPATH' ) || exit; /** * Class to define the WooCommerce features that can be enabled and disabled by admin users, * provides also a mechanism for WooCommerce plugins to declare that they are compatible * (or incompatible) with a given feature. * * Note: the 'woocommerce_register_feature_definitions' hook allows registering new features * externally. This hook is deprecated, features should be registered from within get_feature_definitions. * However, in case you use it for testing purposes, keep in mind that the hook is fired from inside 'init'; * therefore, features that need to be queried, enabled, or disabled before 'init' (e.g. during WP CLI initialization) * can't be registered using the hook. */ class FeaturesController { public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed'; public const PLUGINS_COMPATIBLE_BY_DEFAULT_OPTION = 'woocommerce_plugins_are_compatible_with_features_by_default'; /** * The existing feature definitions. * * @var array[] */ private $features = array(); /** * The registered compatibility info for WooCommerce plugins, with plugin names as keys. * * @var array */ private $compatibility_info_by_plugin = array(); /** * The registered compatibility info for WooCommerce plugins, with feature ids as keys. * * @var array */ private $compatibility_info_by_feature = array(); /** * Pending compatibility declarations. Format is [feature_id, plugin_file, positive_compatibility]. * * @var array */ private $pending_declarations = array(); /** * The LegacyProxy instance to use. * * @var LegacyProxy */ private $proxy; /** * The PluginUtil instance to use. * * @var PluginUtil */ private $plugin_util; /** * Flag indicating that features will be enableable from the settings page * even when they are incompatible with active plugins. * * @var bool */ private $force_allow_enabling_features = false; /** * Flag indicating that plugins will be activable from the plugins page * even when they are incompatible with enabled features. * * @var bool */ private $force_allow_enabling_plugins = false; /** * List of plugins excluded from feature compatibility warnings in UI. * * @var string[] */ private $plugins_excluded_from_compatibility_ui; /** * Flag indicating if additional features have been registered already * via woocommerce_register_feature_definitions action. * * @var bool */ private bool $registered_additional_features_via_action = false; /** * Flag indicating if additional features have been registered already * via calls to other classes. * * @var bool */ private bool $registered_additional_features_via_class_calls = false; /** * Flag indicating if we are currently delaying plugin normalization. * * @var bool */ private bool $lazy = true; /** * Creates a new instance of the class. */ public function __construct() { // In principle, register_additional_features is triggered manually from within class-woocommerce // right before before_woocommerce_init is fired (this is needed for the features to be visible // to plugins executing declare_compatibility). // However we add additional checks/hookings here to support unit tests and possible overlooked/future // DI container/class instantiation nuances. if ( ! $this->registered_additional_features_via_action ) { if ( did_action( 'before_woocommerce_init' ) ) { // Needed for unit tests, where 'before_woocommerce_init' will have been fired already at this point. $this->register_additional_features(); } else { // This needs to have a higher $priority than the 'before_woocommerce_init' hooked by plugins that declare compatibility. add_filter( 'before_woocommerce_init', array( $this, 'register_additional_features' ), -9999, 0 ); } } if ( did_action( 'init' ) ) { // Needed for unit tests, where 'init' will have been fired already at this point. $this->start_listening_for_option_changes(); } else { add_filter( 'init', array( $this, 'start_listening_for_option_changes' ), 10, 0 ); } add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 ); add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 ); add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 ); add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 ); add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 ); add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) ); add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 ); add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 ); add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 ); add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 ); add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 ); add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 ); add_filter( 'woocommerce_admin_features', array( $this, 'sync_iapi_mini_cart_feature' ) ); } /** * Synchronize the 'experimental-iapi-mini-cart' feature flag with the admin Features system. * * @param array $features The original list of features. * @return array The modified list of features. */ public function sync_iapi_mini_cart_feature( $features ) { $option_name = 'woocommerce_feature_experimental-iapi-mini-cart_enabled'; $is_enabled = 'yes' === get_option( $option_name, 'no' ); if ( $is_enabled ) { if ( ! in_array( 'experimental-iapi-mini-cart', $features, true ) ) { $features[] = 'experimental-iapi-mini-cart'; } } else { $features = array_filter( $features, function ( $feature ) { return 'experimental-iapi-mini-cart' !== $feature; } ); } return $features; } /** * Register a feature. * * This used to be called during the `woocommerce_register_feature_definitions` action hook, * now it's called directly from get_feature_definitions as needed. * * @param string $slug The ID slug of the feature. * @param string $name The name of the feature that will appear on the Features screen and elsewhere. * @param array $args { * Properties that make up the feature definition. Each of these properties can also be set as a * callback function, as long as that function returns the specified type. * * @type string $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required. * @type array[] $additional_settings An array of definitions for additional settings controls related to * the feature that will display on the Features screen. See the Settings API * for the schema of these props. * @type string $description A brief description of the feature, used as an input label if the feature * setting is a checkbox. * @type bool $disabled True to disable the setting field for this feature on the Features screen, * so it can't be changed. * @type bool $disable_ui Set to true to hide the setting field for this feature on the * Features screen. Defaults to false. * @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in. * Defaults to false. * @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on * the Features screen. Features set to experimental are also omitted from * the features list in some cases. Defaults to true. * @type bool $skip_compatibility_checks Set to true if the feature should not produce warnings about incompatible plugins. * Defaults to false. * @type string $learn_more_url The URL to the learn more page for the feature. * @type string $option_key The key name for the option that enables/disables the feature. * @type int $order The order that the feature will appear in the list on the Features screen. * Higher number = higher in the list. Defaults to 10. * @type array $setting The properties used by the Settings API to render the setting control on * the Features screen. See the Settings API for the schema of these props. * } * * @return void */ public function add_feature_definition( $slug, $name, array $args = array() ) { $defaults = array( 'disable_ui' => false, 'enabled_by_default' => false, 'is_experimental' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'skip_compatibility_checks' => false, 'name' => $name, 'order' => 10, 'learn_more_url' => '', ); if ( empty( $args['default_plugin_compatibility'] ) ) { wc_doing_it_wrong( __FUNCTION__, sprintf( 'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".', esc_html( $slug ) ), '10.3.0' ); } $args = wp_parse_args( $args, $defaults ); // Sanitize 'default_plugin_compatibility'. if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) { $args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE; } // Support 'is_legacy' flag for backwards compatibility. if ( ! empty( $args['is_legacy'] ) ) { $args['skip_compatibility_checks'] = true; } $this->features[ $slug ] = $args; } /** * Generate and cache the feature definitions. * * @return array[] */ private function get_feature_definitions() { if ( empty( $this->features ) ) { $this->init_feature_definitions(); } if ( ! $this->registered_additional_features_via_class_calls ) { // This needs to be set to true *before* additional feature definition calls are made, // to prevent infinite loops in case one of these calls ends up calling here again. $this->registered_additional_features_via_class_calls = true; // Additional feature definitions. // These used to be tied to the now deprecated woocommerce_register_feature_definitions action, // and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container. $container = wc_get_container(); $container->get( CustomOrdersTableController::class )->add_feature_definition( $this ); $container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this ); $this->init_compatibility_info_by_feature(); } return $this->features; } /** * Initialize the hardcoded feature definitions array. * This doesn't include: * - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions. * - Features whose definition comes from another class. These are initialized directly in get_feature_definitions * to avoid circular calls in the dependency injection container. */ private function init_feature_definitions(): void { $alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' ); $tracking_enabled = WC_Site_Tracking::is_tracking_enabled(); $legacy_features = array( 'analytics' => array( 'name' => __( 'Analytics', 'woocommerce' ), 'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ), 'option_key' => Analytics::TOGGLE_OPTION_NAME, 'is_experimental' => false, 'enabled_by_default' => true, 'disable_ui' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'product_block_editor' => array( 'name' => __( 'New product editor', 'woocommerce' ), 'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ), 'is_experimental' => true, 'disable_ui' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'cart_checkout_blocks' => array( 'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ), 'description' => __( 'Optimize for faster checkout', 'woocommerce' ), 'is_experimental' => false, 'disable_ui' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'rate_limit_checkout' => array( 'name' => __( 'Rate limit Checkout', 'woocommerce' ), 'description' => sprintf( // translators: %s is the URL to the rate limiting documentation. __( 'Enables rate limiting for Checkout place order and Store API /checkout endpoint. To further control this, refer to <a href="%s" target="_blank">rate limiting documentation</a>.', 'woocommerce' ), 'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/' ), 'is_experimental' => false, 'disable_ui' => false, 'enabled_by_default' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'marketplace' => array( 'name' => __( 'Marketplace', 'woocommerce' ), 'description' => __( 'New, faster way to find extensions and themes for your WooCommerce store', 'woocommerce' ), 'is_experimental' => false, 'enabled_by_default' => true, 'disable_ui' => true, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), // Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature. // https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959. 'order_attribution' => array( 'name' => __( 'Order Attribution', 'woocommerce' ), 'description' => __( 'Enable this feature to track and credit channels and campaigns that contribute to orders on your site', 'woocommerce' ), 'enabled_by_default' => true, 'disable_ui' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => false, ), 'site_visibility_badge' => array( 'name' => __( 'Site visibility badge', 'woocommerce' ), 'description' => __( 'Enable the site visibility badge in the WordPress admin bar', 'woocommerce' ), 'enabled_by_default' => true, 'disable_ui' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => false, 'disabled' => false, ), 'hpos_fts_indexes' => array( 'name' => __( 'HPOS Full text search indexes', 'woocommerce' ), 'description' => __( 'Create and use full text search indexes for orders. This feature only works with high-performance order storage.', 'woocommerce' ), 'is_experimental' => true, 'enabled_by_default' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION, ), 'hpos_datastore_caching' => array( 'name' => __( 'HPOS Data Caching', 'woocommerce' ), 'description' => __( 'Enable order data caching in the datastore. This feature only works with high-performance order storage.', 'woocommerce' ), 'is_experimental' => true, 'enabled_by_default' => false, 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'disable_ui' => false, 'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION, ), 'remote_logging' => array( 'name' => __( 'Remote Logging', 'woocommerce' ), 'description' => sprintf( /* translators: %1$s: opening link tag, %2$s: closing link tag */ __( 'Allow WooCommerce to send error logs and non-sensitive diagnostic data to help improve WooCommerce. This feature requires %1$susage tracking%2$s to be enabled.', 'woocommerce' ), '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=woocommerce_com' ) . '">', '</a>' ), 'enabled_by_default' => true, 'disable_ui' => false, /* * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController), * but we wish to handle compatibility checking in a similar fashion to legacy features. The * rational for setting legacy to true is therefore similar to that of the 'order_attribution' * feature. * * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959 */ 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => false, 'setting' => array( 'disabled' => function () use ( $tracking_enabled ) { return ! $tracking_enabled; }, 'desc_tip' => function () use ( $tracking_enabled ) { if ( ! $tracking_enabled ) { return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' ); } return ''; }, ), ), 'email_improvements' => array( 'name' => __( 'Email improvements', 'woocommerce' ), 'description' => __( 'Enable modern email design for transactional emails', 'woocommerce' ), /* * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController), * but as this feature doesn't affect all extensions, and the rollout is fairly short, * we'll skip the compatibility check by marking this as legacy. This is a workaround until * we can implement a more sophisticated compatibility checking system. * * @see https://github.com/woocommerce/woocommerce/issues/39147 * @see https://github.com/woocommerce/woocommerce/issues/55540 */ 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => false, ), 'blueprint' => array( 'name' => __( 'Blueprint (beta)', 'woocommerce' ), 'description' => __( 'Enable blueprint to import and export settings in bulk', 'woocommerce' ), 'enabled_by_default' => true, 'disable_ui' => false, /* * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController), * but we wish to handle compatibility checking in a similar fashion to legacy features. The * rational for setting legacy to true is therefore similar to that of the 'order_attribution' * feature. * * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959 */ 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => false, ), 'block_email_editor' => array( 'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ), 'description' => __( 'Enable the block-based email editor for transactional emails.', 'woocommerce' ), 'learn_more_url' => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256', /* * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController), * but we wish to handle compatibility checking in a similar fashion to legacy features. The * rational for setting legacy to true is therefore similar to that of the 'order_attribution' * feature. * * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959 */ 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'enabled_by_default' => false, ), 'point_of_sale' => array( 'name' => __( 'Point of Sale', 'woocommerce' ), 'description' => __( 'Enable Point of Sale functionality in the WooCommerce mobile apps.', 'woocommerce' ), 'enabled_by_default' => true, 'disable_ui' => false, /* * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController), * but we wish to handle compatibility checking in a similar fashion to legacy features. The * rational for setting legacy to true is therefore similar to that of the 'order_attribution' * feature. * * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959 */ 'skip_compatibility_checks' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_experimental' => true, ), 'fulfillments' => array( 'name' => __( 'Order Fulfillments', 'woocommerce' ), 'description' => __( 'Enable the Order Fulfillments feature to manage order fulfillment and shipping.', 'woocommerce' ), 'enabled_by_default' => false, 'disable_ui' => true, 'is_experimental' => false, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'experimental-iapi-mini-cart' => array( 'name' => __( 'Interactivity API powered Mini Cart', 'woocommerce' ), 'description' => __( 'Enable the new version of the Mini Cart that uses the Interactivity API instead of React in the frontend.', 'woocommerce' ), 'is_experimental' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), 'mcp_integration' => array( 'name' => __( 'WooCommerce MCP', 'woocommerce' ), 'description' => $this->get_mcp_integration_description(), 'enabled_by_default' => false, 'disable_ui' => false, 'is_experimental' => true, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, 'is_legacy' => false, ), 'destroy-empty-sessions' => array( 'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ), 'description' => __( '[Performance] Removes session cookies for non-logged in customers when session data is empty, improving page caching performance. May cause compatibility issues with extensions that depend on the session cookie without using session data.', 'woocommerce' ), 'enabled_by_default' => false, 'is_experimental' => true, 'disable_ui' => false, 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE, ), ); if ( ! $tracking_enabled ) { // Uncheck the remote logging feature when usage tracking is disabled. $legacy_features['remote_logging']['setting']['value'] = 'no'; } foreach ( $legacy_features as $slug => $definition ) { $this->add_feature_definition( $slug, $definition['name'], $definition ); } $this->init_compatibility_info_by_feature(); } /** * Initialize the compatibility_info_by_feature property after all the features have been added. */ private function init_compatibility_info_by_feature() { foreach ( array_keys( $this->features ) as $feature_id ) { if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) { $this->compatibility_info_by_feature[ $feature_id ] = array( FeaturePluginCompatibility::COMPATIBLE => array(), FeaturePluginCompatibility::INCOMPATIBLE => array(), ); } } } /** * Generate the description for the MCP integration feature. * * @return string The feature description with conditional permalink warning and documentation link. */ private function get_mcp_integration_description() { $base_description = __( 'Enable WooCommerce MCP (Model Context Protocol) for AI-powered store operations. AI-generated results and actions can be unpredictable - please review before executing in your store.', 'woocommerce' ); // Check permalink structure requirement. $permalink_structure = get_option( 'permalink_structure' ); if ( empty( $permalink_structure ) ) { $permalinks_url = admin_url( 'options-permalink.php' ); $permalink_warning = sprintf( '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>', __( 'Configuration Required', 'woocommerce' ), __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ), $permalinks_url, __( 'Configure Permalinks', 'woocommerce' ) ); // Add documentation link to permalink warning. $documentation_link = sprintf( ' <a href="%s" target="_blank">%s</a>', 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md', __( 'Learn more', 'woocommerce' ) ); return $base_description . $permalink_warning . $documentation_link; } // Add documentation link. $documentation_link = sprintf( ' <a href="%s" target="_blank">%s</a>', 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md', __( 'Learn more', 'woocommerce' ) ); return $base_description . $documentation_link; } /** * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook. * * This function must execute immediately before the 'before_woocommerce_init' * action is fired, so that feature compatibility declarations happening * in that action find all the features properly declared already. * * @internal */ public function register_additional_features() { if ( $this->registered_additional_features_via_action ) { return; } if ( empty( $this->features ) ) { $this->init_feature_definitions(); } /** * The action for registering features. * * @since 8.3.0 * * @param FeaturesController $features_controller The instance of FeaturesController. * * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions. */ do_action( 'woocommerce_register_feature_definitions', $this ); $this->init_compatibility_info_by_feature(); $this->registered_additional_features_via_action = true; } /** * Initialize the class instance. * * @internal * * @param LegacyProxy $proxy The instance of LegacyProxy to use. * @param PluginUtil $plugin_util The instance of PluginUtil to use. */ final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) { $this->proxy = $proxy; $this->plugin_util = $plugin_util; $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui(); } /** * Get all the existing WooCommerce features. * * Returns an associative array where keys are unique feature ids * and values are arrays with these keys: * * - name (string) * - description (string) * - is_experimental (bool) * - is_enabled (bool) (only if $include_enabled_info is passed as true) * * @param bool $include_experimental Include also experimental/work in progress features in the list. * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info. * @returns array An array of information about existing features. */ public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array { $features = $this->get_feature_definitions(); if ( ! $include_experimental ) { $features = array_filter( $features, function ( $feature ) { return ! $feature['is_experimental']; } ); } if ( $include_enabled_info ) { foreach ( array_keys( $features ) as $feature_id ) { $is_enabled = $this->feature_is_enabled( $feature_id ); $features[ $feature_id ]['is_enabled'] = $is_enabled; } } // We're deprecating the product block editor feature in favor of a v3 coming out. // We want to hide this setting in the UI for users that don't have it enabled. // If users have it enabled, we won't hide it until they explicitly disable it. if ( isset( $features['product_block_editor'] ) && ! $this->feature_is_enabled( 'product_block_editor' ) ) { $features['product_block_editor']['disable_ui'] = true; } return $features; } /** * Get the default plugin compatibility for a given feature. * * @param string $feature_id Feature id to check. * @return string Either 'compatible' or 'incompatible'. * @throws \InvalidArgumentException If the feature doesn't exist. */ public function get_default_plugin_compatibility( string $feature_id ): string { $feature_definition = $this->get_feature_definitions()[ $feature_id ] ?? null; if ( is_null( $feature_definition ) ) { throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) ); } $default_plugin_compatibility = $feature_definition['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE; // Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default(). /** * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature * are to be considered incompatible with that feature. * * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature. * @param string $feature_id The feature to check. * * @since 9.2.0 */ $incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id ); return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE; } /** * Check if a given feature is currently enabled. * * @param string $feature_id Unique feature id. * @return bool True if the feature is enabled, false if not or if the feature doesn't exist. */ public function feature_is_enabled( string $feature_id ): bool { if ( ! $this->feature_exists( $feature_id ) ) { return false; } if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) { return true; } $default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no'; $value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value ); return $value; } /** * Check if a given feature is enabled by default. * * @param string $feature_id Unique feature id. * @return boolean TRUE if the feature is enabled by default, FALSE otherwise. */ private function feature_is_enabled_by_default( string $feature_id ): bool { $features = $this->get_feature_definitions(); return ! empty( $features[ $feature_id ]['enabled_by_default'] ); } /** * Change the enabled/disabled status of a feature. * * @param string $feature_id Unique feature id. * @param bool $enable True to enable the feature, false to disable it. * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value. */ public function change_feature_enable( string $feature_id, bool $enable ): bool { if ( ! $this->feature_exists( $feature_id ) ) { return false; } return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no' ); } /** * Declare (in)compatibility with a given feature for a given plugin. * * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook. * * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name. * * @param string $feature_id Unique feature id. * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'. * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible. * @return bool True on success, false on error (feature doesn't exist or not inside the required hook). * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time. */ public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool { if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; /* translators: 1: class::method 2: before_woocommerce_init */ $this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' ); return false; } if ( ! $this->feature_exists( $feature_id ) ) { return false; } if ( $this->lazy ) { // Lazy mode: Queue to be normalized later. $this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility ); return true; } // Late call: Normalize and register immediately. return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility ); } /** * Registers compatibility information internally for a given feature and plugin file. * * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins, * and registers the compatibility data if valid. * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both * compatible and incompatible with the same feature), and throws an exception if a conflict is detected. * Duplicate declarations (same compatibility type) are ignored. * * This is an internal helper method and should not be called directly. * * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed. * @since 10.1.0 * * @param string $feature_id Unique feature ID. * @param string $plugin_file Raw plugin file path (full or 'directory/file.php'). * @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility. * @return bool True on successful registration, false if the feature does not exist. * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature. */ private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool { if ( ! $this->feature_exists( $feature_id ) ) { return false; } // Normalize and validate plugin file. $plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file ); if ( ! $plugin_id ) { $logger = $this->proxy->call_function( 'wc_get_logger' ); $logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." ); return false; } // Register compatibility by plugin. ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id ); $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE; $opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE; ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key ); ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key ); if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) { throw new \Exception( esc_html( "Plugin $plugin_id is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ) ); } if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) { $this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id; } // Register compatibility by feature. $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE; if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) { $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id; } return true; } /** * Processes any pending compatibility declarations by normalizing plugin file paths * and registering them internally. * * This method is called lazily when compatibility information is queried (via * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()). * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins. * Pending declarations are cleared after processing to avoid redundant work. * * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed. * @since 10.1.0 * @return void */ private function process_pending_declarations(): void { if ( empty( $this->pending_declarations ) ) { return; } foreach ( $this->pending_declarations as $declaration ) { [ $feature_id, $plugin_file, $positive_compatibility ] = $declaration; // Register internally. $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility ); } $this->pending_declarations = array(); $this->lazy = false; } /** * Check whether a feature exists with a given id. * * @param string $feature_id The feature id to check. * @return bool True if the feature exists. */ private function feature_exists( string $feature_id ): bool { $features = $this->get_feature_definitions(); return isset( $features[ $feature_id ] ); } /** * Get the ids of the features that a certain plugin has declared compatibility for. * * This method can't be called before the 'woocommerce_init' hook is fired. * * @param string $plugin_name Plugin name, in the form 'directory/file.php'. * @param bool $enabled_features_only True to return only names of enabled plugins. * @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible. * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids. */ public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array { $this->process_pending_declarations(); $this->verify_did_woocommerce_init( __FUNCTION__ ); $features = $this->get_feature_definitions(); if ( $enabled_features_only ) { $features = array_filter( $features, array( $this, 'feature_is_enabled' ), ARRAY_FILTER_USE_KEY ); } if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) { return array( FeaturePluginCompatibility::COMPATIBLE => array(), FeaturePluginCompatibility::INCOMPATIBLE => array(), FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ), ); } $info = $this->compatibility_info_by_plugin[ $plugin_name ]; $info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) ); $info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) ); $info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) ); if ( $resolve_uncertain ) { foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) { $key = $this->get_default_plugin_compatibility( $feature_id ); $info[ $key ][] = $feature_id; } $info[ FeaturePluginCompatibility::UNCERTAIN ] = array(); } return $info; } /** * Get the names of the plugins that have been declared compatible or incompatible with a given feature. * * @param string $feature_id Feature id. * @param bool $active_only True to return only active plugins. * @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible. * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names. */ public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array { $this->process_pending_declarations(); $this->verify_did_woocommerce_init( __FUNCTION__ ); $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only ); if ( ! $this->feature_exists( $feature_id ) ) { return array( FeaturePluginCompatibility::COMPATIBLE => array(), FeaturePluginCompatibility::INCOMPATIBLE => array(), FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins, ); } $info = $this->compatibility_info_by_feature[ $feature_id ]; ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN ); // Resolve uncertain plugin compatibility? $uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) ); $key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN; $info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins ); return $info; } /** * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not. * * @param string|null $function_name Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running. * * @return bool True if 'woocommerce_init' has run or is running, false otherwise. */ private function verify_did_woocommerce_init( ?string $function_name = null ): bool { if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) && ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) { if ( ! is_null( $function_name ) ) { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name; /* translators: 1: class::method 2: plugins_loaded */ $this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' ); } return false; } return true; } /** * Get the name of the option that enables/disables a given feature. * * Note that it doesn't check if the feature actually exists. Instead it * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different * name isn't specified in the feature registration. * * @param string $feature_id The id of the feature. * @return string The option that enables or disables the feature. */ public function feature_enable_option_name( string $feature_id ): string { $features = $this->get_feature_definitions(); if ( ! empty( $features[ $feature_id ]['option_key'] ) ) { return $features[ $feature_id ]['option_key']; } return "woocommerce_feature_{$feature_id}_enabled"; } /** * Check if the compatibility checks should be skipped for a given feature. * * @since 10.3.0 * * @param string $feature_id The feature id to check. * @return bool TRUE if the compatibility checks should be skipped. */ public function should_skip_compatibility_checks( string $feature_id ): bool { $features = $this->get_feature_definitions(); return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] ); } /** * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active * from the WooCommerce feature settings page. */ public function allow_enabling_features_with_incompatible_plugins(): void { $this->force_allow_enabling_features = true; } /** * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled * from the WordPress plugins page. */ public function allow_activating_plugins_with_incompatible_features(): void { $this->force_allow_enabling_plugins = true; } /** * Adds our callbacks for the `updated_option` and `added_option` filter hooks. * * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions, * and building that list requires translating various strings (which should not be done earlier than `init`). * * @return void * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function start_listening_for_option_changes(): void { add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 ); add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 ); } /** * Handler for the 'added_option' hook. * * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled. * * @param string $option The option that has been created. * @param mixed $value The value of the option. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function process_added_option( string $option, $value ) { $this->process_updated_option( $option, false, $value ); } /** * Handler for the 'updated_option' hook. * * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled. * * @param string $option The option that has been modified. * @param mixed $old_value The old value of the option. * @param mixed $value The new value of the option. * * @return void * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function process_updated_option( string $option, $old_value, $value ) { $matches = array(); $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches ); $features_with_custom_keys = array_filter( $this->get_feature_definitions(), function ( $feature ) { return ! empty( $feature['option_key'] ); } ); $custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' ); if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) { return; } if ( $value === $old_value ) { return; } $feature_id = ''; if ( $is_default_key ) { $feature_id = $matches[1]; } elseif ( in_array( $option, $custom_keys, true ) ) { $feature_id = array_search( $option, $custom_keys, true ); } if ( ! $feature_id ) { return; } WC_Tracks::record_event( self::FEATURE_ENABLED_CHANGED_ACTION, array( 'feature_id' => $feature_id, 'enabled' => $value, ) ); /** * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed). * * @param string $feature_id The id of the feature. * @param bool $enabled True if the feature has been enabled, false if it has been disabled. * * @since 7.0.0 */ do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value ); } /** * Handler for the 'woocommerce_get_sections_advanced' hook, * it adds the "Features" section to the advanced settings page. * * @param array $sections The original sections array. * @return array The updated sections array. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function add_features_section( $sections ) { if ( ! isset( $sections['features'] ) ) { $sections['features'] = __( 'Features', 'woocommerce' ); } return $sections; } /** * Handler for the 'woocommerce_get_settings_advanced' hook, * it adds the settings UI for all the existing features. * * Note that the settings added via the 'woocommerce_settings_features' hook will be * displayed in the non-experimental features section. * * @param array $settings The existing settings for the corresponding settings section. * @param string $current_section The section to get the settings for. * @return array The updated settings array. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function add_feature_settings( $settings, $current_section ): array { if ( 'features' !== $current_section ) { return $settings; } $feature_settings = array( array( 'title' => __( 'Features', 'woocommerce' ), 'type' => 'title', 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ), 'id' => 'features_options', ), ); $features = $this->get_features( true ); $feature_ids = array_keys( $features ); usort( $feature_ids, function ( $feature_id_a, $feature_id_b ) use ( $features ) { return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 ); } ); $experimental_feature_ids = array_filter( $feature_ids, function ( $feature_id ) use ( $features ) { return $features[ $feature_id ]['is_experimental'] ?? false; } ); $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids ); $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids ); foreach ( $feature_ids as $id ) { if ( 'mature_features_end' === $id ) { // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment /** * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page. * * @param bool $disabled False. */ $feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings ); // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment if ( ! empty( $experimental_feature_ids ) ) { $feature_settings[] = array( 'type' => 'sectionend', 'id' => 'features_options', ); $feature_settings[] = array( 'title' => __( 'Experimental features', 'woocommerce' ), 'type' => 'title', 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ), 'id' => 'experimental_features_options', ); } continue; } if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) { continue; } if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) { continue; } $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] ); $additional_settings = $features[ $id ]['additional_settings'] ?? array(); if ( count( $additional_settings ) > 0 ) { $feature_settings = array_merge( $feature_settings, $additional_settings ); } } $feature_settings[] = array( 'type' => 'sectionend', 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options', ); if ( $this->verify_did_woocommerce_init() ) { // Allow feature setting properties to be determined dynamically just before being rendered. $feature_settings = array_map( function ( $feature_setting ) { foreach ( $feature_setting as $prop => $value ) { if ( is_callable( $value ) ) { $feature_setting[ $prop ] = call_user_func( $value ); } } return $feature_setting; }, $feature_settings ); } return $feature_settings; } /** * Get the parameters to display the setting enable/disable UI for a given feature. * * @param string $feature_id The feature id. * @param array $feature The feature parameters, as returned by get_features. * @return array The parameters to add to the settings array. */ private function get_setting_for_feature( string $feature_id, array $feature ): array { $description = $feature['description'] ?? ''; $disabled = false; $desc_tip = ''; $tooltip = $feature['tooltip'] ?? ''; $type = $feature['type'] ?? 'checkbox'; $setting_definition = $feature['setting'] ?? array(); // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment /** * Filter allowing WooCommerce Admin to be disabled. * * @param bool $disabled False. */ $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false ); // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) { $disabled = true; $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' ); } elseif ( 'new_navigation' === $feature_id ) { $update_text = sprintf( // translators: 1: line break tag. __( '%1$s This navigation will soon become unavailable while we make necessary improvements. If you turn it off now, you will not be able to turn it back on.', 'woocommerce' ), '<br/>' ); $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' ); if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) { $update_text = sprintf( // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag. __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ), '<br/>', '<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">', '</a>' ); $disabled = true; } if ( ! empty( $update_text ) ) { $description .= $update_text; } } if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) { $plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true ); $desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature ); } /** * Filter to customize the description tip that appears under the description of each feature in the features settings page. * * @since 7.1.0 * * @param string $desc_tip The original description tip. * @param string $feature_id The id of the feature for which the description tip is being customized. * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature. * @return string The new description tip to use. */ $desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled ); $feature_setting_defaults = array( 'title' => $feature['name'], 'desc' => $description, 'type' => $type, 'id' => $this->feature_enable_option_name( $feature_id ), 'disabled' => $disabled && ! $this->force_allow_enabling_features, 'desc_tip' => $desc_tip, 'tooltip' => $tooltip, 'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no', ); $feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults ); if ( ! empty( $feature['learn_more_url'] ) ) { $feature_setting['desc'] .= sprintf( '<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>', esc_attr( $feature['learn_more_url'] ), esc_html__( 'Learn more', 'woocommerce' ) ); } /** * Allows to modify feature setting that will be used to render in the feature page. * * @param array $feature_setting The feature setting. Describes the feature: * - title: The title of the feature. * - desc: The description of the feature. Will be displayed under the title. * - type: The type of the feature. Could be any of supported settings types from `WC_Admin_Settings::output_fields`, but if it's anything other than checkbox or radio, it will need custom handling. * - id: The id of the feature. Will be used as the name of the setting. * - disabled: Whether the feature is disabled or not. * - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description. * - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name. * - default: The default value of the feature. * @param string $feature_id The id of the feature. * @since 8.0.0 */ return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id ); } /** * Handle the plugin deactivation hook. * * @param string $plugin_name Name of the plugin that has been deactivated. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function handle_plugin_deactivation( $plugin_name ): void { unset( $this->compatibility_info_by_plugin[ $plugin_name ] ); foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) { $compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ]; $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) ); $incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ]; $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) ); } } /** * Handler for the all_plugins filter. * * Returns the list of plugins incompatible with a given plugin * if we are in the plugins page and the query string of the current request * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'. * * @param array $plugin_list The original list of plugins. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function filter_plugins_list( $plugin_list ): array { if ( ! $this->verify_did_woocommerce_init() ) { return $plugin_list; } // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput if ( ! function_exists( 'get_current_screen' ) || ( get_current_screen() && 'plugins' !== get_current_screen()->id ) || 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { return $plugin_list; } $feature_id = $_GET['feature_id'] ?? 'all'; if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) { return $plugin_list; } return $this->get_incompatible_plugins( $feature_id, $plugin_list ); } /** * Returns the list of plugins incompatible with a given feature. * * @param string $feature_id ID of the feature. Can also be `all` to denote all features. * @param array $plugin_list List of plugins to filter. * * @return array List of plugins incompatible with the given feature. */ public function get_incompatible_plugins( $feature_id, $plugin_list ) { $incompatibles = array(); $plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) ); $feature_ids = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id ); $only_enabled_features = 'all' === $feature_id; // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput foreach ( array_keys( $plugin_list ) as $plugin_name ) { if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) { continue; } $compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name ); foreach ( $feature_ids as $feature_id ) { $features_considered_incompatible = array_filter( $this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ), $only_enabled_features ? fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) : fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) ); if ( in_array( $feature_id, $features_considered_incompatible, true ) ) { $incompatibles[] = $plugin_name; } } } return array_intersect_key( $plugin_list, array_flip( $incompatibles ) ); } /** * Handler for the admin_notices action. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function display_notices_in_plugins_page(): void { if ( ! $this->verify_did_woocommerce_init() ) { return; } $feature_filter_description_shown = $this->maybe_display_current_feature_filter_description(); if ( ! $feature_filter_description_shown ) { $this->maybe_display_feature_incompatibility_warning(); } } /** * Shows a warning when there are any incompatibility between active plugins and enabled features. * The warning is shown in on any admin screen except the plugins screen itself, since * there's already a "You are viewing plugins that are incompatible" notice. */ private function maybe_display_feature_incompatibility_warning(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } $incompatible_plugins = false; $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui ); foreach ( $relevant_plugins as $plugin ) { $compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true ); $incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) ); if ( ! empty( $incompatibles ) ) { $incompatible_plugins = true; break; } $uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) ); foreach ( $uncertains as $feature_id ) { if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) { $incompatible_plugins = true; break; } } if ( $incompatible_plugins ) { break; } } if ( ! $incompatible_plugins ) { return; } $message = str_replace( '<a>', '<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">', __( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' ) ); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped ?> <div class="notice notice-error"> <p><?php echo $message; ?></p> </div> <?php // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } /** * Shows a "You are viewing the plugins that are incompatible with the X feature" * if we are in the plugins page and the query string of the current request * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'. */ private function maybe_display_current_feature_filter_description(): bool { if ( 'plugins' !== get_current_screen()->id ) { return false; } // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput $plugin_status = $_GET['plugin_status'] ?? ''; $feature_id = $_GET['feature_id'] ?? ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput if ( 'incompatible_with_feature' !== $plugin_status ) { return false; } $feature_id = ( '' === $feature_id ) ? 'all' : $feature_id; if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) { return false; } $features = $this->get_feature_definitions(); $plugins_page_url = admin_url( 'plugins.php' ); $features_page_url = $this->get_features_page_url(); $message = 'all' === $feature_id ? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' ) : sprintf( /* translators: %s is a feature name. */ __( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ), $features[ $feature_id ]['name'] ); $message .= '<br />'; $message .= sprintf( __( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ), $plugins_page_url, $features_page_url ); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped ?> <div class="notice notice-info"> <p><?php echo $message; ?></p> </div> <?php // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped return true; } /** * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data. * * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list * to inject useful information of its own. * * @see https://github.com/woocommerce/woocommerce/issues/37343 * * @return void * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function maybe_invalidate_cached_plugin_data(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) { wp_cache_delete( 'plugins', 'plugins' ); } } /** * Handler for the 'after_plugin_row' action. * Displays a "This plugin is incompatible with X features" notice if necessary. * * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page. * @param array $plugin_data Plugin data, as returned by 'get_plugins'. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function handle_plugin_list_rows( $plugin_file, $plugin_data ) { global $wp_list_table; if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) { return; } if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification return; } if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) { return; } if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) { return; } $features = $this->get_feature_definitions(); $feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true ); $incompatible_features = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ]; $incompatible_features = array_values( array_filter( $incompatible_features, function ( $feature_id ) { return ! $this->should_skip_compatibility_checks( $feature_id ); } ) ); $incompatible_features_count = count( $incompatible_features ); if ( $incompatible_features_count > 0 ) { $columns_count = $wp_list_table->get_column_count(); $is_active = true; // For now we are showing active plugins in the "Incompatible with..." view. $is_active_class = $is_active ? 'active' : 'inactive'; $is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : ''; if ( 1 === $incompatible_features_count ) { $message = sprintf( /* translators: %s = printable plugin name */ __( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ), $features[ $incompatible_features[0] ]['name'] ); } elseif ( 2 === $incompatible_features_count ) { /* translators: %1\$s, %2\$s = printable plugin names */ $message = sprintf( __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ), $features[ $incompatible_features[0] ]['name'], $features[ $incompatible_features[1] ]['name'] ); } else { /* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */ $message = sprintf( __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ), $features[ $incompatible_features[0] ]['name'], $features[ $incompatible_features[1] ]['name'], $incompatible_features_count - 2 ); } $features_page_url = $this->get_features_page_url(); $manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' ); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped ?> <tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'> <td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>> <div class='notice inline notice-warning notice-alt'> <p> <?php echo $message; ?> <a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a> </p> </div> </td> </tr> <?php // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } } /** * Get the URL of the features settings page. * * @return string */ public function get_features_page_url(): string { return admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' ); } /** * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings. * * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows: * * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower * border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered, * it will have a "plugin-update-tr" class which will draw the missing lower border line. * - Otherwise, the <tr> will be already drawn with the lower border line. * * This is a problem for our rendering of the "plugin is incompatible with X features" warning: * * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then * our own "plugin-update-tr" class will draw an additional line before the "needs update" warning. * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>. * * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise * (and then some extra manual tweaking of margins is needed). * * @param string $current_screen The current screen object. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void { if ( 'plugins' !== $current_screen->id ) { return; } wc_enqueue_js( " const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]'); for(const warningRow of warningRows) { const pluginName = warningRow.getAttribute('data-plugin'); const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)'); if(pluginInfoRow.classList.contains('update')) { warningRow.classList.remove('plugin-update-tr'); warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px'; } else { pluginInfoRow.classList.add('update'); } } " ); } /** * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page. * If we come from a "Manage incompatible plugins" in the features page we'll show just two views: * "All" (so that it's easy to go back to a known state) and "Incompatible with X". * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying * the plugins list via the 'all_plugins' filter. * * @param array $views An array of view ids => view links. * @return string[] The actual views array to use. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function handle_plugins_page_views_list( $views ): array { // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { return $views; } $feature_id = $_GET['feature_id'] ?? 'all'; if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) { return $views; } // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput $all_items = get_plugins(); $features = $this->get_feature_definitions(); $incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) ); $incompatible_text = 'all' === $feature_id ? __( 'Incompatible with WooCommerce features', 'woocommerce' ) /* translators: %s = name of a WooCommerce feature */ : sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] ); $incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>"; $all_plugins_count = count( $all_items ); $all_text = __( 'All', 'woocommerce' ); $all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>"; return array( 'all' => $all_link, 'incompatible_with_feature' => $incompatible_link, ); } /** * Set the feature nonce to be sent from client side. * * @param array $settings Component settings. * * @return array * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function set_change_feature_enable_nonce( $settings ) { $settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' ); return $settings; } /** * Changes the feature given it's id, a toggle value and nonce as a query param. * * `/wp-admin/post.php?product_block_editor=1&_feature_nonce=1234`, 1 for on * `/wp-admin/post.php?product_block_editor=0&_feature_nonce=1234`, 0 for off * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function change_feature_enable_from_query_params(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) ); $query_params_to_remove = array( '_feature_nonce' ); foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) { if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) { $value = absint( $_GET[ $feature_id ] ); if ( $is_feature_nonce_invalid ) { wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) ); return; } if ( 1 === $value ) { $this->change_feature_enable( $feature_id, true ); } elseif ( 0 === $value ) { $this->change_feature_enable( $feature_id, false ); } $query_params_to_remove[] = $feature_id; } } if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) ); } } /** * Display the email improvements feedback notice to render CES modal in. * * @param string $feature_id The feature id. * @param bool $is_enabled Whether the feature is enabled. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void { if ( 'email_improvements' === $feature_id && ! $is_enabled ) { set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 ); add_action( 'admin_notices', function () { echo '<div id="wc_settings_features_email_feedback_slotfill"></div>'; } ); } } /** * Check if the email improvements feature is enabled in preview mode in Settings > Emails. * This is used to force the email improvements feature without affecting shoppers. * * @param string $feature_id The feature id. * @return bool Whether the email improvements feature is enabled in preview mode. */ private function is_preview_email_improvements_enabled( string $feature_id ): bool { if ( 'email_improvements' !== $feature_id ) { return false; } /** * This filter is documented in templates/emails/email-styles.php * * @since 9.9.0 * @param bool $is_email_preview Whether the email is being previewed. */ $is_email_preview = apply_filters( 'woocommerce_is_email_preview', false ); if ( $is_email_preview ) { return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes'; } return false; } }
Copyright ©2021 || Defacer Indonesia