PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.1.0-beta.2
WooCommerce v11.1.0-beta.2
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Features / FeaturesController.php
woocommerce / src / Internal / Features Last commit date
OrderDetailRedesign 2 months ago BlockEditorUnifiedAssets.php 2 weeks ago FeaturesController.php 1 week ago
FeaturesController.php
2223 lines
1 <?php
2 /**
3 * FeaturesController class file
4 */
5
6 declare( strict_types=1 );
7
8 namespace Automattic\WooCommerce\Internal\Features;
9
10 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
11 use WC_Tracks;
12 use WC_Site_Tracking;
13 use Automattic\Jetpack\Constants;
14 use Automattic\WooCommerce\Admin\Features\Features as WCAdminFeatures;
15 use Automattic\WooCommerce\Internal\Admin\Analytics;
16 use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
17 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
18 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
19 use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
20 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
21 use Automattic\WooCommerce\Proxies\LegacyProxy;
22 use Automattic\WooCommerce\Utilities\ArrayUtil;
23 use Automattic\WooCommerce\Utilities\PluginUtil;
24 use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Class to define the WooCommerce features that can be enabled and disabled by admin users,
30 * provides also a mechanism for WooCommerce plugins to declare that they are compatible
31 * (or incompatible) with a given feature.
32 *
33 * Note: the 'woocommerce_register_feature_definitions' hook allows registering new features
34 * externally. This hook is deprecated, features should be registered from within get_feature_definitions.
35 * However, in case you use it for testing purposes, keep in mind that the hook is fired from inside 'init';
36 * therefore, features that need to be queried, enabled, or disabled before 'init' (e.g. during WP CLI initialization)
37 * can't be registered using the hook.
38 */
39 class FeaturesController {
40
41 public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
42
43 public const PLUGINS_COMPATIBLE_BY_DEFAULT_OPTION = 'woocommerce_plugins_are_compatible_with_features_by_default';
44
45 /**
46 * The existing feature definitions.
47 *
48 * @var array[]
49 */
50 private $features = array();
51
52 /**
53 * The registered compatibility info for WooCommerce plugins, with plugin names as keys.
54 *
55 * @var array
56 */
57 private $compatibility_info_by_plugin = array();
58
59 /**
60 * The registered compatibility info for WooCommerce plugins, with feature ids as keys.
61 *
62 * @var array
63 */
64 private $compatibility_info_by_feature = array();
65
66 /**
67 * Pending compatibility declarations. Format is [feature_id, plugin_file, positive_compatibility].
68 *
69 * @var array
70 */
71 private $pending_declarations = array();
72
73 /**
74 * The LegacyProxy instance to use.
75 *
76 * @var LegacyProxy
77 */
78 private $proxy;
79
80 /**
81 * The PluginUtil instance to use.
82 *
83 * @var PluginUtil
84 */
85 private $plugin_util;
86
87 /**
88 * Flag indicating that features will be enableable from the settings page
89 * even when they are incompatible with active plugins.
90 *
91 * @var bool
92 */
93 private $force_allow_enabling_features = false;
94
95 /**
96 * Flag indicating that plugins will be activable from the plugins page
97 * even when they are incompatible with enabled features.
98 *
99 * @var bool
100 */
101 private $force_allow_enabling_plugins = false;
102
103 /**
104 * List of plugins excluded from feature compatibility warnings in UI.
105 *
106 * @var string[]
107 */
108 private $plugins_excluded_from_compatibility_ui;
109
110 /**
111 * Flag indicating if additional features have been registered already
112 * via woocommerce_register_feature_definitions action.
113 *
114 * @var bool
115 */
116 private bool $registered_additional_features_via_action = false;
117
118 /**
119 * Flag indicating if additional features have been registered already
120 * via calls to other classes.
121 *
122 * @var bool
123 */
124 private bool $registered_additional_features_via_class_calls = false;
125
126 /**
127 * Flag indicating if we are currently delaying plugin normalization.
128 *
129 * @var bool
130 */
131 private bool $lazy = true;
132
133 /**
134 * Creates a new instance of the class.
135 */
136 public function __construct() {
137 // In principle, register_additional_features is triggered manually from within class-woocommerce
138 // right before before_woocommerce_init is fired (this is needed for the features to be visible
139 // to plugins executing declare_compatibility).
140 // However we add additional checks/hookings here to support unit tests and possible overlooked/future
141 // DI container/class instantiation nuances.
142 if ( ! $this->registered_additional_features_via_action ) {
143 if ( did_action( 'before_woocommerce_init' ) ) {
144 // Needed for unit tests, where 'before_woocommerce_init' will have been fired already at this point.
145 $this->register_additional_features();
146 } else {
147 // This needs to have a higher $priority than the 'before_woocommerce_init' hooked by plugins that declare compatibility.
148 add_filter( 'before_woocommerce_init', array( $this, 'register_additional_features' ), -9999, 0 );
149 }
150 }
151
152 if ( did_action( 'init' ) ) {
153 // Needed for unit tests, where 'init' will have been fired already at this point.
154 $this->start_listening_for_option_changes();
155 } else {
156 add_filter( 'init', array( $this, 'start_listening_for_option_changes' ), 10, 0 );
157 }
158
159 add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
160 add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
161 add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
162 add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
163 add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
164 add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) );
165 add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
166 add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
167 add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
168 add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
169 add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
170 add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 );
171 add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'flag_abandoned_cart_recovery_enabled_notice' ), 10, 2 );
172 add_action( 'woocommerce_settings_advanced', array( $this, 'maybe_render_abandoned_cart_recovery_enabled_notice' ), 1 );
173 add_filter( 'woocommerce_settings-advanced', array( $this, 'add_point_of_sale_setting_for_rest_api' ), 10, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
174 }
175
176 /**
177 * Register a feature.
178 *
179 * This used to be called during the `woocommerce_register_feature_definitions` action hook,
180 * now it's called directly from get_feature_definitions as needed.
181 *
182 * @param string $slug The ID slug of the feature.
183 * @param string $name The name of the feature that will appear on the Features screen and elsewhere.
184 * @param array $args {
185 * Properties that make up the feature definition. Each of these properties can also be set as a
186 * callback function, as long as that function returns the specified type.
187 *
188 * @type string $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required.
189 * @type array[] $additional_settings An array of definitions for additional settings controls related to
190 * the feature that will display on the Features screen. See the Settings API
191 * for the schema of these props.
192 * @type string $description A brief description of the feature, used as an input label if the feature
193 * setting is a checkbox.
194 * @type bool $disabled True to disable the setting field for this feature on the Features screen,
195 * so it can't be changed.
196 * @type bool $disable_ui Set to true to hide the setting field for this feature on the
197 * Features screen. Defaults to false.
198 * @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in.
199 * Defaults to false.
200 * @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on
201 * the Features screen. Features set to experimental are also omitted from
202 * the features list in some cases. Defaults to true.
203 * @type bool $skip_compatibility_checks Set to true if the feature should not produce warnings about incompatible plugins.
204 * Defaults to false.
205 * @type string $learn_more_url The URL to the learn more page for the feature.
206 * @type string $option_key The key name for the option that enables/disables the feature.
207 * @type int $order The order that the feature will appear in the list on the Features screen.
208 * Higher number = higher in the list. Defaults to 10.
209 * @type array $setting The properties used by the Settings API to render the setting control on
210 * the Features screen. See the Settings API for the schema of these props.
211 * @type string $deprecated_since The WooCommerce version since which this feature is deprecated.
212 * When set, feature_is_enabled() will force feature value to the deprecated_value
213 * instead of reading from the database.
214 * @type bool $deprecated_value The value to return for deprecated features when feature_is_enabled()
215 * is called. Defaults to false.
216 * }
217 *
218 * @return void
219 */
220 public function add_feature_definition( $slug, $name, array $args = array() ) {
221 $defaults = array(
222 'disable_ui' => false,
223 'enabled_by_default' => false,
224 'is_experimental' => true,
225 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
226 'skip_compatibility_checks' => false,
227 'name' => $name,
228 'order' => 10,
229 'learn_more_url' => '',
230 );
231
232 if ( empty( $args['default_plugin_compatibility'] ) ) {
233 wc_doing_it_wrong(
234 __FUNCTION__,
235 sprintf(
236 'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".',
237 esc_html( $slug )
238 ),
239 '10.3.0'
240 );
241 }
242
243 $args = wp_parse_args( $args, $defaults );
244
245 // Sanitize 'default_plugin_compatibility'.
246 if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) {
247 $args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
248 }
249
250 // Support 'is_legacy' flag for backwards compatibility.
251 if ( ! empty( $args['is_legacy'] ) ) {
252 $args['skip_compatibility_checks'] = true;
253 }
254
255 $this->features[ $slug ] = $args;
256 }
257
258 /**
259 * Generate and cache the feature definitions.
260 *
261 * @return array[]
262 */
263 private function get_feature_definitions() {
264 if ( empty( $this->features ) ) {
265 $this->init_feature_definitions();
266 }
267
268 if ( ! $this->registered_additional_features_via_class_calls ) {
269 // This needs to be set to true *before* additional feature definition calls are made,
270 // to prevent infinite loops in case one of these calls ends up calling here again.
271 $this->registered_additional_features_via_class_calls = true;
272
273 // Additional feature definitions.
274 // These used to be tied to the now deprecated woocommerce_register_feature_definitions action,
275 // and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container.
276 $container = wc_get_container();
277 $container->get( CustomOrdersTableController::class )->add_feature_definition( $this );
278 $container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this );
279
280 $this->init_compatibility_info_by_feature();
281 }
282
283 return $this->features;
284 }
285
286 /**
287 * Initialize the hardcoded feature definitions array.
288 * This doesn't include:
289 * - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions.
290 * - Features whose definition comes from another class. These are initialized directly in get_feature_definitions
291 * to avoid circular calls in the dependency injection container.
292 */
293 private function init_feature_definitions(): void {
294 $alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' );
295 $tracking_enabled = WC_Site_Tracking::is_tracking_enabled();
296
297 $legacy_features = array(
298 'analytics' => array(
299 'name' => __( 'WooCommerce Analytics', 'woocommerce' ),
300 'description' => __( 'Enable WooCommerce Analytics to track your store\'s key metrics and view them in a detailed dashboard. All data stays within your store.', 'woocommerce' ),
301 'option_key' => Analytics::TOGGLE_OPTION_NAME,
302 'is_experimental' => false,
303 'enabled_by_default' => true,
304 'disable_ui' => false,
305 'skip_compatibility_checks' => true,
306 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
307 ),
308 ProductMediaGallery::FEATURE_ID => array(
309 'name' => __( 'Product gallery videos', 'woocommerce' ),
310 'description' => __( 'Enable videos in product galleries.', 'woocommerce' ),
311 'option_key' => ProductMediaGallery::ENABLE_OPTION_NAME,
312 'is_experimental' => true,
313 'enabled_by_default' => false,
314 'disable_ui' => false,
315 'skip_compatibility_checks' => true,
316 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
317 ),
318 'cart_checkout_blocks' => array(
319 'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
320 'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
321 'is_experimental' => false,
322 'disable_ui' => true,
323 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
324 ),
325 'rate_limit_checkout' => array(
326 'name' => __( 'Rate limit Checkout', 'woocommerce' ),
327 'description' => sprintf(
328 // translators: %s is the URL to the rate limiting documentation.
329 __( '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' ),
330 'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/'
331 ),
332 'is_experimental' => false,
333 'disable_ui' => false,
334 'enabled_by_default' => false,
335 'skip_compatibility_checks' => true,
336 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
337 ),
338 'marketplace' => array(
339 'name' => __( 'Marketplace', 'woocommerce' ),
340 'description' => __(
341 'New, faster way to find extensions and themes for your WooCommerce store',
342 'woocommerce'
343 ),
344 'is_experimental' => false,
345 'enabled_by_default' => true,
346 'disable_ui' => true,
347 'skip_compatibility_checks' => true,
348 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
349 'deprecated_since' => '10.5.0',
350 'deprecated_value' => true,
351 ),
352 'order_withdrawal' => array(
353 'name' => __( 'Order withdrawal', 'woocommerce' ),
354 'description' => __( 'Enable the public order withdrawal feature for customer requests.', 'woocommerce' ),
355 'learn_more_url' => 'https://woocommerce.com/document/customer-order-withdrawal/',
356 'enabled_by_default' => false,
357 'disable_ui' => false,
358 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
359 'is_experimental' => false,
360 ),
361 // Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
362 // https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
363 'order_attribution' => array(
364 'name' => __( 'Order Attribution', 'woocommerce' ),
365 'description' => __(
366 'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
367 'woocommerce'
368 ),
369 'enabled_by_default' => true,
370 'disable_ui' => false,
371 'skip_compatibility_checks' => true,
372 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
373 'is_experimental' => false,
374 ),
375 'site_visibility_badge' => array(
376 'name' => __( 'Site visibility badge', 'woocommerce' ),
377 'description' => __(
378 'Enable the site visibility badge in the WordPress admin bar',
379 'woocommerce'
380 ),
381 'enabled_by_default' => true,
382 'disable_ui' => true,
383 'skip_compatibility_checks' => true,
384 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
385 'is_experimental' => false,
386 'disabled' => false,
387 ),
388 'hpos_fts_indexes' => array(
389 'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
390 'description' => __(
391 'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
392 'woocommerce'
393 ),
394 'is_experimental' => true,
395 'enabled_by_default' => false,
396 'skip_compatibility_checks' => true,
397 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
398 'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
399 ),
400 'hpos_datastore_caching' => array(
401 'name' => __( 'HPOS Data Caching', 'woocommerce' ),
402 'description' => __(
403 'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
404 'woocommerce'
405 ),
406 'is_experimental' => false,
407 'enabled_by_default' => false,
408 'skip_compatibility_checks' => true,
409 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
410 'disable_ui' => false,
411 'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
412 ),
413 'remote_logging' => array(
414 'name' => __( 'Remote Logging', 'woocommerce' ),
415 'description' => sprintf(
416 /* translators: %1$s: opening link tag, %2$s: closing link tag */
417 __( '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' ),
418 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) . '">',
419 '</a>'
420 ),
421 'enabled_by_default' => true,
422 'disable_ui' => false,
423
424 /*
425 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
426 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
427 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
428 * feature.
429 *
430 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
431 */
432 'skip_compatibility_checks' => true,
433 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
434 'is_experimental' => false,
435 'setting' => array(
436 'disabled' => function () use ( $tracking_enabled ) {
437 return ! $tracking_enabled;
438 },
439 'desc_tip' => function () use ( $tracking_enabled ) {
440 if ( ! $tracking_enabled ) {
441 return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' );
442 }
443
444 return '';
445 },
446 ),
447 ),
448 'deferred_transactional_emails' => array(
449 'name' => __( 'Deferred emails', 'woocommerce' ),
450 'description' => __(
451 'Send transactional emails asynchronously via Action Scheduler instead of during the current request.',
452 'woocommerce'
453 ),
454 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
455 'enabled_by_default' => false,
456 'is_experimental' => false,
457 ),
458 'customer_review_request' => array(
459 'name' => __( 'Customer review request (beta)', 'woocommerce' ),
460 'description' => __(
461 'Send customers a transactional email after order completion inviting them to review the products they bought, and host the per-order Review Order landing page.',
462 'woocommerce'
463 ),
464 // Skip compatibility checks like the other opt-in transactional-email features.
465 'skip_compatibility_checks' => true,
466 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
467 'enabled_by_default' => false,
468 'is_experimental' => false,
469 ),
470 'abandoned_cart_recovery' => array(
471 'name' => __( 'Abandoned cart recovery', 'woocommerce' ),
472 'description' => __(
473 'Send a reminder email to shoppers who didn\'t finish checking out.',
474 'woocommerce'
475 ),
476 'skip_compatibility_checks' => false,
477 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
478 'enabled_by_default' => false,
479 'is_experimental' => true,
480 ),
481 'email_improvements' => array(
482 'name' => __( 'Email improvements', 'woocommerce' ),
483 'description' => __(
484 'Enable modern email design for transactional emails',
485 'woocommerce'
486 ),
487
488 /*
489 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
490 * but as this feature doesn't affect all extensions, and the rollout is fairly short,
491 * we'll skip the compatibility check by marking this as legacy. This is a workaround until
492 * we can implement a more sophisticated compatibility checking system.
493 *
494 * @see https://github.com/woocommerce/woocommerce/issues/39147
495 * @see https://github.com/woocommerce/woocommerce/issues/55540
496 */
497 'skip_compatibility_checks' => true,
498 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
499 'is_experimental' => false,
500 ),
501 'blueprint' => array(
502 'name' => __( 'Blueprint (beta)', 'woocommerce' ),
503 'description' => __(
504 'Enable blueprint to import and export settings in bulk',
505 'woocommerce'
506 ),
507 'enabled_by_default' => true,
508 'disable_ui' => false,
509
510 /*
511 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
512 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
513 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
514 * feature.
515 *
516 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
517 */
518 'skip_compatibility_checks' => true,
519 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
520 'is_experimental' => false,
521 ),
522 'block_email_editor' => array(
523 'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ),
524 'description' => __(
525 'Enable the block-based email editor for transactional emails.',
526 'woocommerce'
527 ),
528 'learn_more_url' => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256',
529
530 /*
531 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
532 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
533 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
534 * feature.
535 *
536 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
537 */
538 'skip_compatibility_checks' => true,
539 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
540 'enabled_by_default' => false,
541 ),
542 \Automattic\WooCommerce\Internal\VariationGallery\Package::FEATURE_ID => array(
543 'name' => __( 'Variation gallery', 'woocommerce' ),
544 'description' => __( 'Add multiple images per product variation.', 'woocommerce' ),
545 'is_experimental' => false,
546 'enabled_by_default' => true,
547 'disable_ui' => true,
548 'skip_compatibility_checks' => true,
549 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
550 'deprecated_since' => '11.1.0',
551 'deprecated_value' => true,
552 ),
553 'wc-visual-attribute' => array(
554 'name' => __( 'Color swatches for attributes', 'woocommerce' ),
555 'description' => __(
556 'Add color swatches to product attribute values.',
557 'woocommerce'
558 ),
559 'option_key' => 'woocommerce_feature_wc_visual_attribute_enabled',
560 'is_experimental' => true,
561 'enabled_by_default' => false,
562 'disable_ui' => false,
563 'skip_compatibility_checks' => true,
564 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
565 ),
566 'point_of_sale' => array(
567 'name' => __( 'Point of Sale', 'woocommerce' ),
568 'description' => __(
569 'Enable Point of Sale functionality in the WooCommerce mobile apps.',
570 'woocommerce'
571 ),
572 'is_experimental' => false,
573 'enabled_by_default' => true,
574 'disable_ui' => true,
575 'skip_compatibility_checks' => true,
576 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
577 'deprecated_since' => '11.0.0',
578 'deprecated_value' => true,
579 ),
580 'point_of_sale_staff' => array(
581 'name' => __( 'POS staff', 'woocommerce' ),
582 'description' => __(
583 'Experimental: POS staff management, roles, and order attribution.',
584 'woocommerce'
585 ),
586 'enabled_by_default' => false,
587 // Hidden while incomplete so it can't ship merchant-toggleable; flip to
588 // false when it's ready for an experimental preview.
589 'disable_ui' => true,
590 'is_experimental' => true,
591 'skip_compatibility_checks' => true,
592 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
593 ),
594 'fulfillments' => array(
595 'name' => __( 'Order Fulfillments', 'woocommerce' ),
596 'description' => __(
597 'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
598 'woocommerce'
599 ),
600 'enabled_by_default' => false,
601 'disable_ui' => true,
602 'is_experimental' => false,
603 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
604 ),
605 'mcp_integration' => array(
606 'name' => __( 'WooCommerce MCP', 'woocommerce' ),
607 'description' => $this->get_mcp_integration_description(),
608 'enabled_by_default' => false,
609 'disable_ui' => false,
610 'is_experimental' => true,
611 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
612 'is_legacy' => false,
613 ),
614 'destroy-empty-sessions' => array(
615 'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
616 'description' => __(
617 '[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.',
618 'woocommerce'
619 ),
620 'enabled_by_default' => false,
621 'is_experimental' => true,
622 'disable_ui' => false,
623 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
624 ),
625 'agentic_checkout' => array(
626 'name' => __( 'Agentic Checkout API', 'woocommerce' ),
627 'description' => __(
628 'Enable the Agentic Checkout API for AI-powered checkout experiences (e.g., ChatGPT). This adds REST API endpoints that allow AI agents to create and manage checkout sessions.',
629 'woocommerce'
630 ),
631 'enabled_by_default' => false,
632 'is_experimental' => true,
633 'disable_ui' => true,
634 'skip_compatibility_checks' => true,
635 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
636 ),
637 'dual_code_graphql_api' => array(
638 'name' => __( 'Dual Code & GraphQL API', 'woocommerce' ),
639 'description' => __(
640 'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
641 'woocommerce'
642 ),
643 'enabled_by_default' => false,
644 'is_experimental' => true,
645 'disable_ui' => true,
646 'skip_compatibility_checks' => true,
647 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
648 ),
649 PushNotifications::FEATURE_NAME => array(
650 'name' => __( 'Push Notifications', 'woocommerce' ),
651 'description' => __(
652 'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
653 'woocommerce'
654 ),
655 'is_experimental' => false,
656 'enabled_by_default' => true,
657 'disable_ui' => true,
658 'skip_compatibility_checks' => true,
659 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
660 'deprecated_since' => '10.9.2',
661 'deprecated_value' => true,
662 ),
663 'rest_api_caching' => array(
664 'name' => __( 'REST API Caching', 'woocommerce' ),
665 'description' => sprintf(
666 /* translators: %1$s and %2$s are opening and closing <a> tags */
667 __( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
668 '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
669 '</a>'
670 ),
671 'enabled_by_default' => false,
672 'is_experimental' => true,
673 'disable_ui' => false,
674 'skip_compatibility_checks' => true,
675 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
676 ),
677 'cart_save_for_later' => array(
678 'name' => __( 'Save for Later in Cart', 'woocommerce' ),
679 'description' => __(
680 'Let shoppers save cart items to a list to purchase later.',
681 'woocommerce'
682 ),
683 'is_experimental' => true,
684 'enabled_by_default' => false,
685 // Custom option_key as we expect this setting to move out of features to
686 // a cart/checkout settings section.
687 'option_key' => 'woocommerce_cart_save_for_later_enabled',
688 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
689 ),
690 'product_wishlist' => array(
691 'name' => __( 'Wishlists', 'woocommerce' ),
692 'description' => __(
693 'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
694 'woocommerce'
695 ),
696 'is_experimental' => true,
697 'enabled_by_default' => false,
698 'option_key' => 'woocommerce_product_wishlist_enabled',
699 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
700 ),
701 ProductCacheController::FEATURE_NAME => array(
702 'name' => __( 'Cache Product Objects', 'woocommerce' ),
703 'description' => __(
704 '[Performance] Speeds up your store by caching product objects during each request, preventing duplicate product loads. Can improve page load times on product-heavy pages.',
705 'woocommerce'
706 ),
707 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
708 'enabled_by_default' => false,
709 'is_experimental' => true,
710 'disable_ui' => false,
711 ),
712 BlockEditorUnifiedAssets::FEATURE_NAME => array(
713 'name' => __( 'Unified block editor assets', 'woocommerce' ),
714 'description' => __( 'Load WooCommerce block editor scripts and styles from shared bundles to improve editor performance.', 'woocommerce' ),
715 'option_key' => BlockEditorUnifiedAssets::OPTION_NAME,
716 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
717 'enabled_by_default' => false,
718 'is_experimental' => true,
719 'disable_ui' => false,
720 ),
721 );
722
723 if ( ! $tracking_enabled ) {
724 // Uncheck the remote logging feature when usage tracking is disabled.
725 $legacy_features['remote_logging']['setting']['value'] = 'no';
726 }
727
728 foreach ( $legacy_features as $slug => $definition ) {
729 $this->add_feature_definition( $slug, $definition['name'], $definition );
730 }
731
732 // Preload option caches to minimize future queries for options that do not yet exist or are not set to autoload.
733 wp_prime_option_caches(
734 array_map(
735 static fn( $slug, $definition ) => $definition['option_key'] ?? sprintf( 'woocommerce_feature_%s_enabled', $slug ),
736 array_keys( $this->features ),
737 $this->features
738 )
739 );
740
741 $this->init_compatibility_info_by_feature();
742 }
743
744 /**
745 * Initialize the compatibility_info_by_feature property after all the features have been added.
746 */
747 private function init_compatibility_info_by_feature() {
748 foreach ( array_keys( $this->features ) as $feature_id ) {
749 if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
750 $this->compatibility_info_by_feature[ $feature_id ] = array(
751 FeaturePluginCompatibility::COMPATIBLE => array(),
752 FeaturePluginCompatibility::INCOMPATIBLE => array(),
753 );
754 }
755 }
756 }
757
758 /**
759 * Generate the description for the MCP integration feature.
760 *
761 * @return string The feature description with conditional permalink warning and documentation link.
762 */
763 private function get_mcp_integration_description() {
764 $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' );
765
766 // Check permalink structure requirement.
767 $permalink_structure = get_option( 'permalink_structure' );
768 if ( empty( $permalink_structure ) ) {
769 $permalinks_url = admin_url( 'options-permalink.php' );
770 $permalink_warning = sprintf(
771 '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
772 __( 'Configuration Required', 'woocommerce' ),
773 __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
774 $permalinks_url,
775 __( 'Configure Permalinks', 'woocommerce' )
776 );
777 // Add documentation link to permalink warning.
778 $documentation_link = sprintf(
779 ' <a href="%s" target="_blank">%s</a>',
780 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
781 __( 'Learn more', 'woocommerce' )
782 );
783 return $base_description . $permalink_warning . $documentation_link;
784 }
785
786 // Add documentation link.
787 $documentation_link = sprintf(
788 ' <a href="%s" target="_blank">%s</a>',
789 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
790 __( 'Learn more', 'woocommerce' )
791 );
792
793 return $base_description . $documentation_link;
794 }
795
796 /**
797 * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
798 *
799 * This function must execute immediately before the 'before_woocommerce_init'
800 * action is fired, so that feature compatibility declarations happening
801 * in that action find all the features properly declared already.
802 *
803 * @internal
804 */
805 public function register_additional_features() {
806 if ( $this->registered_additional_features_via_action ) {
807 return;
808 }
809
810 if ( empty( $this->features ) ) {
811 $this->init_feature_definitions();
812 }
813
814 /**
815 * The action for registering features.
816 *
817 * @since 8.3.0
818 *
819 * @param FeaturesController $features_controller The instance of FeaturesController.
820 *
821 * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
822 */
823 do_action( 'woocommerce_register_feature_definitions', $this );
824
825 $this->init_compatibility_info_by_feature();
826
827 $this->registered_additional_features_via_action = true;
828 }
829
830 /**
831 * Initialize the class instance.
832 *
833 * @internal
834 *
835 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
836 * @param PluginUtil $plugin_util The instance of PluginUtil to use.
837 */
838 final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
839 $this->proxy = $proxy;
840 $this->plugin_util = $plugin_util;
841
842 $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
843 }
844
845 /**
846 * Get all the existing WooCommerce features.
847 *
848 * Returns an associative array where keys are unique feature ids
849 * and values are arrays with these keys:
850 *
851 * - name (string)
852 * - description (string)
853 * - is_experimental (bool)
854 * - is_enabled (bool) (only if $include_enabled_info is passed as true)
855 *
856 * @param bool $include_experimental Include also experimental/work in progress features in the list.
857 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
858 * @returns array An array of information about existing features.
859 */
860 public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
861 $features = $this->get_feature_definitions();
862
863 if ( ! $include_experimental ) {
864 $features = array_filter(
865 $features,
866 function ( $feature ) {
867 return ! $feature['is_experimental'];
868 }
869 );
870 }
871
872 if ( $include_enabled_info ) {
873 foreach ( array_keys( $features ) as $feature_id ) {
874 $is_enabled = false;
875 // For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
876 // The deprecation notice should only fire for external code checking feature status, not for internal listing.
877 if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
878 $is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
879 } else {
880 $is_enabled = $this->feature_is_enabled( $feature_id );
881 }
882 $features[ $feature_id ]['is_enabled'] = $is_enabled;
883 }
884 }
885
886 if ( isset( $features['wc-visual-attribute'] ) && ! wp_is_block_theme() ) {
887 $features['wc-visual-attribute']['disable_ui'] = true;
888 }
889
890 return $features;
891 }
892
893 /**
894 * Get the default plugin compatibility for a given feature.
895 *
896 * @param string $feature_id Feature id to check.
897 * @return string Either 'compatible' or 'incompatible'.
898 * @throws \InvalidArgumentException If the feature doesn't exist.
899 */
900 public function get_default_plugin_compatibility( string $feature_id ): string {
901 $feature = $this->get_feature_definition( $feature_id );
902 if ( null === $feature ) {
903 throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
904 }
905
906 $default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;
907
908 // Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
909 /**
910 * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
911 * are to be considered incompatible with that feature.
912 *
913 * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
914 * @param string $feature_id The feature to check.
915 *
916 * @since 9.2.0
917 */
918 $incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );
919
920 return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
921 }
922
923 /**
924 * Get the definition array for a specific feature.
925 *
926 * @param string $feature_id Unique feature id.
927 * @return array|null The feature definition array, or null if the feature doesn't exist.
928 *
929 * @since 10.5.0
930 */
931 public function get_feature_definition( string $feature_id ): ?array {
932 return $this->get_feature_definitions()[ $feature_id ] ?? null;
933 }
934
935 /**
936 * Check if a given feature is currently enabled.
937 *
938 * Note: This method does not log deprecation notices for deprecated features.
939 * Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
940 *
941 * @param string $feature_id Unique feature id.
942 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
943 */
944 public function feature_is_enabled( string $feature_id ): bool {
945 $feature = $this->get_feature_definition( $feature_id );
946
947 if ( null === $feature ) {
948 return false;
949 }
950
951 // Handle deprecated features - return the backwards-compatible value.
952 if ( ! empty( $feature['deprecated_since'] ) ) {
953 return (bool) ( $feature['deprecated_value'] ?? false );
954 }
955
956 if ( 'analytics' === $feature_id && WCAdminFeatures::is_analytics_disabled_by_legacy_filters() ) {
957 return false;
958 }
959
960 if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
961 return true;
962 }
963
964 $default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
965 $value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
966 return $value;
967 }
968
969 /**
970 * Check if a given feature is enabled by default.
971 *
972 * @param string $feature_id Unique feature id.
973 * @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
974 */
975 private function feature_is_enabled_by_default( string $feature_id ): bool {
976 $features = $this->get_feature_definitions();
977
978 return ! empty( $features[ $feature_id ]['enabled_by_default'] );
979 }
980
981 /**
982 * Change the enabled/disabled status of a feature.
983 *
984 * @param string $feature_id Unique feature id.
985 * @param bool $enable True to enable the feature, false to disable it.
986 * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
987 */
988 public function change_feature_enable( string $feature_id, bool $enable ): bool {
989 if ( ! $this->feature_exists( $feature_id ) ) {
990 return false;
991 }
992
993 return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
994 }
995
996 /**
997 * Declare (in)compatibility with a given feature for a given plugin.
998 *
999 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
1000 *
1001 * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
1002 * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
1003 * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
1004 *
1005 * @param string $feature_id Unique feature id.
1006 * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
1007 * @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
1008 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
1009 * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
1010 */
1011 public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
1012 if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
1013 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
1014 /* translators: 1: class::method 2: before_woocommerce_init */
1015 $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' );
1016 return false;
1017 }
1018 if ( ! $this->feature_exists( $feature_id ) ) {
1019 return false;
1020 }
1021
1022 if ( $this->lazy ) {
1023 // Lazy mode: Queue to be normalized later.
1024 $this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
1025 return true;
1026 }
1027
1028 // Late call: Normalize and register immediately.
1029 return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
1030 }
1031
1032 /**
1033 * Registers compatibility information internally for a given feature and plugin file.
1034 *
1035 * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
1036 * and registers the compatibility data if valid.
1037 * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
1038 * compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
1039 * Duplicate declarations (same compatibility type) are ignored.
1040 *
1041 * This is an internal helper method and should not be called directly.
1042 *
1043 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1044 * @since 10.1.0
1045 *
1046 * @param string $feature_id Unique feature ID.
1047 * @param string $plugin_file Raw plugin file path (full or 'directory/file.php').
1048 * @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
1049 * @return bool True on successful registration, false if the feature does not exist.
1050 * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
1051 */
1052 private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
1053 if ( ! $this->feature_exists( $feature_id ) ) {
1054 return false;
1055 }
1056
1057 // Normalize and validate plugin file.
1058 $plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
1059 if ( ! $plugin_id ) {
1060 $logger = $this->proxy->call_function( 'wc_get_logger' );
1061 $logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
1062 return false;
1063 }
1064
1065 // Register compatibility by plugin.
1066 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );
1067
1068 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1069 $opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
1070 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
1071 ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );
1072
1073 if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
1074 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" ) );
1075 }
1076
1077 if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
1078 $this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
1079 }
1080
1081 // Register compatibility by feature.
1082 $key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
1083
1084 if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
1085 $this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
1086 }
1087
1088 return true;
1089 }
1090
1091 /**
1092 * Processes any pending compatibility declarations by normalizing plugin file paths
1093 * and registering them internally.
1094 *
1095 * This method is called lazily when compatibility information is queried (via
1096 * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
1097 * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
1098 * Pending declarations are cleared after processing to avoid redundant work.
1099 *
1100 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
1101 * @since 10.1.0
1102 * @return void
1103 */
1104 private function process_pending_declarations(): void {
1105 if ( empty( $this->pending_declarations ) ) {
1106 return;
1107 }
1108
1109 foreach ( $this->pending_declarations as $declaration ) {
1110 list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;
1111
1112 // Register internally.
1113 $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
1114 }
1115
1116 $this->pending_declarations = array();
1117 $this->lazy = false;
1118 }
1119
1120 /**
1121 * Check whether a feature exists with a given id.
1122 *
1123 * @param string $feature_id The feature id to check.
1124 * @return bool True if the feature exists.
1125 */
1126 private function feature_exists( string $feature_id ): bool {
1127 $features = $this->get_feature_definitions();
1128
1129 return isset( $features[ $feature_id ] );
1130 }
1131
1132 /**
1133 * Get the ids of the features that a certain plugin has declared compatibility for.
1134 *
1135 * This method can't be called before the 'woocommerce_init' hook is fired.
1136 *
1137 * @param string $plugin_name Plugin name, in the form 'directory/file.php'.
1138 * @param bool $enabled_features_only True to return only names of enabled plugins.
1139 * @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible.
1140 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
1141 */
1142 public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
1143 $this->process_pending_declarations();
1144 $this->verify_did_woocommerce_init( __FUNCTION__ );
1145
1146 $features = $this->get_feature_definitions();
1147
1148 if ( $enabled_features_only ) {
1149 $features = array_filter(
1150 $features,
1151 array( $this, 'feature_is_enabled' ),
1152 ARRAY_FILTER_USE_KEY
1153 );
1154 }
1155
1156 if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
1157 return array(
1158 FeaturePluginCompatibility::COMPATIBLE => array(),
1159 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1160 FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ),
1161 );
1162 }
1163
1164 $info = $this->compatibility_info_by_plugin[ $plugin_name ];
1165 $info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
1166 $info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1167 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1168
1169 if ( $resolve_uncertain ) {
1170 foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
1171 $key = $this->get_default_plugin_compatibility( $feature_id );
1172 $info[ $key ][] = $feature_id;
1173 }
1174
1175 $info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
1176 }
1177
1178 return $info;
1179 }
1180
1181 /**
1182 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
1183 *
1184 * @param string $feature_id Feature id.
1185 * @param bool $active_only True to return only active plugins.
1186 * @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
1187 * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
1188 */
1189 public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
1190 $this->process_pending_declarations();
1191 $this->verify_did_woocommerce_init( __FUNCTION__ );
1192
1193 $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
1194 if ( ! $this->feature_exists( $feature_id ) ) {
1195 return array(
1196 FeaturePluginCompatibility::COMPATIBLE => array(),
1197 FeaturePluginCompatibility::INCOMPATIBLE => array(),
1198 FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins,
1199 );
1200 }
1201
1202 $info = $this->compatibility_info_by_feature[ $feature_id ];
1203 ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );
1204
1205 // Resolve uncertain plugin compatibility?
1206 $uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
1207 $key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
1208 $info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins );
1209
1210 return $info;
1211 }
1212
1213 /**
1214 * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
1215 *
1216 * @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.
1217 *
1218 * @return bool True if 'woocommerce_init' has run or is running, false otherwise.
1219 */
1220 private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
1221 if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
1222 ! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
1223 if ( ! is_null( $function_name ) ) {
1224 $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
1225 /* translators: 1: class::method 2: plugins_loaded */
1226 $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' );
1227 }
1228 return false;
1229 }
1230
1231 return true;
1232 }
1233
1234 /**
1235 * Get the name of the option that enables/disables a given feature.
1236 *
1237 * Note that it doesn't check if the feature actually exists. Instead it
1238 * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
1239 * name isn't specified in the feature registration.
1240 *
1241 * @param string $feature_id The id of the feature.
1242 * @return string The option that enables or disables the feature.
1243 */
1244 public function feature_enable_option_name( string $feature_id ): string {
1245 $features = $this->get_feature_definitions();
1246
1247 if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
1248 return $features[ $feature_id ]['option_key'];
1249 }
1250
1251 return "woocommerce_feature_{$feature_id}_enabled";
1252 }
1253
1254 /**
1255 * Check if the compatibility checks should be skipped for a given feature.
1256 *
1257 * @since 10.3.0
1258 *
1259 * @param string $feature_id The feature id to check.
1260 * @return bool TRUE if the compatibility checks should be skipped.
1261 */
1262 public function should_skip_compatibility_checks( string $feature_id ): bool {
1263 $features = $this->get_feature_definitions();
1264
1265 return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
1266 }
1267
1268 /**
1269 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
1270 * from the WooCommerce feature settings page.
1271 */
1272 public function allow_enabling_features_with_incompatible_plugins(): void {
1273 $this->force_allow_enabling_features = true;
1274 }
1275
1276 /**
1277 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
1278 * from the WordPress plugins page.
1279 */
1280 public function allow_activating_plugins_with_incompatible_features(): void {
1281 $this->force_allow_enabling_plugins = true;
1282 }
1283
1284 /**
1285 * Adds our callbacks for the `updated_option` and `added_option` filter hooks.
1286 *
1287 * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
1288 * and building that list requires translating various strings (which should not be done earlier than `init`).
1289 *
1290 * @return void
1291 *
1292 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1293 */
1294 public function start_listening_for_option_changes(): void {
1295 add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
1296 add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
1297 }
1298
1299 /**
1300 * Handler for the 'added_option' hook.
1301 *
1302 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1303 *
1304 * @param string $option The option that has been created.
1305 * @param mixed $value The value of the option.
1306 *
1307 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1308 */
1309 public function process_added_option( string $option, $value ) {
1310 $this->process_updated_option( $option, false, $value );
1311 }
1312
1313 /**
1314 * Handler for the 'updated_option' hook.
1315 *
1316 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
1317 *
1318 * @param string $option The option that has been modified.
1319 * @param mixed $old_value The old value of the option.
1320 * @param mixed $value The new value of the option.
1321 *
1322 * @return void
1323 *
1324 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1325 */
1326 public function process_updated_option( string $option, $old_value, $value ) {
1327 $matches = array();
1328 $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
1329 $features_with_custom_keys = array_filter(
1330 $this->get_feature_definitions(),
1331 function ( $feature ) {
1332 return ! empty( $feature['option_key'] );
1333 }
1334 );
1335 $custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
1336
1337 if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
1338 return;
1339 }
1340
1341 if ( $value === $old_value ) {
1342 return;
1343 }
1344
1345 $feature_id = '';
1346 if ( $is_default_key ) {
1347 $feature_id = $matches[1];
1348 } elseif ( in_array( $option, $custom_keys, true ) ) {
1349 $feature_id = array_search( $option, $custom_keys, true );
1350 }
1351
1352 if ( ! $feature_id ) {
1353 return;
1354 }
1355
1356 WC_Tracks::record_event(
1357 self::FEATURE_ENABLED_CHANGED_ACTION,
1358 array(
1359 'feature_id' => $feature_id,
1360 'enabled' => $value,
1361 )
1362 );
1363
1364 /**
1365 * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
1366 *
1367 * @param string $feature_id The id of the feature.
1368 * @param bool $enabled True if the feature has been enabled, false if it has been disabled.
1369 *
1370 * @since 7.0.0
1371 */
1372 do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
1373 }
1374
1375 /**
1376 * Handler for the 'woocommerce_get_sections_advanced' hook,
1377 * it adds the "Features" section to the advanced settings page.
1378 *
1379 * @param array $sections The original sections array.
1380 * @return array The updated sections array.
1381 *
1382 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1383 */
1384 public function add_features_section( $sections ) {
1385 if ( ! isset( $sections['features'] ) ) {
1386 $sections['features'] = __( 'Features', 'woocommerce' );
1387 }
1388 return $sections;
1389 }
1390
1391 /**
1392 * Handler for the 'woocommerce_settings-advanced' hook, which defines the settings
1393 * exposed in the wc/v3 settings REST API for the 'advanced' group. It appends the
1394 * Point of Sale feature flag setting.
1395 *
1396 * This is a compatibility shim for the WooCommerce mobile apps: app versions released
1397 * before the point_of_sale feature became always enabled (deprecated in 11.0.0) read and
1398 * write this setting via wc/v3/settings/advanced/woocommerce_feature_point_of_sale_enabled
1399 * to decide whether POS can be used. The setting is no longer rendered in the admin UI;
1400 * this shim can be removed once those app versions are no longer supported.
1401 *
1402 * @param array $settings The settings of the 'advanced' group, as exposed in the REST API.
1403 * @return array The updated settings array.
1404 *
1405 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1406 */
1407 public function add_point_of_sale_setting_for_rest_api( $settings ): array {
1408 $settings[] = array(
1409 'id' => 'woocommerce_feature_point_of_sale_enabled',
1410 'option_key' => 'woocommerce_feature_point_of_sale_enabled',
1411 'label' => __( 'Point of Sale', 'woocommerce' ),
1412 'description' => __( 'Enable Point of Sale functionality in the WooCommerce mobile apps.', 'woocommerce' ),
1413 'type' => 'checkbox',
1414 'default' => 'yes',
1415 );
1416 return $settings;
1417 }
1418
1419 /**
1420 * Handler for the 'woocommerce_get_settings_advanced' hook,
1421 * it adds the settings UI for all the existing features.
1422 *
1423 * Note that the settings added via the 'woocommerce_settings_features' hook will be
1424 * displayed in the non-experimental features section.
1425 *
1426 * @param array $settings The existing settings for the corresponding settings section.
1427 * @param string $current_section The section to get the settings for.
1428 * @return array The updated settings array.
1429 *
1430 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1431 */
1432 public function add_feature_settings( $settings, $current_section ): array {
1433 if ( 'features' !== $current_section ) {
1434 return $settings;
1435 }
1436
1437 $feature_settings = array(
1438 array(
1439 'title' => __( 'Features', 'woocommerce' ),
1440 'type' => 'title',
1441 'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
1442 'id' => 'features_options',
1443 ),
1444 );
1445
1446 $features = $this->get_features( true );
1447
1448 $feature_ids = array_keys( $features );
1449 usort(
1450 $feature_ids,
1451 function ( $feature_id_a, $feature_id_b ) use ( $features ) {
1452 return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
1453 }
1454 );
1455 $experimental_feature_ids = array_filter(
1456 $feature_ids,
1457 function ( $feature_id ) use ( $features ) {
1458 return $features[ $feature_id ]['is_experimental'] ?? false;
1459 }
1460 );
1461 $mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
1462 $feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
1463
1464 foreach ( $feature_ids as $id ) {
1465 if ( 'mature_features_end' === $id ) {
1466 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1467 /**
1468 * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
1469 *
1470 * @param bool $disabled False.
1471 */
1472 $feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
1473 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1474
1475 if ( ! empty( $experimental_feature_ids ) ) {
1476 $feature_settings[] = array(
1477 'type' => 'sectionend',
1478 'id' => 'features_options',
1479 );
1480
1481 $feature_settings[] = array(
1482 'title' => __( 'Experimental features', 'woocommerce' ),
1483 'type' => 'title',
1484 'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
1485 'id' => 'experimental_features_options',
1486 );
1487 }
1488 continue;
1489 }
1490
1491 if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
1492 continue;
1493 }
1494
1495 if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
1496 continue;
1497 }
1498
1499 $feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
1500
1501 $additional_settings = $features[ $id ]['additional_settings'] ?? array();
1502 if ( count( $additional_settings ) > 0 ) {
1503 $feature_settings = array_merge( $feature_settings, $additional_settings );
1504 }
1505 }
1506
1507 $feature_settings[] = array(
1508 'type' => 'sectionend',
1509 'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
1510 );
1511
1512 if ( $this->verify_did_woocommerce_init() ) {
1513 // Allow feature setting properties to be determined dynamically just before being rendered.
1514 $feature_settings = array_map(
1515 function ( $feature_setting ) {
1516 foreach ( $feature_setting as $prop => $value ) {
1517 if ( is_callable( $value ) ) {
1518 $feature_setting[ $prop ] = call_user_func( $value );
1519 }
1520 }
1521
1522 return $feature_setting;
1523 },
1524 $feature_settings
1525 );
1526 }
1527
1528 return $feature_settings;
1529 }
1530
1531 /**
1532 * Get the parameters to display the setting enable/disable UI for a given feature.
1533 *
1534 * @param string $feature_id The feature id.
1535 * @param array $feature The feature parameters, as returned by get_features.
1536 * @return array The parameters to add to the settings array.
1537 */
1538 private function get_setting_for_feature( string $feature_id, array $feature ): array {
1539 $description = $feature['description'] ?? '';
1540 $disabled = false;
1541 $desc_tip = '';
1542 $tooltip = $feature['tooltip'] ?? '';
1543 $type = $feature['type'] ?? 'checkbox';
1544 $setting_definition = $feature['setting'] ?? array();
1545
1546 // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1547 /**
1548 * Filter allowing WooCommerce Admin to be disabled.
1549 *
1550 * @param bool $disabled False.
1551 */
1552 $admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
1553 // phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
1554
1555 if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
1556 $disabled = true;
1557 $desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
1558 } elseif ( 'new_navigation' === $feature_id ) {
1559 $update_text = sprintf(
1560 // translators: 1: line break tag.
1561 __(
1562 '%1$s This navigation will soon become unavailable while we make necessary improvements.
1563 If you turn it off now, you will not be able to turn it back on.',
1564 'woocommerce'
1565 ),
1566 '<br/>'
1567 );
1568
1569 $needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
1570 if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
1571 $update_text = sprintf(
1572 // translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
1573 __( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
1574 '<br/>',
1575 '<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
1576 '</a>'
1577 );
1578 $disabled = true;
1579 }
1580
1581 if ( ! empty( $update_text ) ) {
1582 $description .= $update_text;
1583 }
1584 }
1585
1586 if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
1587 $plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
1588 $desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
1589 }
1590
1591 /**
1592 * Filter to customize the description tip that appears under the description of each feature in the features settings page.
1593 *
1594 * @since 7.1.0
1595 *
1596 * @param string $desc_tip The original description tip.
1597 * @param string $feature_id The id of the feature for which the description tip is being customized.
1598 * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
1599 * @return string The new description tip to use.
1600 */
1601 $desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
1602
1603 $feature_setting_defaults = array(
1604 'title' => $feature['name'],
1605 'desc' => $description,
1606 'type' => $type,
1607 'id' => $this->feature_enable_option_name( $feature_id ),
1608 'disabled' => $disabled && ! $this->force_allow_enabling_features,
1609 'desc_tip' => $desc_tip,
1610 'tooltip' => $tooltip,
1611 'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
1612 );
1613
1614 $feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
1615
1616 if ( ! empty( $feature['learn_more_url'] ) ) {
1617 $feature_setting['desc'] .= sprintf(
1618 '<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
1619 esc_attr( $feature['learn_more_url'] ),
1620 esc_html__( 'Learn more', 'woocommerce' )
1621 );
1622 }
1623
1624 /**
1625 * Allows to modify feature setting that will be used to render in the feature page.
1626 *
1627 * @param array $feature_setting The feature setting. Describes the feature:
1628 * - title: The title of the feature.
1629 * - desc: The description of the feature. Will be displayed under the title.
1630 * - 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.
1631 * - id: The id of the feature. Will be used as the name of the setting.
1632 * - disabled: Whether the feature is disabled or not.
1633 * - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
1634 * - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
1635 * - default: The default value of the feature.
1636 * @param string $feature_id The id of the feature.
1637 * @since 8.0.0
1638 */
1639 return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
1640 }
1641
1642 /**
1643 * Handle the plugin deactivation hook.
1644 *
1645 * @param string $plugin_name Name of the plugin that has been deactivated.
1646 *
1647 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1648 */
1649 public function handle_plugin_deactivation( $plugin_name ): void {
1650 unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
1651
1652 foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
1653 $compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ];
1654 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) );
1655
1656 $incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ];
1657 $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) );
1658 }
1659 }
1660
1661 /**
1662 * Handler for the all_plugins filter.
1663 *
1664 * Returns the list of plugins incompatible with a given plugin
1665 * if we are in the plugins page and the query string of the current request
1666 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1667 *
1668 * @param array $plugin_list The original list of plugins.
1669 *
1670 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1671 */
1672 public function filter_plugins_list( $plugin_list ): array {
1673 if ( ! $this->verify_did_woocommerce_init() ) {
1674 return $plugin_list;
1675 }
1676
1677 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1678 if ( ! function_exists( 'get_current_screen' ) ||
1679 ( get_current_screen() && 'plugins' !== get_current_screen()->id ) ||
1680 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
1681 return $plugin_list;
1682 }
1683
1684 $feature_id = $_GET['feature_id'] ?? 'all';
1685 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1686 return $plugin_list;
1687 }
1688
1689 return $this->get_incompatible_plugins( $feature_id, $plugin_list );
1690 }
1691
1692 /**
1693 * Returns the list of plugins incompatible with a given feature.
1694 *
1695 * @param string $feature_id ID of the feature. Can also be `all` to denote all features.
1696 * @param array $plugin_list List of plugins to filter.
1697 *
1698 * @return array List of plugins incompatible with the given feature.
1699 */
1700 public function get_incompatible_plugins( $feature_id, $plugin_list ) {
1701 $incompatibles = array();
1702 $plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
1703 $feature_ids = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id );
1704 $only_enabled_features = 'all' === $feature_id;
1705
1706 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
1707 foreach ( array_keys( $plugin_list ) as $plugin_name ) {
1708 if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
1709 continue;
1710 }
1711
1712 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name );
1713 foreach ( $feature_ids as $feature_id ) {
1714 $features_considered_incompatible = array_filter(
1715 $this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ),
1716 $only_enabled_features ?
1717 fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) :
1718 fn( $id ) => ! $this->should_skip_compatibility_checks( $id )
1719 );
1720 if ( in_array( $feature_id, $features_considered_incompatible, true ) ) {
1721 $incompatibles[] = $plugin_name;
1722 }
1723 }
1724 }
1725
1726 return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
1727 }
1728
1729 /**
1730 * Handler for the admin_notices action.
1731 *
1732 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1733 */
1734 public function display_notices_in_plugins_page(): void {
1735 if ( ! $this->verify_did_woocommerce_init() ) {
1736 return;
1737 }
1738
1739 $feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
1740 if ( ! $feature_filter_description_shown ) {
1741 $this->maybe_display_feature_incompatibility_warning();
1742 }
1743 }
1744
1745 /**
1746 * Shows a warning when there are any incompatibility between active plugins and enabled features.
1747 * The warning is shown in on any admin screen except the plugins screen itself, since
1748 * there's already a "You are viewing plugins that are incompatible" notice.
1749 */
1750 private function maybe_display_feature_incompatibility_warning(): void {
1751 if ( ! current_user_can( 'activate_plugins' ) ) {
1752 return;
1753 }
1754
1755 $incompatible_plugins = false;
1756 $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui );
1757
1758 foreach ( $relevant_plugins as $plugin ) {
1759 $compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true );
1760
1761 $incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1762 if ( ! empty( $incompatibles ) ) {
1763 $incompatible_plugins = true;
1764 break;
1765 }
1766
1767 $uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
1768 foreach ( $uncertains as $feature_id ) {
1769 if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) {
1770 $incompatible_plugins = true;
1771 break;
1772 }
1773 }
1774
1775 if ( $incompatible_plugins ) {
1776 break;
1777 }
1778 }
1779
1780 if ( ! $incompatible_plugins ) {
1781 return;
1782 }
1783
1784 $message = str_replace(
1785 '<a>',
1786 '<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
1787 __( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
1788 );
1789
1790 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1791 ?>
1792 <div class="notice notice-error">
1793 <p><?php echo $message; ?></p>
1794 </div>
1795 <?php
1796 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1797 }
1798
1799 /**
1800 * Shows a "You are viewing the plugins that are incompatible with the X feature"
1801 * if we are in the plugins page and the query string of the current request
1802 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
1803 */
1804 private function maybe_display_current_feature_filter_description(): bool {
1805 if ( 'plugins' !== get_current_screen()->id ) {
1806 return false;
1807 }
1808
1809 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1810 $plugin_status = $_GET['plugin_status'] ?? '';
1811 $feature_id = $_GET['feature_id'] ?? '';
1812 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
1813
1814 if ( 'incompatible_with_feature' !== $plugin_status ) {
1815 return false;
1816 }
1817
1818 $feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
1819
1820 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
1821 return false;
1822 }
1823
1824 $features = $this->get_feature_definitions();
1825 $plugins_page_url = admin_url( 'plugins.php' );
1826 $features_page_url = $this->get_features_page_url();
1827
1828 $message =
1829 'all' === $feature_id
1830 ? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
1831 : sprintf(
1832 /* translators: %s is a feature name. */
1833 __( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
1834 $features[ $feature_id ]['name']
1835 );
1836
1837 $message .= '<br />';
1838 $message .= sprintf(
1839 __( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
1840 $plugins_page_url,
1841 $features_page_url
1842 );
1843
1844 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1845 ?>
1846 <div class="notice notice-info">
1847 <p><?php echo $message; ?></p>
1848 </div>
1849 <?php
1850 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1851
1852 return true;
1853 }
1854
1855 /**
1856 * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
1857 *
1858 * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
1859 * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
1860 * to inject useful information of its own.
1861 *
1862 * @see https://github.com/woocommerce/woocommerce/issues/37343
1863 *
1864 * @return void
1865 *
1866 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1867 */
1868 public function maybe_invalidate_cached_plugin_data(): void {
1869 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1870 if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
1871 wp_cache_delete( 'plugins', 'plugins' );
1872 }
1873 }
1874
1875 /**
1876 * Handler for the 'after_plugin_row' action.
1877 * Displays a "This plugin is incompatible with X features" notice if necessary.
1878 *
1879 * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
1880 * @param array $plugin_data Plugin data, as returned by 'get_plugins'.
1881 *
1882 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1883 */
1884 public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
1885 global $wp_list_table;
1886
1887 if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) {
1888 return;
1889 }
1890
1891 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
1892 return;
1893 }
1894
1895 if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
1896 return;
1897 }
1898
1899 if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
1900 return;
1901 }
1902
1903 $features = $this->get_feature_definitions();
1904 $feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true );
1905 $incompatible_features = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ];
1906 $incompatible_features = array_values(
1907 array_filter(
1908 $incompatible_features,
1909 function ( $feature_id ) {
1910 return ! $this->should_skip_compatibility_checks( $feature_id );
1911 }
1912 )
1913 );
1914
1915 $incompatible_features_count = count( $incompatible_features );
1916 if ( $incompatible_features_count > 0 ) {
1917 $columns_count = $wp_list_table->get_column_count();
1918 $is_active = true;
1919 // For now we are showing active plugins in the "Incompatible with..." view.
1920 $is_active_class = $is_active ? 'active' : 'inactive';
1921 $is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
1922
1923 if ( 1 === $incompatible_features_count ) {
1924 $message = sprintf(
1925 /* translators: %s = printable plugin name */
1926 __( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
1927 $features[ $incompatible_features[0] ]['name']
1928 );
1929 } elseif ( 2 === $incompatible_features_count ) {
1930 /* translators: %1\$s, %2\$s = printable plugin names */
1931 $message = sprintf(
1932 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
1933 $features[ $incompatible_features[0] ]['name'],
1934 $features[ $incompatible_features[1] ]['name']
1935 );
1936 } else {
1937 /* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
1938 $message = sprintf(
1939 __( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
1940 $features[ $incompatible_features[0] ]['name'],
1941 $features[ $incompatible_features[1] ]['name'],
1942 $incompatible_features_count - 2
1943 );
1944 }
1945 $features_page_url = $this->get_features_page_url();
1946 $manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
1947
1948 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
1949 ?>
1950 <tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
1951 <td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
1952 <div class='notice inline notice-warning notice-alt'>
1953 <p>
1954 <?php echo $message; ?>
1955 <a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
1956 </p>
1957 </div>
1958 </td>
1959 </tr>
1960 <?php
1961 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
1962 }
1963 }
1964
1965 /**
1966 * Get the URL of the features settings page.
1967 *
1968 * @return string
1969 */
1970 public function get_features_page_url(): string {
1971 return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
1972 }
1973
1974 /**
1975 * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
1976 *
1977 * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
1978 *
1979 * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
1980 * border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
1981 * it will have a "plugin-update-tr" class which will draw the missing lower border line.
1982 * - Otherwise, the <tr> will be already drawn with the lower border line.
1983 *
1984 * This is a problem for our rendering of the "plugin is incompatible with X features" warning:
1985 *
1986 * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
1987 * our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
1988 * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
1989 *
1990 * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
1991 * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
1992 * (and then some extra manual tweaking of margins is needed).
1993 *
1994 * @param string $current_screen The current screen object.
1995 *
1996 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
1997 */
1998 public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
1999 if ( 'plugins' !== $current_screen->id ) {
2000 return;
2001 }
2002
2003 $handle = 'wc-features-fix-plugin-list-html';
2004 wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
2005 wp_enqueue_script( $handle );
2006 wp_add_inline_script(
2007 $handle,
2008 "
2009 const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
2010 for(const warningRow of warningRows) {
2011 const pluginName = warningRow.getAttribute('data-plugin');
2012 const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
2013 if(!pluginInfoRow) {
2014 continue;
2015 }
2016 if(pluginInfoRow.classList.contains('update')) {
2017 warningRow.classList.remove('plugin-update-tr');
2018 warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
2019 }
2020 else {
2021 pluginInfoRow.classList.add('update');
2022 }
2023 }
2024 "
2025 );
2026 }
2027
2028 /**
2029 * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
2030 * If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
2031 * "All" (so that it's easy to go back to a known state) and "Incompatible with X".
2032 * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
2033 * the plugins list via the 'all_plugins' filter.
2034 *
2035 * @param array $views An array of view ids => view links.
2036 * @return string[] The actual views array to use.
2037 *
2038 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2039 */
2040 public function handle_plugins_page_views_list( $views ): array {
2041 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
2042 if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
2043 return $views;
2044 }
2045
2046 $feature_id = $_GET['feature_id'] ?? 'all';
2047 if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
2048 return $views;
2049 }
2050 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
2051
2052 $all_items = get_plugins();
2053 $features = $this->get_feature_definitions();
2054
2055 $incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
2056 $incompatible_text =
2057 'all' === $feature_id
2058 ? __( 'Incompatible with WooCommerce features', 'woocommerce' )
2059 /* translators: %s = name of a WooCommerce feature */
2060 : sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
2061 $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>";
2062
2063 $all_plugins_count = count( $all_items );
2064 $all_text = __( 'All', 'woocommerce' );
2065 $all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
2066
2067 return array(
2068 'all' => $all_link,
2069 'incompatible_with_feature' => $incompatible_link,
2070 );
2071 }
2072
2073 /**
2074 * Set the feature nonce to be sent from client side.
2075 *
2076 * @param array $settings Component settings.
2077 *
2078 * @return array
2079 *
2080 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2081 */
2082 public function set_change_feature_enable_nonce( $settings ) {
2083 $settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
2084 return $settings;
2085 }
2086
2087 /**
2088 * Changes the feature given it's id, a toggle value and nonce as a query param.
2089 *
2090 * `/wp-admin/post.php?feature_id=1&_feature_nonce=1234`, 1 for on
2091 * `/wp-admin/post.php?feature_id=0&_feature_nonce=1234`, 0 for off
2092 *
2093 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2094 */
2095 public function change_feature_enable_from_query_params(): void {
2096 if ( ! current_user_can( 'manage_woocommerce' ) ) {
2097 return;
2098 }
2099
2100 $is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );
2101
2102 $query_params_to_remove = array( '_feature_nonce' );
2103
2104 foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
2105 if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
2106 $value = absint( $_GET[ $feature_id ] );
2107
2108 if ( $is_feature_nonce_invalid ) {
2109 wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
2110 return;
2111 }
2112
2113 if ( 1 === $value ) {
2114 $this->change_feature_enable( $feature_id, true );
2115 } elseif ( 0 === $value ) {
2116 $this->change_feature_enable( $feature_id, false );
2117 }
2118 $query_params_to_remove[] = $feature_id;
2119 }
2120 }
2121 if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
2122 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2123 wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
2124 }
2125 }
2126
2127 /**
2128 * Display the email improvements feedback notice to render CES modal in.
2129 *
2130 * @param string $feature_id The feature id.
2131 * @param bool $is_enabled Whether the feature is enabled.
2132 *
2133 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2134 */
2135 public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void {
2136 if ( 'email_improvements' === $feature_id && ! $is_enabled ) {
2137 set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 );
2138 add_action(
2139 'admin_notices',
2140 function () {
2141 echo '<div id="wc_settings_features_email_feedback_slotfill"></div>';
2142 }
2143 );
2144 }
2145 }
2146
2147 /**
2148 * Flag a one-shot transient when the merchant turns on Abandoned cart recovery.
2149 *
2150 * `change_feature_enable` fires this action mid-request before the post-save
2151 * redirect, so the actual notice has to render on the next page load. We
2152 * stash a transient here and `maybe_render_abandoned_cart_recovery_enabled_notice`
2153 * picks it up the next time `woocommerce_settings_advanced` fires.
2154 *
2155 * @param string $feature_id Feature being toggled.
2156 * @param bool $is_enabled True when turned on, false when turned off.
2157 *
2158 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2159 */
2160 public function flag_abandoned_cart_recovery_enabled_notice( $feature_id, $is_enabled ): void {
2161 if ( 'abandoned_cart_recovery' === $feature_id && $is_enabled ) {
2162 set_transient( 'wc_abandoned_cart_recovery_enabled_notice', 'yes', MINUTE_IN_SECONDS );
2163 }
2164 }
2165
2166 /**
2167 * Render a success notice after the merchant enables Abandoned cart recovery,
2168 * pointing them straight at the email settings page where they actually
2169 * configure it.
2170 *
2171 * Hooks into `woocommerce_settings_advanced` at priority 1, which fires
2172 * inside the settings template right after `WC_Admin_Settings::show_messages()`
2173 * (the "Your settings have been saved." notice) and before the form fields.
2174 * That places our notice in the same visual slot below the tabs, alongside
2175 * the standard save confirmation.
2176 *
2177 * `WC_Admin_Settings::add_message()` would be the cleaner API but escapes
2178 * its input via `esc_html()`, which strips the link tag. Direct echo here
2179 * keeps the markup intact while still matching the surrounding notice style.
2180 *
2181 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
2182 */
2183 public function maybe_render_abandoned_cart_recovery_enabled_notice(): void {
2184 if ( 'yes' !== get_transient( 'wc_abandoned_cart_recovery_enabled_notice' ) ) {
2185 return;
2186 }
2187 delete_transient( 'wc_abandoned_cart_recovery_enabled_notice' );
2188
2189 $settings_url = admin_url( 'admin.php?page=wc-settings&tab=email&section=wc_email_customer_abandoned_cart_recovery' );
2190
2191 printf(
2192 '<div id="wc-abandoned-cart-recovery-enabled-notice" class="updated inline"><p><strong>%1$s <a href="%2$s">%3$s</a></strong></p></div>',
2193 esc_html__( 'Abandoned cart recovery is enabled.', 'woocommerce' ),
2194 esc_url( $settings_url ),
2195 esc_html__( 'Configure the recovery email →', 'woocommerce' )
2196 );
2197 }
2198
2199 /**
2200 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
2201 * This is used to force the email improvements feature without affecting shoppers.
2202 *
2203 * @param string $feature_id The feature id.
2204 * @return bool Whether the email improvements feature is enabled in preview mode.
2205 */
2206 private function is_preview_email_improvements_enabled( string $feature_id ): bool {
2207 if ( 'email_improvements' !== $feature_id ) {
2208 return false;
2209 }
2210 /**
2211 * This filter is documented in templates/emails/email-styles.php
2212 *
2213 * @since 9.9.0
2214 * @param bool $is_email_preview Whether the email is being previewed.
2215 */
2216 $is_email_preview = apply_filters( 'woocommerce_is_email_preview', false );
2217 if ( $is_email_preview ) {
2218 return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes';
2219 }
2220 return false;
2221 }
2222 }
2223