Σ(゚Д゚;≡;゚д゚)Σ(゚Д゚;≡;゚д゚)Σ(゚Д゚;≡;゚д゚)始,故人唐宰相鲁公,🆚开府南服,余以布衣从戎。明年,别公漳水湄。后明年,公以事过张睢阳庙及颜杲卿所尝往来处,悲歌慷慨,卒不负其言而从之游。今其诗具在,可考也。😭
Features/FeaturesController.php 0000644 00000116322 15233366071 0012670 0 ustar 00 <?php
/**
* FeaturesController class file
*/
namespace Automattic\WooCommerce\Internal\Features;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
defined( 'ABSPATH' ) || exit;
/**
* Class to define the WooCommerce features that can be enabled and disabled by admin users,
* provides also a mechanism for WooCommerce plugins to declare that they are compatible
* (or incompatible) with a given feature.
*/
class FeaturesController {
use AccessiblePrivateMethods;
public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
/**
* The existing feature definitions.
*
* @var array[]
*/
private $features;
/**
* The registered compatibility info for WooCommerce plugins, with plugin names as keys.
*
* @var array
*/
private $compatibility_info_by_plugin;
/**
* Ids of the legacy features (they existed before the features engine was implemented).
*
* @var array
*/
private $legacy_feature_ids;
/**
* The registered compatibility info for WooCommerce plugins, with feature ids as keys.
*
* @var array
*/
private $compatibility_info_by_feature;
/**
* The LegacyProxy instance to use.
*
* @var LegacyProxy
*/
private $proxy;
/**
* The PluginUtil instance to use.
*
* @var PluginUtil
*/
private $plugin_util;
/**
* Flag indicating that features will be enableable from the settings page
* even when they are incompatible with active plugins.
*
* @var bool
*/
private $force_allow_enabling_features = false;
/**
* Flag indicating that plugins will be activable from the plugins page
* even when they are incompatible with enabled features.
*
* @var bool
*/
private $force_allow_enabling_plugins = false;
/**
* Creates a new instance of the class.
*/
public function __construct() {
$features = array(
'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enables WooCommerce Analytics', 'woocommerce' ),
'is_experimental' => false,
'enabled_by_default' => true,
),
'new_navigation' => array(
'name' => __( 'Navigation', 'woocommerce' ),
'description' => __( 'Adds the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
'is_experimental' => false,
),
'custom_order_tables' => array(
'name' => __( 'High-Performance order storage (COT)', 'woocommerce' ),
'description' => __( 'Enable the high performance order storage feature.', 'woocommerce' ),
'is_experimental' => true,
),
);
$this->legacy_feature_ids = array( 'analytics', 'new_navigation' );
$this->init_features( $features );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
self::add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
self::add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
self::add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
self::add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
self::add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
self::add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
}
/**
* Initialize the class according to the existing features.
*
* @param array $features Information about the existing features.
*/
private function init_features( array $features ) {
$this->compatibility_info_by_plugin = array();
$this->compatibility_info_by_feature = array();
$this->features = $features;
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
);
}
}
/**
* Initialize the class instance.
*
* @internal
*
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
* @param PluginUtil $plugin_util The instance of PluginUtil to use.
*/
final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
$this->proxy = $proxy;
$this->plugin_util = $plugin_util;
}
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name (string)
* - description (string)
* - is_experimental (bool)
* - is_enabled (bool) (only if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
$features = $this->features;
if ( ! $include_experimental ) {
$features = array_filter(
$features,
function( $feature ) {
return ! $feature['is_experimental'];
}
);
}
if ( $include_enabled_info ) {
foreach ( array_keys( $features ) as $feature_id ) {
$is_enabled = $this->feature_is_enabled( $feature_id );
$features[ $feature_id ]['is_enabled'] = $is_enabled;
}
}
return $features;
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public function feature_is_enabled( string $feature_id ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
$default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
return 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
}
/**
* Check if a given feature is enabled by default.
*
* @param string $feature_id Unique feature id.
* @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
*/
private function feature_is_enabled_by_default( string $feature_id ): bool {
return ! empty( $this->features[ $feature_id ]['enabled_by_default'] );
}
/**
* Change the enabled/disabled status of a feature.
*
* @param string $feature_id Unique feature id.
* @param bool $enable True to enable the feature, false to disable it.
* @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
*/
public function change_feature_enable( string $feature_id, bool $enable ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no' );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
*
* The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
* of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
* FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
* @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
*/
public function declare_compatibility( string $feature_id, string $plugin_name, bool $positive_compatibility = true ): bool {
if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
/* translators: 1: class::method 2: before_woocommerce_init */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' );
return false;
}
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
$plugin_name = str_replace( '\\', '/', $plugin_name );
// Register compatibility by plugin.
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_name );
$key = $positive_compatibility ? 'compatible' : 'incompatible';
$opposite_key = $positive_compatibility ? 'incompatible' : 'compatible';
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $key );
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $opposite_key );
if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $opposite_key ], true ) ) {
throw new \Exception( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" );
}
if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $key ], true ) ) {
$this->compatibility_info_by_plugin[ $plugin_name ][ $key ][] = $feature_id;
}
// Register compatibility by feature.
$key = $positive_compatibility ? 'compatible' : 'incompatible';
if ( ! in_array( $plugin_name, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
$this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_name;
}
return true;
}
/**
* Check whether a feature exists with a given id.
*
* @param string $feature_id The feature id to check.
* @return bool True if the feature exists.
*/
private function feature_exists( string $feature_id ): bool {
return isset( $this->features[ $feature_id ] );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $enabled_features_only True to return only names of enabled plugins.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
*/
public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
$features = $this->features;
if ( $enabled_features_only ) {
$features = array_filter(
$features,
array( $this, 'feature_is_enabled' ),
ARRAY_FILTER_USE_KEY
);
}
if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
'uncertain' => array_keys( $features ),
);
}
$info = $this->compatibility_info_by_plugin[ $plugin_name ];
$info['compatible'] = array_values( array_intersect( array_keys( $features ), $info['compatible'] ) );
$info['incompatible'] = array_values( array_intersect( array_keys( $features ), $info['incompatible'] ) );
$info['uncertain'] = array_values( array_diff( array_keys( $features ), $info['compatible'], $info['incompatible'] ) );
return $info;
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @param bool $active_only True to return only active plugins.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
$woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
if ( ! $this->feature_exists( $feature_id ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
'uncertain' => $woo_aware_plugins,
);
}
$info = $this->compatibility_info_by_feature[ $feature_id ];
$info['uncertain'] = array_values( array_diff( $woo_aware_plugins, $info['compatible'], $info['incompatible'] ) );
return $info;
}
/**
* Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
*
* @param string|null $function Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
* @return bool True if 'woocommerce_init' has run or is running, false otherwise.
*/
private function verify_did_woocommerce_init( string $function = null ): bool {
if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
if ( ! is_null( $function ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function;
/* translators: 1: class::method 2: plugins_loaded */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
}
return false;
}
return true;
}
/**
* Get the name of the option that enables/disables a given feature.
* Note that it doesn't check if the feature actually exists.
*
* @param string $feature_id The id of the feature.
* @return string The option that enables or disables the feature.
*/
public function feature_enable_option_name( string $feature_id ): string {
if ( 'analytics' === $feature_id ) {
return Analytics::TOGGLE_OPTION_NAME;
} elseif ( 'new_navigation' === $feature_id ) {
return Init::TOGGLE_OPTION_NAME;
}
return "woocommerce_feature_${feature_id}_enabled";
}
/**
* Checks whether a feature id corresponds to a legacy feature
* (a feature that existed prior to the implementation of the features engine).
*
* @param string $feature_id The feature id to check.
* @return bool True if the id corresponds to a legacy feature.
*/
public function is_legacy_feature( string $feature_id ): bool {
return in_array( $feature_id, $this->legacy_feature_ids, true );
}
/**
* Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
* from the WooCommerce feature settings page.
*/
public function allow_enabling_features_with_incompatible_plugins(): void {
$this->force_allow_enabling_features = true;
}
/**
* Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
* from the WordPress plugins page.
*/
public function allow_activating_plugins_with_incompatible_features(): void {
$this->force_allow_enabling_plugins = true;
}
/**
* Handler for the 'added_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been created.
* @param mixed $value The value of the option.
*/
private function process_added_option( string $option, $value ) {
$this->process_updated_option( $option, false, $value );
}
/**
* Handler for the 'updated_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been modified.
* @param mixed $old_value The old value of the option.
* @param mixed $value The new value of the option.
*/
private function process_updated_option( string $option, $old_value, $value ) {
$matches = array();
$success = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
if ( ! $success && Analytics::TOGGLE_OPTION_NAME !== $option && Init::TOGGLE_OPTION_NAME !== $option ) {
return;
}
if ( $value === $old_value ) {
return;
}
if ( Analytics::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'analytics';
} elseif ( Init::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'new_navigation';
} else {
$feature_id = $matches[1];
}
/**
* Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
*
* @param string $feature_id The id of the feature.
* @param bool $enabled True if the feature has been enabled, false if it has been disabled.
*
* @since 7.0.0
*/
do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
}
/**
* Handler for the 'woocommerce_get_sections_advanced' hook,
* it adds the "Features" section to the advanced settings page.
*
* @param array $sections The original sections array.
* @return array The updated sections array.
*/
private function add_features_section( $sections ) {
if ( ! isset( $sections['features'] ) ) {
$sections['features'] = __( 'Features', 'woocommerce' );
}
return $sections;
}
/**
* Handler for the 'woocommerce_get_settings_advanced' hook,
* it adds the settings UI for all the existing features.
*
* Note that the settings added via the 'woocommerce_settings_features' hook will be
* displayed in the non-experimental features section.
*
* @param array $settings The existing settings for the corresponding settings section.
* @param string $current_section The section to get the settings for.
* @return array The updated settings array.
*/
private function add_feature_settings( $settings, $current_section ): array {
if ( 'features' !== $current_section ) {
return $settings;
}
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing WooCommerce Admin to be disabled.
*
* @param bool $disabled False.
*/
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings =
array(
array(
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
),
);
$features = $this->get_features( true );
$feature_ids = array_keys( $features );
$experimental_feature_ids = array_filter(
$feature_ids,
function( $feature_id ) use ( $features ) {
return $features[ $feature_id ]['is_experimental'];
}
);
$mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
$feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
foreach ( $feature_ids as $id ) {
if ( 'mature_features_end' === $id ) {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
*
* @param bool $disabled False.
*/
$additional_features = apply_filters( 'woocommerce_settings_features', $features );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings = array_merge( $feature_settings, $additional_features );
if ( ! empty( $experimental_feature_ids ) ) {
$feature_settings[] = array(
'type' => 'sectionend',
'id' => 'features_options',
);
$feature_settings[] = array(
'title' => __( 'Experimental features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
'id' => 'experimental_features_options',
);
}
continue;
}
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ], $admin_features_disabled );
}
$feature_settings[] = array(
'type' => 'sectionend',
'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
);
return $feature_settings;
}
/**
* Get the parameters to display the setting enable/disable UI for a given feature.
*
* @param string $feature_id The feature id.
* @param array $feature The feature parameters, as returned by get_features.
* @param bool $admin_features_disabled True if admin features have been disabled via 'woocommerce_admin_disabled' filter.
* @return array The parameters to add to the settings array.
*/
private function get_setting_for_feature( string $feature_id, array $feature, bool $admin_features_disabled ): array {
$description = $feature['description'];
$disabled = false;
$desc_tip = '';
if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
$disabled = true;
$desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
} elseif ( 'new_navigation' === $feature_id ) {
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
$update_text = sprintf(
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<br/>',
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
'</a>'
);
$description .= $update_text;
$disabled = true;
}
}
if ( ! $this->is_legacy_feature( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
$plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
$incompatibles = array_merge( $plugin_info_for_feature['incompatible'], $plugin_info_for_feature['uncertain'] );
$incompatibles = array_filter( $incompatibles, 'is_plugin_active' );
$incompatible_count = count( $incompatibles );
if ( $incompatible_count > 0 ) {
if ( 1 === $incompatible_count ) {
/* translators: %s = printable plugin name */
$desc_tip = sprintf( __( "⚠ This feature shouldn't be enabled, the %s plugin is active and isn't compatible with it.", 'woocommerce' ), $this->plugin_util->get_plugin_name( $incompatibles[0] ) );
} elseif ( 2 === $incompatible_count ) {
/* translators: %1\$s, %2\$s = printable plugin names */
$desc_tip = sprintf(
__( "⚠ This feature shouldn't be enabled: the %1\$s and %2\$s plugins are active and aren't compatible with it.", 'woocommerce' ),
$this->plugin_util->get_plugin_name( $incompatibles[0] ),
$this->plugin_util->get_plugin_name( $incompatibles[1] )
);
} else {
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
$desc_tip = sprintf(
_n(
"⚠ This feature shouldn't be enabled: %1\$s, %2\$s and %3\$d more active plugin isn't compatible with it",
"⚠ This feature shouldn't be enabled: the %1\$s and %2\$s plugins are active and aren't compatible with it. There are %3\$d other incompatible plugins.",
$incompatible_count - 2,
'woocommerce'
),
$this->plugin_util->get_plugin_name( $incompatibles[0] ),
$this->plugin_util->get_plugin_name( $incompatibles[1] ),
$incompatible_count - 2
);
}
$incompatible_plugins_url = add_query_arg(
array(
'plugin_status' => 'incompatible_with_feature',
'feature_id' => $feature_id,
),
admin_url( 'plugins.php' )
);
/* translators: %s = URL of the plugins page */
$extra_desc_tip = sprintf( __( " <a href='%s'>Manage incompatible plugins</a>", 'woocommerce' ), $incompatible_plugins_url );
$desc_tip .= $extra_desc_tip;
$disabled = ! $this->feature_is_enabled( $feature_id );
}
}
/**
* Filter to customize the description tip that appears under the description of each feature in the features settings page.
*
* @since 7.1.0
*
* @param string $desc_tip The original description tip.
* @param string $feature_id The id of the feature for which the description tip is being customized.
* @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
* @return string The new description tip to use.
*/
$desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
return array(
'title' => $feature['name'],
'desc' => $description,
'type' => 'checkbox',
'id' => $this->feature_enable_option_name( $feature_id ),
'disabled' => $disabled && ! $this->force_allow_enabling_features,
'desc_tip' => $desc_tip,
'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
);
}
/**
* Handle the plugin deactivation hook.
*
* @param string $plugin_name Name of the plugin that has been deactivated.
*/
private function handle_plugin_deactivation( $plugin_name ): void {
unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
$compatibles = $this->compatibility_info_by_feature[ $feature ]['compatible'];
$this->compatibility_info_by_feature[ $feature ]['compatible'] = array_diff( $compatibles, array( $plugin_name ) );
$incompatibles = $this->compatibility_info_by_feature[ $feature ]['incompatible'];
$this->compatibility_info_by_feature[ $feature ]['incompatible'] = array_diff( $incompatibles, array( $plugin_name ) );
}
}
/**
* Handler for the all_plugins filter.
*
* Returns the list of plugins incompatible with a given plugin
* if we are in the plugins page and the query string of the current request
* looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
*
* @param array $list The original list of plugins.
*/
private function filter_plugins_list( $list ): array {
if ( ! $this->verify_did_woocommerce_init() ) {
return $list;
}
// phpcs:disable WordPress.Security.NonceVerification
if ( ! function_exists( 'get_current_screen' ) || get_current_screen() && 'plugins' !== get_current_screen()->id || 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
return $list;
}
$feature_id = ArrayUtil::get_value_or_default( $_GET, 'feature_id', 'all' );
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return $list;
}
$incompatibles = array();
// phpcs:enable WordPress.Security.NonceVerification
foreach ( array_keys( $list ) as $plugin_name ) {
if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
continue;
}
$compatibility = $this->get_compatible_features_for_plugin( $plugin_name );
$incompatible_with = array_diff(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
);
if ( ( 'all' === $feature_id && ! empty( $incompatible_with ) ) || in_array( $feature_id, $incompatible_with, true ) ) {
$incompatibles[] = $plugin_name;
}
}
return array_intersect_key( $list, array_flip( $incompatibles ) );
}
/**
* Handler for the admin_notices action.
*/
private function display_notices_in_plugins_page(): void {
if ( ! $this->verify_did_woocommerce_init() ) {
return;
}
$feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
if ( ! $feature_filter_description_shown ) {
$this->maybe_display_feature_incompatibility_warning();
}
}
/**
* Shows a warning when there are any incompatibility between active plugins and enabled features.
* The warning is shown in on any admin screen except the plugins screen itself, since
* there's already a "You are viewing
*/
private function maybe_display_feature_incompatibility_warning(): void {
$incompatible_plugins = false;
foreach ( $this->plugin_util->get_woocommerce_aware_plugins( true ) as $plugin ) {
$compatibility = $this->get_compatible_features_for_plugin( $plugin, true );
$incompatible_with = array_diff(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
);
if ( $incompatible_with ) {
$incompatible_plugins = true;
break;
}
}
if ( ! $incompatible_plugins ) {
return;
}
$message = str_replace(
'<a>',
'<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
__( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
);
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<div class="notice notice-error">
<p><?php echo $message; ?></p>
</div>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Shows a "You are viewing the plugins that are incompatible with the X feature"
* if we are in the plugins page and the query string of the current request
* looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
*/
private function maybe_display_current_feature_filter_description(): bool {
if ( 'plugins' !== get_current_screen()->id ) {
return false;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
$plugin_status = $_GET['plugin_status'] ?? '';
$feature_id = $_GET['feature_id'] ?? '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
if ( 'incompatible_with_feature' !== $plugin_status ) {
return false;
}
$feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return false;
}
// phpcs:enable WordPress.Security.NonceVerification
$plugins_page_url = admin_url( 'plugins.php' );
$features_page_url = $this->get_features_page_url();
$message =
'all' === $feature_id
? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
: sprintf(
/* translators: %s is a feature name. */
__( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
$this->features[ $feature_id ]['name']
);
$message .= '<br />';
$message .= sprintf(
__( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
$plugins_page_url,
$features_page_url
);
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<div class="notice notice-info">
<p><?php echo $message; ?></p>
</div>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
return true;
}
/**
* Handler for the 'after_plugin_row' action.
* Displays a "This plugin is incompatible with X features" notice if necessary.
*
* @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
* @param array $plugin_data Plugin data, as returned by 'get_plugins'.
*/
private function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
global $wp_list_table;
if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
return;
}
if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
return;
}
if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
return;
}
$feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true );
$incompatible_features = array_merge( $feature_compatibility_info['incompatible'], $feature_compatibility_info['uncertain'] );
$incompatible_features = array_values(
array_filter(
$incompatible_features,
function( $feature_id ) {
return ! $this->is_legacy_feature( $feature_id );
}
)
);
$incompatible_features_count = count( $incompatible_features );
if ( $incompatible_features_count > 0 ) {
$columns_count = $wp_list_table->get_column_count();
$is_active = true; // For now we are showing active plugins in the "Incompatible with..." view.
$is_active_class = $is_active ? 'active' : 'inactive';
$is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
if ( 1 === $incompatible_features_count ) {
$message = sprintf(
/* translators: %s = printable plugin name */
__( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name']
);
} elseif ( 2 === $incompatible_features_count ) {
/* translators: %1\$s, %2\$s = printable plugin names */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name']
);
} else {
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name'],
$incompatible_features_count - 2
);
}
$features_page_url = $this->get_features_page_url();
$manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
<td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
<div class='notice inline notice-warning notice-alt'>
<p>
<?php echo $message; ?>
<a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
</p>
</div>
</td>
</tr>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* Get the URL of the features settings page.
*
* @return string
*/
private function get_features_page_url(): string {
return admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' );
}
/**
* Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
*
* WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
*
* - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
* border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
* it will have a "plugin-update-tr" class which will draw the missing lower border line.
* - Otherwise, the <tr> will be already drawn with the lower border line.
*
* This is a problem for our rendering of the "plugin is incompatible with X features" warning:
*
* - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
* our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
* - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
*
* This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
* (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
* (and then some extra manual tweaking of margins is needed).
*
* @param string $current_screen The current screen object.
*/
private function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
if ( 'plugins' !== $current_screen->id ) {
return;
}
wc_enqueue_js(
"
const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
for(const warningRow of warningRows) {
const pluginName = warningRow.getAttribute('data-plugin');
const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
if(pluginInfoRow.classList.contains('update')) {
warningRow.classList.remove('plugin-update-tr');
warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
}
else {
pluginInfoRow.classList.add('update');
}
}
"
);
}
/**
* Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
* If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
* "All" (so that it's easy to go back to a known state) and "Incompatible with X".
* We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
* the plugins list via the 'all_plugins' filter.
*
* @param array $views An array of view ids => view links.
* @return string[] The actual views array to use.
*/
private function handle_plugins_page_views_list( $views ): array {
// phpcs:disable WordPress.Security.NonceVerification
if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
return $views;
}
$feature_id = ArrayUtil::get_value_or_default( $_GET, 'feature_id', 'all' );
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return $views;
}
// phpcs:enable WordPress.Security.NonceVerification
$all_items = get_plugins();
$incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
$incompatible_text =
'all' === $feature_id
? __( 'Incompatible with WooCommerce features', 'woocommerce' )
/* translators: %s = name of a WooCommerce feature */
: sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $this->features[ $feature_id ]['name'] );
$incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>";
$all_plugins_count = count( $all_items );
$all_text = __( 'All', 'woocommerce' );
$all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
return array(
'all' => $all_link,
'incompatible_with_feature' => $incompatible_link,
);
}
}
Admin/Coupons.php 0000644 00000006153 15233366071 0007746 0 ustar 00 <?php
/**
* WooCommerce Marketing > Coupons.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Notes\CouponPageMoved;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Coupons feature.
*/
class Coupons {
use CouponsMovedTrait;
/**
* Class instance.
*
* @var Coupons instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
// If the main marketing feature is disabled, don't modify coupon behavior.
if ( ! Features::is_enabled( 'marketing' ) ) {
return;
}
// Only support coupon modifications if coupons are enabled.
if ( ! wc_coupons_enabled() ) {
return;
}
( new CouponPageMoved() )->init();
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_add_marketing_coupon_script' ) );
add_action( 'woocommerce_register_post_type_shop_coupon', array( $this, 'move_coupons' ) );
add_action( 'admin_head', array( $this, 'fix_coupon_menu_highlight' ), 99 );
add_action( 'admin_menu', array( $this, 'maybe_add_coupon_menu_redirect' ) );
}
/**
* Maybe add menu item back in original spot to help people transition
*/
public function maybe_add_coupon_menu_redirect() {
if ( ! $this->should_display_legacy_menu() ) {
return;
}
add_submenu_page(
'woocommerce',
__( 'Coupons', 'woocommerce' ),
__( 'Coupons', 'woocommerce' ),
'manage_options',
'coupons-moved',
[ $this, 'coupon_menu_moved' ]
);
}
/**
* Call back for transition menu item
*/
public function coupon_menu_moved() {
wp_safe_redirect( $this->get_legacy_coupon_url(), 301 );
exit();
}
/**
* Modify registered post type shop_coupon
*
* @param array $args Array of post type parameters.
*
* @return array the filtered parameters.
*/
public function move_coupons( $args ) {
$args['show_in_menu'] = current_user_can( 'manage_woocommerce' ) ? 'woocommerce-marketing' : true;
return $args;
}
/**
* Undo WC modifications to $parent_file for 'shop_coupon'
*/
public function fix_coupon_menu_highlight() {
global $parent_file, $post_type;
if ( $post_type === 'shop_coupon' ) {
$parent_file = 'woocommerce-marketing'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
}
}
/**
* Maybe add our wc-admin coupon scripts if viewing coupon pages
*/
public function maybe_add_marketing_coupon_script() {
$curent_screen = PageController::get_instance()->get_current_page();
if ( ! isset( $curent_screen['id'] ) || $curent_screen['id'] !== 'woocommerce-coupons' ) {
return;
}
$rtl = is_rtl() ? '-rtl' : '';
wp_enqueue_style(
'wc-admin-marketing-coupons',
WCAdminAssets::get_url( "marketing-coupons/style{$rtl}", 'css' ),
array(),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'marketing-coupons', true );
}
}
Admin/SystemStatusReport.php 0000644 00000013537 15233366071 0012210 0 ustar 00 <?php
/**
* Add additional system status report sections.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Notes\Notes;
defined( 'ABSPATH' ) || exit;
/**
* SystemStatusReport class.
*/
class SystemStatusReport {
/**
* Class instance.
*
* @var SystemStatus instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'woocommerce_system_status_report', array( $this, 'system_status_report' ) );
}
/**
* Hooks extra necessary sections into the system status report template
*/
public function system_status_report() {
?>
<table class="wc_status_table widefat" cellspacing="0">
<thead>
<tr>
<th colspan="5" data-export-label="Admin">
<h2>
<?php esc_html_e( 'Admin', 'woocommerce' ); ?><?php echo wc_help_tip( esc_html__( 'This section shows details of WC Admin.', 'woocommerce' ) ); ?>
</h2>
</th>
</tr>
</thead>
<tbody>
<?php
$this->render_features();
$this->render_daily_cron();
$this->render_options();
$this->render_notes();
$this->render_onboarding_state();
?>
</tbody>
</table>
<?php
}
/**
* Render features rows.
*/
public function render_features() {
/**
* Filter the admin feature configs.
*
* @since 6.5.0
*/
$features = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
$enabled_features = array_filter( $features );
$disabled_features = array_filter(
$features,
function( $feature ) {
return empty( $feature );
}
);
?>
<tr>
<td data-export-label="Enabled Features">
<?php esc_html_e( 'Enabled Features', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are enabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( implode( ', ', array_keys( $enabled_features ) ) )
?>
</td>
</tr>
<tr>
<td data-export-label="Disabled Features">
<?php esc_html_e( 'Disabled Features', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are disabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( implode( ', ', array_keys( $disabled_features ) ) )
?>
</td>
</tr>
<?php
}
/**
* Render daily cron row.
*/
public function render_daily_cron() {
$next_daily_cron = wp_next_scheduled( 'wc_admin_daily' );
?>
<tr>
<td data-export-label="Daily Cron">
<?php esc_html_e( 'Daily Cron', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Is the daily cron job active, when does it next run?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
if ( empty( $next_daily_cron ) ) {
echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not scheduled', 'woocommerce' ) . '</mark>';
} else {
echo '<mark class="yes"><span class="dashicons dashicons-yes"></span> Next scheduled: ' . esc_html( date_i18n( 'Y-m-d H:i:s P', $next_daily_cron ) ) . '</mark>';
}
?>
</td>
</tr>
<?php
}
/**
* Render option row.
*/
public function render_options() {
$woocommerce_admin_install_timestamp = get_option( 'woocommerce_admin_install_timestamp' );
$all_options_expected = is_numeric( $woocommerce_admin_install_timestamp )
&& 0 < (int) $woocommerce_admin_install_timestamp
&& is_array( get_option( 'woocommerce_onboarding_profile', array() ) );
?>
<tr>
<td data-export-label="Options">
<?php esc_html_e( 'Options', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Do the important options return expected values?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
if ( $all_options_expected ) {
echo '<mark class="yes"><span class="dashicons dashicons-yes"></mark>';
} else {
echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not all expected', 'woocommerce' ) . '</mark>';
}
?>
</td>
</tr>
<?php
}
/**
* Render the notes row.
*/
public function render_notes() {
$notes_count = Notes::get_notes_count();
?>
<tr>
<td data-export-label="Notes">
<?php esc_html_e( 'Notes', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'How many notes in the database?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( $notes_count )
?>
</td>
</tr>
<?php
}
/**
* Render the onboarding state row.
*/
public function render_onboarding_state() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
$onboarding_state = '-';
if ( isset( $onboarding_profile['skipped'] ) && $onboarding_profile['skipped'] ) {
$onboarding_state = 'skipped';
}
if ( isset( $onboarding_profile['completed'] ) && $onboarding_profile['completed'] ) {
$onboarding_state = 'completed';
}
?>
<tr>
<td data-export-label="Onboarding">
<?php esc_html_e( 'Onboarding', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Was onboarding completed or skipped?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( $onboarding_state )
?>
</td>
</tr>
<?php
}
}
Admin/CustomerEffortScoreTracks.php 0000644 00000036140 15233366071 0013432 0 ustar 00 <?php
/**
* WooCommerce Customer effort score tracks
*
* @package WooCommerce\Admin\Features
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Triggers customer effort score on several different actions.
*/
class CustomerEffortScoreTracks {
/**
* Option name for the CES Tracks queue.
*/
const CES_TRACKS_QUEUE_OPTION_NAME = 'woocommerce_ces_tracks_queue';
/**
* Option name for the clear CES Tracks queue for page.
*/
const CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME =
'woocommerce_clear_ces_tracks_queue_for_page';
/**
* Option name for the set of actions that have been shown.
*/
const SHOWN_FOR_ACTIONS_OPTION_NAME = 'woocommerce_ces_shown_for_actions';
/**
* Action name for product add/publish.
*/
const PRODUCT_ADD_PUBLISH_ACTION_NAME = 'product_add_publish';
/**
* Action name for product update.
*/
const PRODUCT_UPDATE_ACTION_NAME = 'product_update';
/**
* Action name for shop order update.
*/
const SHOP_ORDER_UPDATE_ACTION_NAME = 'shop_order_update';
/**
* Action name for settings change.
*/
const SETTINGS_CHANGE_ACTION_NAME = 'settings_change';
/**
* Action name for add product categories.
*/
const ADD_PRODUCT_CATEGORIES_ACTION_NAME = 'add_product_categories';
/**
* Action name for add product tags.
*/
const ADD_PRODUCT_TAGS_ACTION_NAME = 'add_product_tags';
/*
* Action name for add product attributes.
*/
const ADD_PRODUCT_ATTRIBUTES_ACTION_NAME = 'add_product_attributes';
/**
* Action name for import products.
*/
const IMPORT_PRODUCTS_ACTION_NAME = 'import_products';
/**
* Action name for search.
*/
const SEARCH_ACTION_NAME = 'ces_search';
/**
* Label for the snackbar that appears when a user submits the survey.
*
* @var string
*/
private $onsubmit_label;
/**
* Constructor. Sets up filters to hook into WooCommerce.
*/
public function __construct() {
$this->enable_survey_enqueing_if_tracking_is_enabled();
}
/**
* Add actions that require woocommerce_allow_tracking.
*/
private function enable_survey_enqueing_if_tracking_is_enabled() {
// Only hook up the action handlers if in wp-admin.
if ( ! is_admin() ) {
return;
}
// Do not hook up the action handlers if a mobile device is used.
if ( wp_is_mobile() ) {
return;
}
// Only enqueue a survey if tracking is allowed.
$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking', 'no' );
if ( ! $allow_tracking ) {
return;
}
add_action( 'admin_init', array( $this, 'maybe_clear_ces_tracks_queue' ) );
add_action( 'woocommerce_update_options', array( $this, 'run_on_update_options' ), 10, 3 );
add_action( 'product_cat_add_form', array( $this, 'add_script_track_product_categories' ), 10, 3 );
add_action( 'product_tag_add_form', array( $this, 'add_script_track_product_tags' ), 10, 3 );
add_action( 'woocommerce_attribute_added', array( $this, 'run_on_add_product_attributes' ), 10, 3 );
add_action( 'load-edit.php', array( $this, 'run_on_load_edit_php' ), 10, 3 );
add_action( 'product_page_product_importer', array( $this, 'run_on_product_import' ), 10, 3 );
// Only hook up the transition_post_status action handler
// if on the edit page.
global $pagenow;
if ( 'post.php' === $pagenow ) {
add_action(
'transition_post_status',
array(
$this,
'run_on_transition_post_status',
),
10,
3
);
}
$this->onsubmit_label = __( 'Thank you for your feedback!', 'woocommerce' );
}
/**
* Returns a generated script for tracking tags added on edit-tags.php page.
* CES survey is triggered via direct access to wc/customer-effort-score store
* via wp.data.dispatch method.
*
* Due to lack of options to directly hook ourselves into the ajax post request
* initiated by edit-tags.php page, we infer a successful request by observing
* an increase of the number of rows in tags table
*
* @param string $action Action name for the survey.
* @param string $label Label for the snackbar.
*
* @return string Generated JavaScript to append to page.
*/
private function get_script_track_edit_php( $action, $label ) {
return sprintf(
"(function( $ ) {
'use strict';
// Hook on submit button and sets a 500ms interval function
// to determine successful add tag or otherwise.
$('#addtag #submit').on( 'click', function() {
const initialCount = $('.tags tbody > tr').length;
const interval = setInterval( function() {
if ( $('.tags tbody > tr').length > initialCount ) {
// New tag detected.
clearInterval( interval );
wp.data.dispatch('wc/customer-effort-score').addCesSurvey( '%s', '%s', window.pagenow, window.adminpage, '%s' );
} else {
// Form is no longer loading, most likely failed.
if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {
clearInterval( interval );
}
}
}, 500 );
});
})( jQuery );",
esc_js( $action ),
esc_js( $label ),
esc_js( $this->onsubmit_label )
);
}
/**
* Get the current published product count.
*
* @return integer The current published product count.
*/
private function get_product_count() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$product_count = intval( $products->total );
return $product_count;
}
/**
* Get the current shop order count.
*
* @return integer The current shop order count.
*/
private function get_shop_order_count() {
$query = new \WC_Order_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
)
);
$shop_orders = $query->get_orders();
$shop_order_count = intval( $shop_orders->total );
return $shop_order_count;
}
/**
* Return whether the action has already been shown.
*
* @param string $action The action to check.
*
* @return bool Whether the action has already been shown.
*/
private function has_been_shown( $action ) {
$shown_for_features = get_option( self::SHOWN_FOR_ACTIONS_OPTION_NAME, array() );
$has_been_shown = in_array( $action, $shown_for_features, true );
return $has_been_shown;
}
/**
* Enqueue the item to the CES tracks queue.
*
* @param array $item The item to enqueue.
*/
private function enqueue_to_ces_tracks( $item ) {
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$has_duplicate = array_filter(
$queue,
function ( $queue_item ) use ( $item ) {
return $queue_item['action'] === $item['action'];
}
);
if ( $has_duplicate ) {
return;
}
$queue[] = $item;
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
$queue
);
}
/**
* Enqueue the CES survey on using search dynamically.
*
* @param string $search_area Search area such as "product" or "shop_order".
* @param string $page_now Value of window.pagenow.
* @param string $admin_page Value of window.adminpage.
*/
public function enqueue_ces_survey_for_search( $search_area, $page_now, $admin_page ) {
if ( $this->has_been_shown( self::SEARCH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SEARCH_ACTION_NAME,
'label' => __(
'How easy was it to use search?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => $page_now,
'adminpage' => $admin_page,
'props' => (object) array(
'search_area' => $search_area,
),
)
);
}
/**
* Hook into the post status lifecycle, to detect relevant user actions
* that we want to survey about.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
* @param Post $post The post.
*/
public function run_on_transition_post_status(
$new_status,
$old_status,
$post
) {
if ( 'product' === $post->post_type ) {
$this->maybe_enqueue_ces_survey_for_product( $new_status, $old_status );
} elseif ( 'shop_order' === $post->post_type ) {
$this->enqueue_ces_survey_for_edited_shop_order();
}
}
/**
* Maybe enqueue the CES survey, if product is being added or edited.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
*/
private function maybe_enqueue_ces_survey_for_product(
$new_status,
$old_status
) {
if ( 'publish' !== $new_status ) {
return;
}
if ( 'publish' !== $old_status ) {
$this->enqueue_ces_survey_for_new_product();
} else {
$this->enqueue_ces_survey_for_edited_product();
}
}
/**
* Enqueue the CES survey trigger for a new product.
*/
private function enqueue_ces_survey_for_new_product() {
if ( $this->has_been_shown( self::PRODUCT_ADD_PUBLISH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_ADD_PUBLISH_ACTION_NAME,
'label' => __(
'How easy was it to add a product?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing product.
*/
private function enqueue_ces_survey_for_edited_product() {
if ( $this->has_been_shown( self::PRODUCT_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_UPDATE_ACTION_NAME,
'label' => __(
'How easy was it to edit your product?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing shop order.
*/
private function enqueue_ces_survey_for_edited_shop_order() {
if ( $this->has_been_shown( self::SHOP_ORDER_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SHOP_ORDER_UPDATE_ACTION_NAME,
'label' => __(
'How easy was it to update an order?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'shop_order',
'adminpage' => 'post-php',
'props' => array(
'order_count' => $this->get_shop_order_count(),
),
)
);
}
/**
* Maybe clear the CES tracks queue, executed on every page load. If the
* clear option is set it clears the queue. In practice, this executes a
* page load after the queued CES tracks are displayed on the client, which
* sets the clear option.
*/
public function maybe_clear_ces_tracks_queue() {
$clear_ces_tracks_queue_for_page = get_option(
self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME,
false
);
if ( ! $clear_ces_tracks_queue_for_page ) {
return;
}
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$remaining_items = array_filter(
$queue,
function ( $item ) use ( $clear_ces_tracks_queue_for_page ) {
return $clear_ces_tracks_queue_for_page['pagenow'] !== $item['pagenow']
|| $clear_ces_tracks_queue_for_page['adminpage'] !== $item['adminpage'];
}
);
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array_values( $remaining_items )
);
update_option( self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME, false );
}
/**
* Appends a script to footer to trigger CES on adding product categories.
*/
public function add_script_track_product_categories() {
if ( $this->has_been_shown( self::ADD_PRODUCT_CATEGORIES_ACTION_NAME ) ) {
return;
}
wc_enqueue_js(
$this->get_script_track_edit_php(
self::ADD_PRODUCT_CATEGORIES_ACTION_NAME,
__( 'How easy was it to add product category?', 'woocommerce' )
)
);
}
/**
* Appends a script to footer to trigger CES on adding product tags.
*/
public function add_script_track_product_tags() {
if ( $this->has_been_shown( self::ADD_PRODUCT_TAGS_ACTION_NAME ) ) {
return;
}
wc_enqueue_js(
$this->get_script_track_edit_php(
self::ADD_PRODUCT_TAGS_ACTION_NAME,
__( 'How easy was it to add a product tag?', 'woocommerce' )
)
);
}
/**
* Maybe enqueue the CES survey on product import, if step is done.
*/
public function run_on_product_import() {
// We're only interested in when the importer completes.
if ( empty( $_GET['step'] ) || 'done' !== $_GET['step'] ) { // phpcs:ignore CSRF ok.
return;
}
if ( $this->has_been_shown( self::IMPORT_PRODUCTS_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::IMPORT_PRODUCTS_ACTION_NAME,
'label' => __(
'How easy was it to import products?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_importer',
'adminpage' => 'product_page_product_importer',
'props' => (object) array(),
)
);
}
/**
* Enqueue the CES survey trigger for setting changes.
*/
public function run_on_update_options() {
// $current_tab is set when WC_Admin_Settings::save_settings is called.
global $current_tab;
global $current_section;
if ( $this->has_been_shown( self::SETTINGS_CHANGE_ACTION_NAME ) ) {
return;
}
$props = array(
'settings_area' => $current_tab,
);
if ( $current_section ) {
$props['settings_section'] = $current_section;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SETTINGS_CHANGE_ACTION_NAME,
'label' => __(
'How easy was it to update your settings?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'woocommerce_page_wc-settings',
'adminpage' => 'woocommerce_page_wc-settings',
'props' => (object) $props,
)
);
}
/**
* Enqueue the CES survey on adding new product attributes.
*/
public function run_on_add_product_attributes() {
if ( $this->has_been_shown( self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME,
'label' => __(
'How easy was it to add a product attribute?',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_attributes',
'adminpage' => 'product_page_product_attributes',
'props' => (object) array(),
)
);
}
/**
* Determine on initiating CES survey on searching for product or orders.
*/
public function run_on_load_edit_php() {
$allowed_types = array( 'product', 'shop_order' );
$post_type = get_current_screen()->post_type;
// We're only interested for certain post types.
if ( ! in_array( $post_type, $allowed_types, true ) ) {
return;
}
// Determine whether request is search by "s" GET parameter.
if ( empty( $_GET['s'] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
return;
}
$page_now = 'edit-' . $post_type;
$this->enqueue_ces_survey_for_search( $post_type, $page_now, 'edit-php' );
}
}
Admin/ShippingLabelBannerDisplayRules.php 0000644 00000012266 15233366071 0014532 0 ustar 00 <?php
/**
* WooCommerce Shipping Label Banner Display Rules.
*/
namespace Automattic\WooCommerce\Internal\Admin;
/**
* Determines whether or not the Shipping Label Banner should be displayed
*/
class ShippingLabelBannerDisplayRules {
/**
* Holds the installed Jetpack version.
*
* @var string
*/
private $jetpack_version;
/**
* Whether or not the installed Jetpack is connected.
*
* @var bool
*/
private $jetpack_connected;
/**
* Holds the installed WooCommerce Shipping & Tax version.
*
* @var string
*/
private $wcs_version;
/**
* Whether or not there're plugins installed incompatible with the banner.
*
* @var bool
*/
private $no_incompatible_plugins_installed;
/**
* Whether or not the WooCommerce Shipping & Tax ToS has been accepted.
*
* @var bool
*/
private $wcs_tos_accepted;
/**
* Minimum supported Jetpack version.
*
* @var string
*/
private $min_jetpack_version = '4.4';
/**
* Minimum supported WooCommerce Shipping & Tax version.
*
* @var string
*/
private $min_wcs_version = '1.22.5';
/**
* Supported countries by USPS, see: https://webpmt.usps.gov/pmt010.cfm
*
* @var array
*/
private $supported_countries = array( 'US', 'AS', 'PR', 'VI', 'GU', 'MP', 'UM', 'FM', 'MH' );
/**
* Array of supported currency codes.
*
* @var array
*/
private $supported_currencies = array( 'USD' );
/**
* Constructor.
*
* @param string $jetpack_version Installed Jetpack version to check.
* @param bool $jetpack_connected Is Jetpack connected?.
* @param string $wcs_version Installed WooCommerce Shipping & Tax version to check.
* @param bool $wcs_tos_accepted WooCommerce Shipping & Tax Terms of Service accepted?.
* @param bool $incompatible_plugins_installed Are there any incompatible plugins installed?.
*/
public function __construct( $jetpack_version, $jetpack_connected, $wcs_version, $wcs_tos_accepted, $incompatible_plugins_installed ) {
$this->jetpack_version = $jetpack_version;
$this->jetpack_connected = $jetpack_connected;
$this->wcs_version = $wcs_version;
$this->wcs_tos_accepted = $wcs_tos_accepted;
$this->no_incompatible_plugins_installed = ! $incompatible_plugins_installed;
}
/**
* Determines whether banner is eligible for display (does not include a/b logic).
*/
public function should_display_banner() {
return $this->banner_not_dismissed() &&
$this->jetpack_installed_and_active() &&
$this->jetpack_up_to_date() &&
$this->jetpack_connected &&
$this->no_incompatible_plugins_installed &&
$this->order_has_shippable_products() &&
$this->store_in_us_and_usd() &&
( $this->wcs_not_installed() || (
$this->wcs_up_to_date() && ! $this->wcs_tos_accepted
) );
}
/**
* Checks if the banner was not dismissed by the user.
*
* @return bool
*/
private function banner_not_dismissed() {
$dismissed_timestamp_ms = get_option( 'woocommerce_shipping_dismissed_timestamp' );
if ( ! is_numeric( $dismissed_timestamp_ms ) ) {
return true;
}
$dismissed_timestamp_ms = intval( $dismissed_timestamp_ms );
$dismissed_timestamp = intval( round( $dismissed_timestamp_ms / 1000 ) );
$expired_timestamp = $dismissed_timestamp + 24 * 60 * 60; // 24 hours from click time
$dismissed_for_good = -1 === $dismissed_timestamp_ms;
$dismissed_24h = time() < $expired_timestamp;
return ! $dismissed_for_good && ! $dismissed_24h;
}
/**
* Checks if jetpack is installed and active.
*
* @return bool
*/
private function jetpack_installed_and_active() {
return ! ! $this->jetpack_version;
}
/**
* Checks if Jetpack version is supported.
*
* @return bool
*/
private function jetpack_up_to_date() {
return version_compare( $this->jetpack_version, $this->min_jetpack_version, '>=' );
}
/**
* Checks if there's a shippable product in the current order.
*
* @return bool
*/
private function order_has_shippable_products() {
$post = get_post();
if ( ! $post ) {
return false;
}
$order = wc_get_order( get_post()->ID );
if ( ! $order ) {
return false;
}
// At this point (no packaging data), only show if there's at least one existing and shippable product.
foreach ( $order->get_items() as $item ) {
if ( $item instanceof \WC_Order_Item_Product ) {
$product = $item->get_product();
if ( $product && $product->needs_shipping() ) {
return true;
}
}
}
return false;
}
/**
* Checks if the store is in the US and has its default currency set to USD.
*
* @return bool
*/
private function store_in_us_and_usd() {
$base_currency = get_woocommerce_currency();
$base_location = wc_get_base_location();
return in_array( $base_currency, $this->supported_currencies, true ) && in_array( $base_location['country'], $this->supported_countries, true );
}
/**
* Checks if WooCommerce Shipping & Tax is not installed.
*
* @return bool
*/
private function wcs_not_installed() {
return ! $this->wcs_version;
}
/**
* Checks if WooCommerce Shipping & Tax is up to date.
*/
private function wcs_up_to_date() {
return $this->wcs_version && version_compare( $this->wcs_version, $this->min_wcs_version, '>=' );
}
}
Admin/Onboarding/OnboardingMailchimp.php 0000644 00000002301 15233366071 0014277 0 ustar 00 <?php
/**
* WooCommerce Onboarding Mailchimp
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
/**
* Logic around updating Mailchimp during onboarding.
*/
class OnboardingMailchimp {
/**
* Class instance.
*
* @var OnboardingMailchimp instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_onboarding_profile_data_updated', array( $this, 'on_profile_data_updated' ), 10, 2 );
}
/**
* Reset MailchimpScheduler if profile data is being updated with a new email.
*
* @param array $existing_data Existing option data.
* @param array $updating_data Updating option data.
*/
public function on_profile_data_updated( $existing_data, $updating_data ) {
if (
isset( $existing_data['store_email'] ) &&
isset( $updating_data['store_email'] ) &&
$existing_data['store_email'] !== $updating_data['store_email']
) {
MailchimpScheduler::reset();
}
}
}
Admin/Onboarding/OnboardingProducts.php 0000644 00000013145 15233366071 0014207 0 ustar 00 <?php
/**
* WooCommerce Onboarding Products
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Class for handling product types and data around product types.
*/
class OnboardingProducts {
/**
* Name of product data transient.
*
* @var string
*/
const PRODUCT_DATA_TRANSIENT = 'wc_onboarding_product_data';
/**
* Get a list of allowed product types for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_product_types() {
$products = array(
'physical' => array(
'label' => __( 'Physical products', 'woocommerce' ),
'default' => true,
),
'downloads' => array(
'label' => __( 'Downloads', 'woocommerce' ),
),
'subscriptions' => array(
'label' => __( 'Subscriptions', 'woocommerce' ),
),
'memberships' => array(
'label' => __( 'Memberships', 'woocommerce' ),
'product' => 958589,
),
'bookings' => array(
'label' => __( 'Bookings', 'woocommerce' ),
'product' => 390890,
),
'product-bundles' => array(
'label' => __( 'Bundles', 'woocommerce' ),
'product' => 18716,
),
'product-add-ons' => array(
'label' => __( 'Customizable products', 'woocommerce' ),
'product' => 18618,
),
);
$base_location = wc_get_base_location();
$has_cbd_industry = false;
if ( 'US' === $base_location['country'] ) {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! empty( $profile['industry'] ) ) {
$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
}
}
if ( ! Features::is_enabled( 'subscriptions' ) || 'US' !== $base_location['country'] || $has_cbd_industry ) {
$products['subscriptions']['product'] = 27147;
}
return apply_filters( 'woocommerce_admin_onboarding_product_types', $products );
}
/**
* Get dynamic product data from API.
*
* @param array $product_types Array of product types.
* @return array
*/
public static function get_product_data( $product_types ) {
$locale = get_user_locale();
// Transient value is an array of product data keyed by locale.
$transient_value = get_transient( self::PRODUCT_DATA_TRANSIENT );
$transient_value = is_array( $transient_value ) ? $transient_value : array();
$woocommerce_products = $transient_value[ $locale ] ?? false;
if ( false === $woocommerce_products ) {
$woocommerce_products = wp_remote_get(
add_query_arg(
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
'locale' => $locale,
),
'https://woocommerce.com/wp-json/wccom-extensions/1.0/search'
)
);
if ( is_wp_error( $woocommerce_products ) ) {
return $product_types;
}
$transient_value[ $locale ] = $woocommerce_products;
set_transient( self::PRODUCT_DATA_TRANSIENT, $transient_value, DAY_IN_SECONDS );
}
$data = json_decode( $woocommerce_products['body'] );
$products = array();
$product_data = array();
// Map product data by ID.
if ( isset( $data ) && isset( $data->products ) ) {
foreach ( $data->products as $product_datum ) {
if ( isset( $product_datum->id ) ) {
$products[ $product_datum->id ] = $product_datum;
}
}
}
// Loop over product types and append data.
foreach ( $product_types as $key => $product_type ) {
$product_data[ $key ] = $product_types[ $key ];
if ( isset( $product_type['product'] ) && isset( $products[ $product_type['product'] ] ) ) {
$price = html_entity_decode( $products[ $product_type['product'] ]->price );
$yearly_price = (float) str_replace( '$', '', $price );
$product_data[ $key ]['yearly_price'] = $yearly_price;
$product_data[ $key ]['description'] = $products[ $product_type['product'] ]->excerpt;
$product_data[ $key ]['more_url'] = $products[ $product_type['product'] ]->link;
$product_data[ $key ]['slug'] = strtolower( preg_replace( '~[^\pL\d]+~u', '-', $products[ $product_type['product'] ]->slug ) );
} elseif ( isset( $product_type['product'] ) ) {
/* translators: site currency symbol (used to show that the product costs money) */
$product_data[ $key ]['label'] .= sprintf( __( ' — %s', 'woocommerce' ), html_entity_decode( get_woocommerce_currency_symbol() ) );
}
}
return $product_data;
}
/**
* Get the allowed product types with the polled data.
*
* @return array
*/
public static function get_product_types_with_data() {
return self::get_product_data( self::get_allowed_product_types() );
}
/**
* Get relevant purchaseable products for the site.
*
* @return array
*/
public static function get_relevant_products() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$installed = PluginsHelper::get_installed_plugin_slugs();
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
$product_data = self::get_product_types_with_data();
$purchaseable = array();
$remaining = array();
foreach ( $product_types as $type ) {
if ( ! isset( $product_data[ $type ]['slug'] ) ) {
continue;
}
$purchaseable[] = $product_data[ $type ];
if ( ! in_array( $product_data[ $type ]['slug'], $installed, true ) ) {
$remaining[] = $product_data[ $type ]['label'];
}
}
return array(
'purchaseable' => $purchaseable,
'remaining' => $remaining,
);
}
}
Admin/Onboarding/OnboardingProfile.php 0000644 00000006152 15233366071 0014004 0 ustar 00 <?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingProfile {
/**
* Profile data option name.
*/
const DATA_OPTION = 'woocommerce_onboarding_profile';
/**
* Add onboarding actions.
*/
public static function init() {
add_action( 'woocommerce_updated', array( __CLASS__, 'maybe_mark_complete' ) );
add_action( 'update_option_' . self::DATA_OPTION, array( __CLASS__, 'trigger_complete' ), 10, 2 );
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public static function trigger_complete( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Check if the profiler still needs to be completed.
*
* @return bool
*/
public static function needs_completion() {
$onboarding_data = get_option( self::DATA_OPTION, array() );
$is_completed = isset( $onboarding_data['completed'] ) && true === $onboarding_data['completed'];
$is_skipped = isset( $onboarding_data['skipped'] ) && true === $onboarding_data['skipped'];
// @todo When merging to WooCommerce Core, we should set the `completed` flag to true during the upgrade progress.
// https://github.com/woocommerce/woocommerce-admin/pull/2300#discussion_r287237498.
return ! $is_completed && ! $is_skipped;
}
/**
* When updating WooCommerce, mark the profiler and task list complete.
*
* @todo The `maybe_enable_setup_wizard()` method should be revamped on onboarding enable in core.
* See https://github.com/woocommerce/woocommerce/blob/1ca791f8f2325fe2ee0947b9c47e6a4627366374/includes/class-wc-install.php#L341
*/
public static function maybe_mark_complete() {
// The install notice still exists so don't complete the profiler.
if ( ! class_exists( 'WC_Admin_Notices' ) || \WC_Admin_Notices::has_notice( 'install' ) ) {
return;
}
$onboarding_data = get_option( self::DATA_OPTION, array() );
// Don't make updates if the profiler is completed or skipped, but task list is potentially incomplete.
if (
( isset( $onboarding_data['completed'] ) && $onboarding_data['completed'] ) ||
( isset( $onboarding_data['skipped'] ) && $onboarding_data['skipped'] )
) {
return;
}
$onboarding_data['completed'] = true;
update_option( self::DATA_OPTION, $onboarding_data );
if ( ! WCAdminHelper::is_wc_admin_active_for( DAY_IN_SECONDS ) ) {
$task_list = TaskLists::get_list( 'setup' );
if ( ! $task_list ) {
return;
}
$task_list->hide();
}
}
}
Admin/Onboarding/OnboardingHelper.php 0000644 00000011502 15233366071 0013616 0 ustar 00 <?php
/**
* WooCommerce Onboarding Helper
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingHelper {
/**
* Class instance.
*
* @var OnboardingHelper instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'add_help_tab' ), 60 );
add_action( 'current_screen', array( $this, 'reset_task_list' ) );
add_action( 'current_screen', array( $this, 'reset_extended_task_list' ) );
}
/**
* Update the help tab setup link to reset the onboarding profiler.
*/
public function add_help_tab() {
if ( ! function_exists( 'wc_get_screen_ids' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
return;
}
// Remove the old help tab if it exists.
$help_tabs = $screen->get_help_tabs();
foreach ( $help_tabs as $help_tab ) {
if ( 'woocommerce_onboard_tab' !== $help_tab['id'] ) {
continue;
}
$screen->remove_help_tab( 'woocommerce_onboard_tab' );
}
// Add the new help tab.
$help_tab = array(
'title' => __( 'Setup wizard', 'woocommerce' ),
'id' => 'woocommerce_onboard_tab',
);
$setup_list = TaskLists::get_list( 'setup' );
$extended_list = TaskLists::get_list( 'extended' );
if ( $setup_list ) {
$help_tab['content'] = '<h2>' . __( 'WooCommerce Onboarding', 'woocommerce' ) . '</h2>';
$help_tab['content'] .= '<h3>' . __( 'Profile Setup Wizard', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to access the setup wizard again, please click on the button below.', 'woocommerce' ) . '</p>' .
'<p><a href="' . wc_admin_url( '&path=/setup-wizard' ) . '" class="button button-primary">' . __( 'Setup wizard', 'woocommerce' ) . '</a></p>';
$help_tab['content'] .= '<h3>' . __( 'Task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $setup_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
if ( $extended_list ) {
$help_tab['content'] .= '<h3>' . __( 'Extended task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the extended task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $extended_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_extended_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_extended_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
$screen->add_help_tab( $help_tab );
}
/**
* Reset the onboarding task list and redirect to the dashboard.
*/
public function reset_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'setup' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'tasklist_toggled',
array(
'status' => $show ? 'enabled' : 'disabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
/**
* Reset the extended task list and redirect to the dashboard.
*/
public function reset_extended_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_extended_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'extended' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_extended_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'extended_tasklist_toggled',
array(
'status' => $show ? 'disabled' : 'enabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
}
Admin/Onboarding/Onboarding.php 0000644 00000001073 15233366071 0012460 0 ustar 00 <?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Initializes backend logic for the onboarding process.
*/
class Onboarding {
/**
* Initialize onboarding functionality.
*/
public static function init() {
OnboardingHelper::instance()->init();
OnboardingIndustries::init();
OnboardingJetpack::instance()->init();
OnboardingMailchimp::instance()->init();
OnboardingProfile::init();
OnboardingSetupWizard::instance()->init();
OnboardingSync::instance()->init();
OnboardingThemes::init();
}
}
Admin/Onboarding/OnboardingSetupWizard.php 0000644 00000020246 15233366071 0014665 0 ustar 00 <?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSetupWizard {
/**
* Class instance.
*
* @var OnboardingSetupWizard instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Add onboarding actions.
*/
public function init() {
if ( ! is_admin() ) {
return;
}
// Old settings injection.
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
add_filter( 'woocommerce_admin_preload_settings', array( $this, 'preload_settings' ) );
add_filter( 'admin_body_class', array( $this, 'add_loading_classes' ) );
add_action( 'admin_init', array( $this, 'do_admin_redirects' ) );
add_action( 'current_screen', array( $this, 'redirect_to_profiler' ) );
add_filter( 'woocommerce_show_admin_notice', array( $this, 'remove_old_install_notice' ), 10, 2 );
}
/**
* Test whether the context of execution comes from async action scheduler.
* Note: this is a polyfill for wc_is_running_from_async_action_scheduler()
* which was introduced in WC 4.0.
*
* @return bool
*/
private function is_running_from_async_action_scheduler() {
if ( function_exists( '\wc_is_running_from_async_action_scheduler' ) ) {
return \wc_is_running_from_async_action_scheduler();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return isset( $_REQUEST['action'] ) && 'as_async_request_queue_runner' === $_REQUEST['action'];
}
/**
* Handle redirects to setup/welcome page after install and updates.
*
* For setup wizard, transient must be present, the user must have access rights, and we must ignore the network/bulk plugin updaters.
*/
public function do_admin_redirects() {
// Don't run this fn from Action Scheduler requests, as it would clear _wc_activation_redirect transient.
// That means OBW would never be shown.
if ( $this->is_running_from_async_action_scheduler() ) {
return;
}
// Setup wizard redirect.
if ( get_transient( '_wc_activation_redirect' ) && apply_filters( 'woocommerce_enable_setup_wizard', true ) ) {
$do_redirect = true;
$current_page = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification
$is_onboarding_path = ! isset( $_GET['path'] ) || '/setup-wizard' === wc_clean( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// On these pages, or during these events, postpone the redirect.
if ( wp_doing_ajax() || is_network_admin() || ! current_user_can( 'manage_woocommerce' ) ) {
$do_redirect = false;
}
// On these pages, or during these events, disable the redirect.
if (
( 'wc-admin' === $current_page && $is_onboarding_path ) ||
apply_filters( 'woocommerce_prevent_automatic_wizard_redirect', false ) ||
isset( $_GET['activate-multi'] ) // phpcs:ignore WordPress.Security.NonceVerification
) {
delete_transient( '_wc_activation_redirect' );
$do_redirect = false;
}
if ( $do_redirect ) {
delete_transient( '_wc_activation_redirect' );
wp_safe_redirect( wc_admin_url() );
exit;
}
}
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function trigger_profile_completed_action( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Returns true if the profiler should be displayed (not completed and not skipped).
*
* @return bool
*/
private function should_show() {
if ( $this->is_setup_wizard() ) {
return true;
}
return OnboardingProfile::needs_completion();
}
/**
* Redirect to the profiler on homepage if completion is needed.
*/
public function redirect_to_profiler() {
if ( ! $this->is_homepage() || ! OnboardingProfile::needs_completion() ) {
return;
}
wp_safe_redirect( wc_admin_url( '&path=/setup-wizard' ) );
exit;
}
/**
* Check if the current page is the profile wizard.
*
* @return bool
*/
private function is_setup_wizard() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
isset( $_GET['path'] ) &&
'/setup-wizard' === $_GET['path'];
/* phpcs: enable */
}
/**
* Check if the current page is the homepage.
*
* @return bool
*/
private function is_homepage() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
! isset( $_GET['path'] );
/* phpcs: enable */
}
/**
* Determine if the current page is one of the WC Admin pages.
*
* @return bool
*/
private function is_woocommerce_page() {
$current_page = PageController::get_instance()->get_current_page();
if ( ! $current_page || ! isset( $current_page['path'] ) ) {
return false;
}
return 0 === strpos( $current_page['path'], 'wc-admin' );
}
/**
* Add profiler items to component settings.
*
* @param array $settings Component settings.
*
* @return array
*/
public function component_settings( $settings ) {
$profile = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
$settings['onboarding'] = array(
'profile' => $profile,
);
// Only fetch if the onboarding wizard OR the task list is incomplete or currently shown
// or the current page is one of the WooCommerce Admin pages.
if (
( ! $this->should_show() && ! count( TaskLists::get_visible() )
||
! $this->is_woocommerce_page()
)
) {
return $settings;
}
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
$wccom_auth = \WC_Helper_Options::get( 'auth' );
$profile['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;
$settings['onboarding']['currencySymbols'] = get_woocommerce_currency_symbols();
$settings['onboarding']['euCountries'] = WC()->countries->get_european_union_countries();
$settings['onboarding']['localeInfo'] = include WC()->plugin_path() . '/i18n/locale-info.php';
$settings['onboarding']['profile'] = $profile;
return apply_filters( 'woocommerce_admin_onboarding_preloaded_data', $settings );
}
/**
* Preload WC setting options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_settings( $options ) {
$options[] = 'general';
return $options;
}
/**
* Set the admin full screen class when loading to prevent flashes of unstyled content.
*
* @param bool $classes Body classes.
* @return array
*/
public function add_loading_classes( $classes ) {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( $this->is_setup_wizard() ) {
$classes .= ' woocommerce-admin-full-screen';
}
/* phpcs: enable */
return $classes;
}
/**
* Remove the install notice that prompts the user to visit the old onboarding setup wizard.
*
* @param bool $show Show or hide the notice.
* @param string $notice The slug of the notice.
* @return bool
*/
public function remove_old_install_notice( $show, $notice ) {
if ( 'install' === $notice ) {
return false;
}
return $show;
}
}
Admin/Onboarding/OnboardingSync.php 0000644 00000007721 15233366071 0013323 0 ustar 00 <?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSync {
/**
* Class instance.
*
* @var OnboardingSync instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( $this, 'send_profile_data_on_update' ), 10, 2 );
add_action( 'woocommerce_helper_connected', array( $this, 'send_profile_data_on_connect' ) );
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'redirect_wccom_install' ) );
}
/**
* Send profile data to WooCommerce.com.
*/
private function send_profile_data() {
if ( 'yes' !== get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_API' ) || ! method_exists( '\WC_Helper_API', 'put' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_Options' ) ) {
return;
}
$auth = \WC_Helper_Options::get( 'auth' );
if ( empty( $auth['access_token'] ) || empty( $auth['access_token_secret'] ) ) {
return false;
}
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
$base_location = wc_get_base_location();
$defaults = array(
'plugins' => 'skipped',
'industry' => array(),
'product_types' => array(),
'product_count' => '0',
'selling_venues' => 'no',
'number_employees' => '1',
'revenue' => 'none',
'other_platform' => 'none',
'business_extensions' => array(),
'theme' => get_stylesheet(),
'setup_client' => false,
'store_location' => $base_location['country'],
'default_currency' => get_woocommerce_currency(),
);
// Prepare industries as an array of slugs if they are in array format.
if ( isset( $profile['industry'] ) && is_array( $profile['industry'] ) ) {
$industry_slugs = array();
foreach ( $profile['industry'] as $industry ) {
$industry_slugs[] = is_array( $industry ) ? $industry['slug'] : $industry;
}
$profile['industry'] = $industry_slugs;
}
$body = wp_parse_args( $profile, $defaults );
\WC_Helper_API::put(
'profile',
array(
'authenticated' => true,
'body' => wp_json_encode( $body ),
'headers' => array(
'Content-Type' => 'application/json',
),
)
);
}
/**
* Send profiler data on profiler change to completion.
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function send_profile_data_on_update( $old_value, $value ) {
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Send profiler data after a site is connected.
*/
public function send_profile_data_on_connect() {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $profile['completed'] ) || ! $profile['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Redirects the user to the task list if the task list is enabled and finishing a wccom checkout.
*
* @todo Once URL params are added to the redirect, we can check those instead of the referer.
*/
public function redirect_wccom_install() {
$task_list = TaskLists::get_list( 'setup' );
if (
! $task_list ||
$task_list->is_hidden() ||
! isset( $_SERVER['HTTP_REFERER'] ) ||
0 !== strpos( $_SERVER['HTTP_REFERER'], 'https://woocommerce.com/checkout?utm_medium=product' ) // phpcs:ignore sanitization ok.
) {
return;
}
wp_safe_redirect( wc_admin_url() );
}
}
Admin/Onboarding/OnboardingIndustries.php 0000644 00000005020 15233366071 0014526 0 ustar 00 <?php
/**
* WooCommerce Onboarding Industries
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Logic around onboarding industries.
*/
class OnboardingIndustries {
/**
* Init.
*/
public static function init() {
add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
}
/**
* Get a list of allowed industries for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_industries() {
/* With "use_description" we turn the description input on. With "description_label" we set the input label */
return apply_filters(
'woocommerce_admin_onboarding_industries',
array(
'fashion-apparel-accessories' => array(
'label' => __( 'Fashion, apparel, and accessories', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'health-beauty' => array(
'label' => __( 'Health and beauty', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'electronics-computers' => array(
'label' => __( 'Electronics and computers', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'food-drink' => array(
'label' => __( 'Food and drink', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'home-furniture-garden' => array(
'label' => __( 'Home, furniture, and garden', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'cbd-other-hemp-derived-products' => array(
'label' => __( 'CBD and other hemp-derived products', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'education-and-learning' => array(
'label' => __( 'Education and learning', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'other' => array(
'label' => __( 'Other', 'woocommerce' ),
'use_description' => true,
'description_label' => __( 'Description', 'woocommerce' ),
),
)
);
}
/**
* Add preloaded data to onboarding.
*
* @param array $settings Component settings.
* @return array
*/
public static function preload_data( $settings ) {
$settings['onboarding']['industries'] = self::get_allowed_industries();
return $settings;
}
}
Admin/Onboarding/OnboardingThemes.php 0000644 00000015312 15233366071 0013627 0 ustar 00 <?php
/**
* WooCommerce Onboarding Themes
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use \Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Init as OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/**
* Logic around onboarding themes.
*/
class OnboardingThemes {
/**
* Name of themes transient.
*
* @var string
*/
const THEMES_TRANSIENT = 'wc_onboarding_themes';
/**
* Init.
*/
public static function init() {
add_action( 'woocommerce_theme_installed', array( __CLASS__, 'delete_themes_transient' ) );
add_action( 'after_switch_theme', array( __CLASS__, 'delete_themes_transient' ) );
add_filter( 'woocommerce_rest_prepare_themes', array( __CLASS__, 'add_uploaded_theme_data' ) );
add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
}
/**
* Get puchasable theme by slug.
*
* @param string $price_string string of price.
* @return float|null
*/
private static function get_price_from_string( $price_string ) {
$price_match = null;
// Parse price from string as it includes the currency symbol.
preg_match( '/\\d+\.\d{2}\s*/', $price_string, $price_match );
if ( count( $price_match ) > 0 ) {
return (float) $price_match[0];
}
return null;
}
/**
* Get puchasable theme by slug.
*
* @param string $slug from theme.
* @return array|null
*/
public static function get_paid_theme_by_slug( $slug ) {
$themes = self::get_themes();
$theme_key = array_search( $slug, array_column( $themes, 'slug' ), true );
$theme = false !== $theme_key ? $themes[ $theme_key ] : null;
if ( $theme && isset( $theme['id'] ) && isset( $theme['price'] ) ) {
$price = self::get_price_from_string( $theme['price'] );
if ( $price && $price > 0 ) {
return $themes[ $theme_key ];
}
}
return null;
}
/**
* Sort themes returned from WooCommerce.com
*
* @param array $themes Array of themes from WooCommerce.com.
* @return array
*/
public static function sort_woocommerce_themes( $themes ) {
usort(
$themes,
function ( $product_1, $product_2 ) {
if ( ! property_exists( $product_1, 'id' ) || ! property_exists( $product_1, 'slug' ) ) {
return 1;
}
if ( ! property_exists( $product_2, 'id' ) || ! property_exists( $product_2, 'slug' ) ) {
return 1;
}
if ( in_array( 'Storefront', array( $product_1->slug, $product_2->slug ), true ) ) {
return 'Storefront' === $product_1->slug ? -1 : 1;
}
return $product_1->id < $product_2->id ? 1 : -1;
}
);
return $themes;
}
/**
* Get a list of themes for the onboarding wizard.
*
* @return array
*/
public static function get_themes() {
$themes = get_transient( self::THEMES_TRANSIENT );
if ( false === $themes ) {
$theme_data = wp_remote_get(
'https://woocommerce.com/wp-json/wccom-extensions/1.0/search?category=themes',
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$themes = array();
if ( ! is_wp_error( $theme_data ) ) {
$theme_data = json_decode( $theme_data['body'] );
$woo_themes = property_exists( $theme_data, 'products' ) ? $theme_data->products : array();
$sorted_themes = self::sort_woocommerce_themes( $woo_themes );
foreach ( $sorted_themes as $theme ) {
$slug = sanitize_title_with_dashes( $theme->slug );
$themes[ $slug ] = (array) $theme;
$themes[ $slug ]['is_installed'] = false;
$themes[ $slug ]['has_woocommerce_support'] = true;
$themes[ $slug ]['slug'] = $slug;
}
}
$installed_themes = wp_get_themes();
$active_theme = get_option( 'stylesheet' );
foreach ( $installed_themes as $slug => $theme ) {
$theme_data = self::get_theme_data( $theme );
if ( isset( $themes[ $slug ] ) ) {
$themes[ $slug ]['is_installed'] = true;
$themes[ $slug ]['image'] = $theme_data['image'];
} else {
$themes[ $slug ] = $theme_data;
}
}
// Add the WooCommerce support tag for default themes that don't explicitly declare support.
if ( function_exists( 'wc_is_wp_default_theme_active' ) && wc_is_wp_default_theme_active() ) {
$themes[ $active_theme ]['has_woocommerce_support'] = true;
}
$themes = array( $active_theme => $themes[ $active_theme ] ) + $themes;
set_transient( self::THEMES_TRANSIENT, $themes, DAY_IN_SECONDS );
}
$themes = apply_filters( 'woocommerce_admin_onboarding_themes', $themes );
return array_values( $themes );
}
/**
* Get theme data used in onboarding theme browser.
*
* @param WP_Theme $theme Theme to gather data from.
* @return array
*/
public static function get_theme_data( $theme ) {
return array(
'slug' => sanitize_text_field( $theme->stylesheet ),
'title' => $theme->get( 'Name' ),
'price' => '0.00',
'is_installed' => true,
'image' => $theme->get_screenshot(),
'has_woocommerce_support' => true,
);
}
/**
* Add theme data to response from themes controller.
*
* @param WP_REST_Response $response Rest response.
* @return WP_REST_Response
*/
public static function add_uploaded_theme_data( $response ) {
if ( ! isset( $response->data['theme'] ) ) {
return $response;
}
$theme = wp_get_theme( $response->data['theme'] );
$response->data['theme_data'] = self::get_theme_data( $theme );
return $response;
}
/**
* Delete the stored themes transient.
*/
public static function delete_themes_transient() {
delete_transient( self::THEMES_TRANSIENT );
}
/**
* Add preloaded data to onboarding.
*
* @param array $settings Component settings.
*
* @return array
*/
public static function preload_data( $settings ) {
$settings['onboarding']['activeTheme'] = get_option( 'stylesheet' );
$settings['onboarding']['themes'] = self::get_themes();
return $settings;
}
/**
* Gets an array of themes that can be installed & activated via the onboarding wizard.
*
* @return array
*/
public static function get_allowed_themes() {
$allowed_themes = array();
$themes = self::get_themes();
foreach ( $themes as $theme ) {
$price = preg_replace( '/&#?[a-z0-9]+;/i', '', $theme['price'] );
if ( $theme['is_installed'] || '0.00' === $price ) {
$allowed_themes[] = $theme['slug'];
}
}
return apply_filters( 'woocommerce_admin_onboarding_themes_whitelist', $allowed_themes );
}
}
Admin/Onboarding/OnboardingJetpack.php 0000644 00000003464 15233366071 0013770 0 ustar 00 <?php
/**
* WooCommerce Onboarding Jetpack
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Contains logic around Jetpack setup during onboarding.
*/
class OnboardingJetpack {
/**
* Class instance.
*
* @var OnboardingJetpack instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_admin_plugins_pre_activate', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
add_action( 'woocommerce_admin_plugins_pre_install', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
// Always hook into Jetpack connection even if outside of admin.
add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) );
}
/**
* Sets the woocommerce_setup_jetpack_opted_in to true when Jetpack connects to WPCOM.
*/
public function set_woocommerce_setup_jetpack_opted_in() {
update_option( 'woocommerce_setup_jetpack_opted_in', true );
}
/**
* Ensure that Jetpack gets installed and activated ahead of WooCommerce Payments
* if both are being installed/activated at the same time.
*
* See: https://github.com/Automattic/woocommerce-payments/issues/1663
* See: https://github.com/Automattic/jetpack/issues/19624
*
* @param array $plugins A list of plugins to install or activate.
*
* @return array
*/
public function activate_and_install_jetpack_ahead_of_wcpay( $plugins ) {
if ( in_array( 'jetpack', $plugins, true ) && in_array( 'woocommerce-payments', $plugins, true ) ) {
array_unshift( $plugins, 'jetpack' );
$plugins = array_unique( $plugins );
}
return $plugins;
}
}
Admin/RemoteInboxNotifications.php 0000644 00000001645 15233366071 0013306 0 ustar 00 <?php
/**
* Remote Inbox Notifications feature.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use \Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
/**
* Remote Inbox Notifications feature logic.
*/
class RemoteInboxNotifications {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_show_marketplace_suggestions';
/**
* Class instance.
*
* @var RemoteInboxNotifications instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( Features::is_enabled( 'remote-inbox-notifications' ) ) {
RemoteInboxNotificationsEngine::init();
}
}
}
Admin/ActivityPanels.php 0000644 00000003120 15233366071 0011246 0 ustar 00 <?php
/**
* WooCommerce Activity Panel.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Contains backend logic for the activity panel feature.
*/
class ActivityPanels {
/**
* Class instance.
*
* @var ActivityPanels instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
}
/**
* Adds fields so that we can store activity panel last read and open times.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'activity_panel_inbox_last_read',
'activity_panel_reviews_last_read',
)
);
}
/**
* Add alert count to the component settings.
*
* @param array $settings Component settings.
*/
public function component_settings( $settings ) {
$settings['alertCount'] = Notes::get_notes_count( array( 'error', 'update' ), array( 'unactioned' ) );
return $settings;
}
}
Admin/Analytics.php 0000644 00000021702 15233366071 0010244 0 ustar 00 <?php
/**
* WooCommerce Analytics.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* Contains backend logic for the Analytics feature.
*/
class Analytics {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_analytics_enabled';
/**
* Clear cache tool identifier.
*/
const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Determines if the feature has been toggled on or off.
*
* @var boolean
*/
protected static $is_updated = false;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
if ( ! Features::is_enabled( 'analytics' ) ) {
return;
}
add_filter( 'woocommerce_component_settings_preload_endpoints', array( $this, 'add_preload_endpoints' ) );
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_pages' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
}
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
return $features;
}
/**
* Reloads the page when the option is toggled to make sure all Analytics features are loaded.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function reload_page_on_toggle( $old_value, $value ) {
if ( $old_value === $value ) {
return;
}
self::$is_updated = true;
}
/**
* Reload the page if the setting has been updated.
*/
public static function maybe_reload_page() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
return;
}
wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
exit();
}
/**
* Preload data from the countries endpoint.
*
* @param array $endpoints Array of preloaded endpoints.
* @return array
*/
public function add_preload_endpoints( $endpoints ) {
$endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed';
$endpoints['leaderboards'] = '/wc-analytics/leaderboards/allowed';
return $endpoints;
}
/**
* Adds fields so that we can store user preferences for the columns to display on a report.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'categories_report_columns',
'coupons_report_columns',
'customers_report_columns',
'orders_report_columns',
'products_report_columns',
'revenue_report_columns',
'taxes_report_columns',
'variations_report_columns',
'dashboard_sections',
'dashboard_chart_type',
'dashboard_chart_interval',
'dashboard_leaderboard_rows',
)
);
}
/**
* Register the cache clearing tool on the WooCommerce > Status > Tools page.
*
* @param array $debug_tools Available debug tool registrations.
* @return array Filtered debug tool registrations.
*/
public function register_cache_clear_tool( $debug_tools ) {
$settings_url = add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/analytics/settings',
),
get_admin_url( null, 'admin.php' )
);
$debug_tools[ self::CACHE_TOOL_ID ] = array(
'name' => __( 'Clear analytics cache', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'desc' => sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ),
'<a href="' . esc_url( $settings_url ) . '">',
'</a>'
),
'callback' => array( $this, 'run_clear_cache_tool' ),
);
return $debug_tools;
}
/**
* Registers report pages.
*/
public function register_pages() {
$report_pages = self::get_report_pages();
foreach ( $report_pages as $report_page ) {
if ( ! is_null( $report_page ) ) {
wc_admin_register_page( $report_page );
}
}
}
/**
* Get report pages.
*/
public static function get_report_pages() {
$overview_page = array(
'id' => 'woocommerce-analytics',
'title' => __( 'Analytics', 'woocommerce' ),
'path' => '/analytics/overview',
'icon' => 'dashicons-chart-bar',
'position' => 57, // After WooCommerce & Product menu items.
);
$report_pages = array(
$overview_page,
array(
'id' => 'woocommerce-analytics-overview',
'title' => __( 'Overview', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/overview',
'nav_args' => array(
'order' => 10,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-products',
'title' => __( 'Products', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/products',
'nav_args' => array(
'order' => 20,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-revenue',
'title' => __( 'Revenue', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/revenue',
'nav_args' => array(
'order' => 30,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-orders',
'title' => __( 'Orders', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/orders',
'nav_args' => array(
'order' => 40,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-variations',
'title' => __( 'Variations', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/variations',
'nav_args' => array(
'order' => 50,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-categories',
'title' => __( 'Categories', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/categories',
'nav_args' => array(
'order' => 60,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-coupons',
'title' => __( 'Coupons', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/coupons',
'nav_args' => array(
'order' => 70,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-taxes',
'title' => __( 'Taxes', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/taxes',
'nav_args' => array(
'order' => 80,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-downloads',
'title' => __( 'Downloads', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/downloads',
'nav_args' => array(
'order' => 90,
'parent' => 'woocommerce-analytics',
),
),
'yes' === get_option( 'woocommerce_manage_stock' ) ? array(
'id' => 'woocommerce-analytics-stock',
'title' => __( 'Stock', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/stock',
'nav_args' => array(
'order' => 100,
'parent' => 'woocommerce-analytics',
),
) : null,
array(
'id' => 'woocommerce-analytics-customers',
'title' => __( 'Customers', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => '/customers',
),
array(
'id' => 'woocommerce-analytics-settings',
'title' => __( 'Settings', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/settings',
'nav_args' => array(
'title' => __( 'Analytics', 'woocommerce' ),
'parent' => 'woocommerce-settings',
),
),
);
/**
* The analytics report items used in the menu.
*
* @since 6.4.0
*/
return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages );
}
/**
* "Clear" analytics cache by invalidating it.
*/
public function run_clear_cache_tool() {
Cache::invalidate();
return __( 'Analytics cache cleared.', 'woocommerce' );
}
}
Admin/MobileAppBanner.php 0000644 00000001674 15233366071 0011321 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Determine if the mobile app banner shows on Android devices
*/
class MobileAppBanner {
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
}
/**
* Adds fields so that we can store user preferences for the mobile app banner
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'android_app_banner_dismissed',
)
);
}
}
Admin/ShippingLabelBanner.php 0000644 00000010227 15233366071 0012164 0 ustar 00 <?php
/**
* WooCommerce Shipping Label banner.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use \Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
/**
* Shows print shipping label banner on edit order page.
*/
class ShippingLabelBanner {
/**
* Singleton for the display rules class
*
* @var ShippingLabelBannerDisplayRules
*/
private $shipping_label_banner_display_rules;
/**
* Constructor
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ), 6, 2 );
}
/**
* Check if WooCommerce Shipping makes sense for this merchant.
*
* @return bool
*/
private function should_show_meta_box() {
if ( ! $this->shipping_label_banner_display_rules ) {
$jetpack_version = null;
$jetpack_connected = null;
$wcs_version = null;
$wcs_tos_accepted = null;
if ( defined( 'JETPACK__VERSION' ) ) {
$jetpack_version = JETPACK__VERSION;
}
if ( class_exists( Jetpack_Connection_Manager::class ) ) {
$jetpack_connected = ( new Jetpack_Connection_Manager() )->has_connected_owner();
}
if ( class_exists( '\WC_Connect_Loader' ) ) {
$wcs_version = \WC_Connect_Loader::get_wcs_version();
}
if ( class_exists( '\WC_Connect_Options' ) ) {
$wcs_tos_accepted = \WC_Connect_Options::get_option( 'tos_accepted' );
}
$incompatible_plugins = class_exists( '\WC_Shipping_Fedex_Init' ) ||
class_exists( '\WC_Shipping_UPS_Init' ) ||
class_exists( '\WC_Integration_ShippingEasy' ) ||
class_exists( '\WC_ShipStation_Integration' );
$this->shipping_label_banner_display_rules =
new ShippingLabelBannerDisplayRules(
$jetpack_version,
$jetpack_connected,
$wcs_version,
$wcs_tos_accepted,
$incompatible_plugins
);
}
return $this->shipping_label_banner_display_rules->should_display_banner();
}
/**
* Add metabox to order page.
*
* @param string $post_type current post type.
* @param \WP_Post $post Current post object.
*/
public function add_meta_boxes( $post_type, $post ) {
if ( 'shop_order' !== $post_type ) {
return;
}
$order = wc_get_order( $post );
if ( $this->should_show_meta_box() ) {
add_meta_box(
'woocommerce-admin-print-label',
__( 'Shipping Label', 'woocommerce' ),
array( $this, 'meta_box' ),
null,
'normal',
'high',
array(
'context' => 'shipping_label',
'order' => $post->ID,
'items' => $this->count_shippable_items( $order ),
)
);
add_action( 'admin_enqueue_scripts', array( $this, 'add_print_shipping_label_script' ) );
}
}
/**
* Count shippable items
*
* @param \WC_Order $order Current order.
* @return int
*/
private function count_shippable_items( \WC_Order $order ) {
$count = 0;
foreach ( $order->get_items() as $item ) {
if ( $item instanceof \WC_Order_Item_Product ) {
$product = $item->get_product();
if ( $product && $product->needs_shipping() ) {
$count += $item->get_quantity();
}
}
}
return $count;
}
/**
* Adds JS to order page to render shipping banner.
*
* @param string $hook current page hook.
*/
public function add_print_shipping_label_script( $hook ) {
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'print-shipping-label-banner-style',
WCAdminAssets::get_url( "print-shipping-label-banner/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'print-shipping-label-banner', true );
$payload = array(
'nonce' => wp_create_nonce( 'wp_rest' ),
'baseURL' => get_rest_url(),
'wcs_server_connection' => true,
);
wp_localize_script( 'print-shipping-label-banner', 'wcConnectData', $payload );
}
/**
* Render placeholder metabox.
*
* @param \WP_Post $post current post.
* @param array $args empty args.
*/
public function meta_box( $post, $args ) {
?>
<div id="wc-admin-shipping-banner-root" class="woocommerce <?php echo esc_attr( 'wc-admin-shipping-banner' ); ?>" data-args="<?php echo esc_attr( wp_json_encode( $args['args'] ) ); ?>">
</div>
<?php
}
}
Admin/FeaturePlugin.php 0000644 00000015347 15233366071 0011077 0 ustar 00 <?php
/**
* WooCommerce Admin: Feature plugin main class.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API;
use \Automattic\WooCommerce\Admin\Notes\Notes;
use \Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use \Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use \Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use \Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use \Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use \Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use \Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use \Automattic\WooCommerce\Internal\Admin\Notes\MerchantEmailNotifications;
use \Automattic\WooCommerce\Internal\Admin\Notes\WelcomeToWooCommerceForStoreUsers;
use \Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstaller;
use Automattic\WooCommerce\Admin\ReportExporter;
use Automattic\WooCommerce\Admin\ReportsSync;
use Automattic\WooCommerce\Internal\Admin\CategoryLookup;
use Automattic\WooCommerce\Internal\Admin\Events;
use Automattic\WooCommerce\Internal\Admin\Onboarding\Onboarding;
/**
* Feature plugin main class.
*
* @internal This file will not be bundled with woo core, only the feature plugin.
* @internal Note this is not called WC_Admin due to a class already existing in core with that name.
*/
class FeaturePlugin {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init the feature plugin, only if we can detect both Gutenberg and WooCommerce.
*/
public function init() {
// Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint).
if ( ! defined( 'WC_ABSPATH' ) ) {
return;
}
// Load the page controller functions file first to prevent fatal errors when disabling WooCommerce Admin.
$this->define_constants();
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/page-controller-functions.php';
require_once WC_ADMIN_ABSPATH . '/src/Admin/Notes/DeprecatedNotes.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/core-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/feature-config.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/wc-admin-update-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/class-experimental-abtest.php';
if ( did_action( 'plugins_loaded' ) ) {
self::on_plugins_loaded();
} else {
// Make sure we hook into `plugins_loaded` before core's Automattic\WooCommerce\Package::init().
// If core is network activated but we aren't, the packaged version of WooCommerce Admin will
// attempt to use a data store that hasn't been loaded yet - because we've defined our constants here.
// See: https://github.com/woocommerce/woocommerce-admin/issues/3869.
add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 9 );
}
}
/**
* Setup plugin once all other plugins are loaded.
*
* @return void
*/
public function on_plugins_loaded() {
$this->hooks();
$this->includes();
}
/**
* Define Constants.
*/
protected function define_constants() {
$this->define( 'WC_ADMIN_APP', 'wc-admin-app' );
$this->define( 'WC_ADMIN_ABSPATH', WC_ABSPATH );
$this->define( 'WC_ADMIN_DIST_JS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_DIST_CSS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_PLUGIN_FILE', WC_PLUGIN_FILE );
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_IMAGES_FOLDER_URL' ) ) {
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
define( 'WC_ADMIN_IMAGES_FOLDER_URL', plugins_url( 'assets/images', WC_PLUGIN_FILE ) );
}
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_VERSION_NUMBER' ) ) {
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
define( 'WC_ADMIN_VERSION_NUMBER', '3.3.0' );
}
}
/**
* Include WC Admin classes.
*/
public function includes() {
// Initialize Database updates, option migrations, and Notes.
Events::instance()->init();
Notes::init();
// Initialize Plugins Installer.
PluginsInstaller::init();
PluginsHelper::init();
// Initialize API.
API\Init::instance();
if ( Features::is_enabled( 'onboarding' ) ) {
Onboarding::init();
}
if ( Features::is_enabled( 'analytics' ) ) {
// Initialize Reports syncing.
ReportsSync::init();
CategoryLookup::instance()->init();
// Initialize Reports exporter.
ReportExporter::init();
}
// Admin note providers.
// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
new WooSubscriptionsNotes();
new OrderMilestones();
new TrackingOptIn();
new WooCommercePayments();
new InstallJPAndWCSPlugins();
new TestCheckout();
new SellingOnlineCourses();
new WelcomeToWooCommerceForStoreUsers();
new MagentoMigration();
// Initialize MerchantEmailNotifications.
MerchantEmailNotifications::init();
}
/**
* Set up our admin hooks and plugin loader.
*/
protected function hooks() {
add_filter( 'woocommerce_admin_features', array( $this, 'replace_supported_features' ), 0 );
Loader::get_instance();
WCAdminAssets::get_instance();
}
/**
* Overwrites the allowed features array using a local `feature-config.php` file.
*
* @param array $features Array of feature slugs.
*/
public function replace_supported_features( $features ) {
/**
* Get additional feature config
*
* @since 6.5.0
*/
$feature_config = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
$features = array_keys( array_filter( $feature_config ) );
return $features;
}
/**
* Define constant if not already set.
*
* @param string $name Constant name.
* @param string|bool $value Constant value.
*/
protected function define( $name, $value ) {
if ( ! defined( $name ) ) {
define( $name, $value );
}
}
/**
* Prevent cloning.
*/
private function __clone() {}
/**
* Prevent unserializing.
*/
public function __wakeup() {
die();
}
}
Admin/Schedulers/OrdersScheduler.php 0000644 00000020561 15233366071 0013515 0 ustar 00 <?php
/**
* Order syncing related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\Overrides\Order;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* OrdersScheduler Class.
*/
class OrdersScheduler extends ImportScheduler {
/**
* Slug to identify the scheduler.
*
* @var string
*/
public static $name = 'orders';
/**
* Attach order lookup update hooks.
*
* @internal
*/
public static function init() {
// Activate WC_Order extension.
\Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
\Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();
// Order and refund data must be run on these hooks to ensure meta data is set.
add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
OrdersStatsDataStore::init();
CouponsDataStore::init();
ProductsDataStore::init();
TaxesDataStore::init();
parent::init();
}
/**
* Add customer dependencies.
*
* @internal
* @return array
*/
public static function get_dependencies() {
return array(
'import_batch_init' => \Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler::get_action( 'import_batch_init' ),
);
}
/**
* Get the order/refund IDs and total count that need to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*/
public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
return self::get_items_from_orders_table( $limit, $page, $days, $skip_existing );
} else {
return self::get_items_from_posts_table( $limit, $page, $days, $skip_existing );
}
}
/**
* Helper method to ger order/refund IDS and total count that needs to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*
* @return object Total counts.
*/
private static function get_items_from_posts_table( $limit, $page, $days, $skip_existing ) {
global $wpdb;
$where_clause = '';
$offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
if ( is_int( $days ) ) {
$days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
$where_clause .= " AND post_date_gmt >= '{$days_ago}'";
}
if ( $skip_existing ) {
$where_clause .= " AND NOT EXISTS (
SELECT 1 FROM {$wpdb->prefix}wc_order_stats
WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
)";
}
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}"
); // phpcs:ignore unprepared SQL ok.
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}
ORDER BY post_date_gmt ASC
LIMIT %d
OFFSET %d",
$limit,
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
return (object) array(
'total' => absint( $count ),
'ids' => $order_ids,
);
}
/**
* Helper method to ger order/refund IDS and total count that needs to be synced from HPOS.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*
* @return object Total counts.
*/
private static function get_items_from_orders_table( $limit, $page, $days, $skip_existing ) {
global $wpdb;
$where_clause = '';
$offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
$order_table = OrdersTableDataStore::get_orders_table_name();
if ( is_int( $days ) ) {
$days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
$where_clause .= " AND orders.date_created_gmt >= '{$days_ago}'";
}
if ( $skip_existing ) {
$where_clause .= "AND NOT EXiSTS (
SELECT 1 FROM {$wpdb->prefix}wc_order_stats
WHERE {$wpdb->prefix}wc_order_stats.order_id = orders.id
)
";
}
$count = $wpdb->get_var(
"
SELECT COUNT(*) FROM {$order_table} AS orders
WHERE type in ( 'shop_order', 'shop_order_refund' )
AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
{$where_clause}
"
); // phpcs:ignore unprepared SQL ok.
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$order_table} AS orders
WHERE type IN ( 'shop_order', 'shop_order_refund' )
AND status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}
ORDER BY date_created_gmt ASC
LIMIT %d
OFFSET %d",
$limit,
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
return (object) array(
'total' => absint( $count ),
'ids' => $order_ids,
);
}
/**
* Get total number of rows imported.
*
* @internal
*/
public static function get_total_imported() {
global $wpdb;
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
}
/**
* Schedule this import if the post is an order or refund.
*
* @param int $order_id Post ID.
*
* @internal
*/
public static function possibly_schedule_import( $order_id ) {
if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() ) {
return;
}
self::schedule_action( 'import', array( $order_id ) );
}
/**
* Imports a single order or refund to update lookup tables for.
* If an error is encountered in one of the updates, a retry action is scheduled.
*
* @internal
* @param int $order_id Order or refund ID.
* @return void
*/
public static function import( $order_id ) {
$order = wc_get_order( $order_id );
// If the order isn't found for some reason, skip the sync.
if ( ! $order ) {
return;
}
$type = $order->get_type();
// If the order isn't the right type, skip sync.
if ( 'shop_order' !== $type && 'shop_order_refund' !== $type ) {
return;
}
// If the order has no id or date created, skip sync.
if ( ! $order->get_id() || ! $order->get_date_created() ) {
return;
}
$results = array(
OrdersStatsDataStore::sync_order( $order_id ),
ProductsDataStore::sync_order_products( $order_id ),
CouponsDataStore::sync_order_coupons( $order_id ),
TaxesDataStore::sync_order_taxes( $order_id ),
CustomersDataStore::sync_order_customer( $order_id ),
);
if ( 'shop_order' === $type ) {
$order_refunds = $order->get_refunds();
foreach ( $order_refunds as $refund ) {
OrdersStatsDataStore::sync_order( $refund->get_id() );
}
}
ReportsCache::invalidate();
}
/**
* Delete a batch of orders.
*
* @internal
* @param int $batch_size Number of items to delete.
* @return void
*/
public static function delete( $batch_size ) {
global $wpdb;
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $order_ids as $order_id ) {
OrdersStatsDataStore::delete_order( $order_id );
}
}
}
Admin/Schedulers/ImportScheduler.php 0000644 00000011461 15233366071 0013530 0 ustar 00 <?php
/**
* Import related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use \Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;
/**
* ImportScheduler class.
*/
abstract class ImportScheduler implements ImportInterface {
/**
* Import stats option name.
*/
const IMPORT_STATS_OPTION = 'woocommerce_admin_import_stats';
/**
* Scheduler traits.
*/
use SchedulerTraits {
get_batch_sizes as get_scheduler_batch_sizes;
}
/**
* Returns true if an import is in progress.
*
* @internal
* @return bool
*/
public static function is_importing() {
$pending_jobs = self::queue()->search(
array(
'status' => 'pending',
'per_page' => 1,
'claimed' => false,
'search' => 'import',
'group' => self::$group,
)
);
if ( empty( $pending_jobs ) ) {
$in_progress = self::queue()->search(
array(
'status' => 'in-progress',
'per_page' => 1,
'search' => 'import',
'group' => self::$group,
)
);
}
return ! empty( $pending_jobs ) || ! empty( $in_progress );
}
/**
* Get batch sizes.
*
* @internal
* @retun array
*/
public static function get_batch_sizes() {
return array_merge(
self::get_scheduler_batch_sizes(),
array(
'delete' => 10,
'import' => 25,
'queue' => 100,
)
);
}
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
*
* @internal
* @return array
*/
public static function get_scheduler_actions() {
return array(
'import_batch_init' => 'wc-admin_import_batch_init_' . static::$name,
'import_batch' => 'wc-admin_import_batch_' . static::$name,
'delete_batch_init' => 'wc-admin_delete_batch_init_' . static::$name,
'delete_batch' => 'wc-admin_delete_batch_' . static::$name,
'import' => 'wc-admin_import_' . static::$name,
);
}
/**
* Queue the imports into multiple batches.
*
* @internal
* @param integer|boolean $days Number of days to import.
* @param boolean $skip_existing Skip exisiting records.
*/
public static function import_batch_init( $days, $skip_existing ) {
$batch_size = static::get_batch_size( 'import' );
$items = static::get_items( 1, 1, $days, $skip_existing );
if ( 0 === $items->total ) {
return;
}
$num_batches = ceil( $items->total / $batch_size );
self::queue_batches( 1, $num_batches, 'import_batch', array( $days, $skip_existing ) );
}
/**
* Imports a batch of items to update.
*
* @internal
* @param int $batch_number Batch number to import (essentially a query page number).
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip exisiting records.
* @return void
*/
public static function import_batch( $batch_number, $days, $skip_existing ) {
$batch_size = static::get_batch_size( 'import' );
$properties = array(
'batch_number' => $batch_number,
'batch_size' => $batch_size,
'type' => static::$name,
);
wc_admin_record_tracks_event( 'import_job_start', $properties );
// When we are skipping already imported items, the table of items to import gets smaller in
// every batch, so we want to always import the first page.
$page = $skip_existing ? 1 : $batch_number;
$items = static::get_items( $batch_size, $page, $days, $skip_existing );
foreach ( $items->ids as $id ) {
static::import( $id );
}
$import_stats = get_option( self::IMPORT_STATS_OPTION, array() );
$imported_count = absint( $import_stats[ static::$name ]['imported'] ) + count( $items->ids );
$import_stats[ static::$name ]['imported'] = $imported_count;
update_option( self::IMPORT_STATS_OPTION, $import_stats );
$properties['imported_count'] = $imported_count;
wc_admin_record_tracks_event( 'import_job_complete', $properties );
}
/**
* Queue item deletion in batches.
*
* @internal
*/
public static function delete_batch_init() {
global $wpdb;
$batch_size = static::get_batch_size( 'delete' );
$count = static::get_total_imported();
if ( 0 === $count ) {
return;
}
$num_batches = ceil( $count / $batch_size );
self::queue_batches( 1, $num_batches, 'delete_batch' );
}
/**
* Delete a batch by passing the count to be deleted to the child delete method.
*
* @internal
* @return void
*/
public static function delete_batch() {
wc_admin_record_tracks_event( 'delete_import_data_job_start', array( 'type' => static::$name ) );
$batch_size = static::get_batch_size( 'delete' );
static::delete( $batch_size );
ReportsCache::invalidate();
wc_admin_record_tracks_event( 'delete_import_data_job_complete', array( 'type' => static::$name ) );
}
}
Admin/Schedulers/MailchimpScheduler.php 0000644 00000007160 15233366071 0014162 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
/**
* Class MailchimpScheduler
*
* @package Automattic\WooCommerce\Admin\Schedulers
*/
class MailchimpScheduler {
const SUBSCRIBE_ENDPOINT = 'https://woocommerce.com/wp-json/wccom/v1/subscribe';
const SUBSCRIBE_ENDPOINT_DEV = 'http://woocommerce.test/wp-json/wccom/v1/subscribe';
const SUBSCRIBED_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp';
const SUBSCRIBED_ERROR_COUNT_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp_error_count';
const MAX_ERROR_THRESHOLD = 3;
const LOGGER_CONTEXT = 'mailchimp_scheduler';
/**
* The logger instance.
*
* @var \WC_Logger_Interface|null
*/
private $logger;
/**
* MailchimpScheduler constructor.
*
* @internal
* @param \WC_Logger_Interface|null $logger Logger instance.
*/
public function __construct( \WC_Logger_Interface $logger = null ) {
if ( null === $logger ) {
$logger = wc_get_logger();
}
$this->logger = $logger;
}
/**
* Attempt to subscribe store_email to MailChimp.
*
* @internal
*/
public function run() {
// Abort if we've already subscribed to MailChimp.
if ( 'yes' === get_option( self::SUBSCRIBED_OPTION_NAME ) ) {
return false;
}
$profile_data = get_option( 'woocommerce_onboarding_profile' );
if ( ! isset( $profile_data['is_agree_marketing'] ) || false === $profile_data['is_agree_marketing'] ) {
return false;
}
// Abort if store_email doesn't exist.
if ( ! isset( $profile_data['store_email'] ) ) {
return false;
}
// Abort if failed requests reaches the threshold.
if ( intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) >= self::MAX_ERROR_THRESHOLD ) {
return false;
}
$response = $this->make_request( $profile_data['store_email'] );
if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
$this->handle_request_error();
return false;
}
$body = json_decode( $response['body'] );
if ( isset( $body->success ) && true === $body->success ) {
update_option( self::SUBSCRIBED_OPTION_NAME, 'yes' );
return true;
}
$this->handle_request_error( $body );
return false;
}
/**
* Make an HTTP request to the API.
*
* @internal
* @param string $store_email Email address to subscribe.
*
* @return mixed
*/
public function make_request( $store_email ) {
if ( true === defined( 'WP_ENVIRONMENT_TYPE' ) && 'development' === constant( 'WP_ENVIRONMENT_TYPE' ) ) {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT_DEV;
} else {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT;
}
return wp_remote_post(
$subscribe_endpoint,
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
'method' => 'POST',
'body' => array(
'email' => $store_email,
),
)
);
}
/**
* Reset options.
*
* @internal
*/
public static function reset() {
delete_option( self::SUBSCRIBED_OPTION_NAME );
delete_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME );
}
/**
* Handle subscribe API error.
*
* @internal
* @param string $extra_msg Extra message to log.
*/
private function handle_request_error( $extra_msg = null ) {
// phpcs:ignore
$msg = isset( $extra_msg ) ? 'Incorrect response from Mailchimp API with: ' . print_r( $extra_msg, true ) : 'Error getting a response from Mailchimp API.';
$this->logger->error( $msg, array( 'source' => self::LOGGER_CONTEXT ) );
$accumulated_error_count = intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) + 1;
update_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, $accumulated_error_count );
}
}
Admin/Schedulers/ImportInterface.php 0000644 00000001306 15233366071 0013507 0 ustar 00 <?php
/**
* Import related abstract functions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
interface ImportInterface {
/**
* Get items based on query and return IDs along with total available.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported items.
*/
public static function get_items( $limit, $page, $days, $skip_existing );
/**
* Get total number of items already imported.
*
* @internal
* @return null
*/
public static function get_total_imported();
}
Admin/Schedulers/CustomersScheduler.php 0000644 00000016573 15233366071 0014253 0 ustar 00 <?php
/**
* Customer syncing related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
/**
* CustomersScheduler Class.
*/
class CustomersScheduler extends ImportScheduler {
/**
* Slug to identify the scheduler.
*
* @var string
*/
public static $name = 'customers';
/**
* Attach customer lookup update hooks.
*
* @internal
*/
public static function init() {
add_action( 'woocommerce_new_customer', array( __CLASS__, 'schedule_import' ) );
add_action( 'woocommerce_update_customer', array( __CLASS__, 'schedule_import' ) );
add_action( 'updated_user_meta', array( __CLASS__, 'schedule_import_via_last_active' ), 10, 3 );
add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'schedule_anonymize' ) );
add_action( 'delete_user', array( __CLASS__, 'schedule_user_delete' ) );
add_action( 'remove_user_from_blog', array( __CLASS__, 'schedule_user_delete' ) );
CustomersDataStore::init();
parent::init();
}
/**
* Add customer dependencies.
*
* @internal
* @return array
*/
public static function get_dependencies() {
return array(
'delete_batch_init' => OrdersScheduler::get_action( 'delete_batch_init' ),
'anonymize' => self::get_action( 'import' ),
'delete_user' => self::get_action( 'import' ),
);
}
/**
* Get the customer IDs and total count that need to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported customers.
*/
public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
$customer_roles = apply_filters( 'woocommerce_analytics_import_customer_roles', array( 'customer' ) );
$query_args = array(
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
'number' => $limit,
'paged' => $page,
'role__in' => $customer_roles,
);
if ( is_int( $days ) ) {
$query_args['date_query'] = array(
'after' => gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ),
);
}
if ( $skip_existing ) {
add_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
}
$customer_query = new \WP_User_Query( $query_args );
remove_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
return (object) array(
'total' => $customer_query->get_total(),
'ids' => $customer_query->get_results(),
);
}
/**
* Exclude users that already exist in our customer lookup table.
*
* Meant to be hooked into 'pre_user_query' action.
*
* @internal
* @param WP_User_Query $wp_user_query WP_User_Query to modify.
*/
public static function exclude_existing_customers_from_query( $wp_user_query ) {
global $wpdb;
$wp_user_query->query_where .= " AND NOT EXISTS (
SELECT ID FROM {$wpdb->prefix}wc_customer_lookup
WHERE {$wpdb->prefix}wc_customer_lookup.user_id = {$wpdb->users}.ID
)";
}
/**
* Get total number of rows imported.
*
* @internal
* @return int
*/
public static function get_total_imported() {
global $wpdb;
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
}
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
*
* @internal
* @return array
*/
public static function get_scheduler_actions() {
$actions = parent::get_scheduler_actions();
$actions['anonymize'] = 'wc-admin_anonymize_' . static::$name;
$actions['delete_user'] = 'wc-admin_delete_user_' . static::$name;
return $actions;
}
/**
* Schedule import.
*
* @internal
* @param int $user_id User ID.
* @return void
*/
public static function schedule_import( $user_id ) {
self::schedule_action( 'import', array( $user_id ) );
}
/**
* Schedule an import if the "last active" meta value was changed.
* Function expects to be hooked into the `updated_user_meta` action.
*
* @internal
* @param int $meta_id ID of updated metadata entry.
* @param int $user_id ID of the user being updated.
* @param string $meta_key Meta key being updated.
*/
public static function schedule_import_via_last_active( $meta_id, $user_id, $meta_key ) {
if ( 'wc_last_active' === $meta_key ) {
self::schedule_import( $user_id );
}
}
/**
* Schedule an action to anonymize a single Order.
*
* @internal
* @param WC_Order $order Order object.
* @return void
*/
public static function schedule_anonymize( $order ) {
if ( is_a( $order, 'WC_Order' ) ) {
// Postpone until any pending updates are completed.
self::schedule_action( 'anonymize', array( $order->get_id() ) );
}
}
/**
* Schedule an action to delete a single User.
*
* @internal
* @param int $user_id User ID.
* @return void
*/
public static function schedule_user_delete( $user_id ) {
if ( (int) $user_id > 0 && ! doing_action( 'wp_uninitialize_site' ) ) {
// Postpone until any pending updates are completed.
self::schedule_action( 'delete_user', array( $user_id ) );
}
}
/**
* Imports a single customer.
*
* @internal
* @param int $user_id User ID.
* @return void
*/
public static function import( $user_id ) {
CustomersDataStore::update_registered_customer( $user_id );
}
/**
* Delete a batch of customers.
*
* @internal
* @param int $batch_size Number of items to delete.
* @return void
*/
public static function delete( $batch_size ) {
global $wpdb;
$customer_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT customer_id FROM {$wpdb->prefix}wc_customer_lookup ORDER BY customer_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $customer_ids as $customer_id ) {
CustomersDataStore::delete_customer( $customer_id );
}
}
/**
* Anonymize the customer data for a single order.
*
* @internal
* @param int $order_id Order id.
* @return void
*/
public static function anonymize( $order_id ) {
global $wpdb;
$customer_id = $wpdb->get_var(
$wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id )
);
if ( ! $customer_id ) {
return;
}
// Long form query because $wpdb->update rejects [deleted].
$deleted_text = __( '[deleted]', 'woocommerce' );
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}wc_customer_lookup
SET
user_id = NULL,
username = %s,
first_name = %s,
last_name = %s,
email = %s,
country = '',
postcode = %s,
city = %s,
state = %s
WHERE
customer_id = %d",
array(
$deleted_text,
$deleted_text,
$deleted_text,
'deleted@site.invalid',
$deleted_text,
$deleted_text,
$deleted_text,
$customer_id,
)
)
);
// If the customer row was anonymized, flush the cache.
if ( $updated ) {
ReportsCache::invalidate();
}
}
/**
* Delete the customer data for a single user.
*
* @internal
* @param int $user_id User ID.
* @return void
*/
public static function delete_user( $user_id ) {
CustomersDataStore::delete_customer_by_user_id( $user_id );
}
}
Admin/Loader.php 0000644 00000047441 15233366071 0007533 0 ustar 00 <?php
/**
* Register the scripts, styles, and includes needed for pieces of the WooCommerce Admin experience.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\Admin\Settings;
/**
* Loader Class.
*/
class Loader {
/**
* Class instance.
*
* @var Loader instance
*/
protected static $instance = null;
/**
* An array of classes to load from the includes folder.
*
* @var array
*/
protected static $classes = array();
/**
* WordPress capability required to use analytics features.
*
* @var string
*/
protected static $required_capability = null;
/**
* An array of dependencies that have been preloaded (to avoid duplicates).
*
* @var array
*/
protected $preloaded_dependencies = array(
'script' => array(),
'style' => array(),
);
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
Features::get_instance();
WCAdminSharedSettings::get_instance();
Translations::get_instance();
WCAdminUser::get_instance();
Settings::get_instance();
SystemStatusReport::get_instance();
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );
add_action( 'in_admin_header', array( __CLASS__, 'embed_page_header' ) );
add_action( 'admin_head', array( __CLASS__, 'remove_notices' ) );
add_action( 'admin_head', array( __CLASS__, 'smart_app_banner' ) );
add_action( 'admin_notices', array( __CLASS__, 'inject_before_notices' ), -9999 );
add_action( 'admin_notices', array( __CLASS__, 'inject_after_notices' ), PHP_INT_MAX );
// Added this hook to delete the field woocommerce_onboarding_homepage_post_id when deleting the homepage.
add_action( 'trashed_post', array( __CLASS__, 'delete_homepage' ) );
/*
* Remove the emoji script as it always defaults to replacing emojis with Twemoji images.
* Gutenberg has also disabled emojis. More on that here -> https://github.com/WordPress/gutenberg/pull/6151
*/
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
add_action( 'admin_init', array( __CLASS__, 'deactivate_wc_admin_plugin' ) );
}
/**
* If WooCommerce Admin is installed and activated, it will attempt to deactivate and show a notice.
*/
public static function deactivate_wc_admin_plugin() {
$plugin_path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
if ( is_plugin_active( $plugin_path ) ) {
$path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
deactivate_plugins( $path );
$notice_action = is_network_admin() ? 'network_admin_notices' : 'admin_notices';
add_action(
$notice_action,
function() {
echo '<div class="error"><p>';
printf(
/* translators: %s: is referring to the plugin's name. */
esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ),
'<code>WooCommerce Admin</code>',
'<code>WooCommerce</code>'
);
echo '</p></div>';
}
);
}
}
/**
* Returns breadcrumbs for the current page.
*/
private static function get_embed_breadcrumbs() {
return wc_admin_get_breadcrumbs();
}
/**
* Outputs breadcrumbs via PHP for the initial load of an embedded page.
*
* @param array $section Section to create breadcrumb from.
*/
private static function output_heading( $section ) {
echo esc_html( $section );
}
/**
* Set up a div for the header embed to render into.
* The initial contents here are meant as a place loader for when the PHP page initialy loads.
*/
public static function embed_page_header() {
if ( ! PageController::is_admin_page() && ! PageController::is_embed_page() ) {
return;
}
if ( ! PageController::is_embed_page() ) {
return;
}
$sections = self::get_embed_breadcrumbs();
$sections = is_array( $sections ) ? $sections : array( $sections );
?>
<div id="woocommerce-embedded-root" class="is-embed-loading">
<div class="woocommerce-layout">
<div class="woocommerce-layout__header is-embed-loading">
<h1 class="woocommerce-layout__header-heading">
<?php self::output_heading( end( $sections ) ); ?>
</h1>
</div>
</div>
</div>
<?php
}
/**
* Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
*
* @param string $admin_body_class Body class to add.
*/
public static function add_admin_body_classes( $admin_body_class = '' ) {
if ( ! PageController::is_admin_or_embed_page() ) {
return $admin_body_class;
}
$classes = explode( ' ', trim( $admin_body_class ) );
$classes[] = 'woocommerce-page';
if ( PageController::is_embed_page() ) {
$classes[] = 'woocommerce-embed-page';
}
/**
* Some routes or features like onboarding hide the wp-admin navigation and masterbar.
* Setting `woocommerce_admin_is_loading` to true allows us to premeptively hide these
* elements while the JS app loads.
* This class needs to be removed by those feature components (like <ProfileWizard />).
*
* @param bool $is_loading If WooCommerce Admin is loading a fullscreen view.
*/
$is_loading = apply_filters( 'woocommerce_admin_is_loading', false );
if ( PageController::is_admin_page() && $is_loading ) {
$classes[] = 'woocommerce-admin-is-loading';
}
$admin_body_class = implode( ' ', array_unique( $classes ) );
return " $admin_body_class ";
}
/**
* Adds an iOS "Smart App Banner" for display on iOS Safari.
* See https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
*/
public static function smart_app_banner() {
if ( PageController::is_admin_or_embed_page() ) {
echo "
<meta name='apple-itunes-app' content='app-id=1389130815'>
";
}
}
/**
* Removes notices that should not be displayed on WC Admin pages.
*/
public static function remove_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Hello Dolly.
if ( function_exists( 'hello_dolly' ) ) {
remove_action( 'admin_notices', 'hello_dolly' );
}
}
/**
* Runs before admin notices action and hides them.
*/
public static function inject_before_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// The JITMs won't be shown in the Onboarding Wizard.
$is_onboarding = isset( $_GET['path'] ) && '/setup-wizard' === wc_clean( wp_unslash( $_GET['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$maybe_hide_jitm = $is_onboarding ? '-hide' : '';
echo '<div class="woocommerce-layout__jitm' . sanitize_html_class( $maybe_hide_jitm ) . '" id="jp-admin-notices"></div>';
// Wrap the notices in a hidden div to prevent flickering before
// they are moved elsewhere in the page by WordPress Core.
echo '<div class="woocommerce-layout__notice-list-hide" id="wp__notice-list">';
if ( PageController::is_admin_page() ) {
// Capture all notices and hide them. WordPress Core looks for
// `.wp-header-end` and appends notices after it if found.
// https://github.com/WordPress/WordPress/blob/f6a37e7d39e2534d05b9e542045174498edfe536/wp-admin/js/common.js#L737 .
echo '<div class="wp-header-end" id="woocommerce-layout__notice-catcher"></div>';
}
}
/**
* Runs after admin notices and closes div.
*/
public static function inject_after_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Close the hidden div used to prevent notices from flickering before
// they are inserted elsewhere in the page.
echo '</div>';
}
/**
* Edits Admin title based on section of wc-admin.
*
* @param string $admin_title Modifies admin title.
* @todo Can we do some URL rewriting so we can figure out which page they are on server side?
*/
public static function update_admin_title( $admin_title ) {
if (
! did_action( 'current_screen' ) ||
! PageController::is_admin_page()
) {
return $admin_title;
}
$sections = self::get_embed_breadcrumbs();
$pieces = array();
foreach ( $sections as $section ) {
$pieces[] = is_array( $section ) ? $section[1] : $section;
}
$pieces = array_reverse( $pieces );
$title = implode( ' ‹ ', $pieces );
/* translators: %1$s: updated title, %2$s: blog info name */
return sprintf( __( '%1$s ‹ %2$s', 'woocommerce' ), $title, get_bloginfo( 'name' ) );
}
/**
* Set up a div for the app to render into.
*/
public static function page_wrapper() {
?>
<div class="wrap">
<div id="root"></div>
</div>
<?php
}
/**
* Hooks extra neccessary data into the component settings array already set in WooCommerce core.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public static function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
if ( ! function_exists( 'wc_blocks_container' ) ) {
global $wp_locale;
// inject data not available via older versions of wc_blocks/woo.
$settings['orderStatuses'] = self::get_order_statuses( wc_get_order_statuses() );
$settings['stockStatuses'] = self::get_order_statuses( wc_get_product_stock_status_options() );
$settings['currency'] = self::get_currency_settings();
$settings['locale'] = [
'siteLocale' => isset( $settings['siteLocale'] )
? $settings['siteLocale']
: get_locale(),
'userLocale' => isset( $settings['l10n']['userLocale'] )
? $settings['l10n']['userLocale']
: get_user_locale(),
'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
? $settings['l10n']['weekdaysShort']
: array_values( $wp_locale->weekday_abbrev ),
];
}
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
'rest_preload_api_request'
);
}
$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
if ( ! empty( $preload_options ) ) {
foreach ( $preload_options as $option ) {
$settings['preloadOptions'][ $option ] = get_option( $option );
}
}
$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
if ( ! empty( $preload_settings ) ) {
$setting_options = new \WC_REST_Setting_Options_V2_Controller();
foreach ( $preload_settings as $group ) {
$group_settings = $setting_options->get_group_settings( $group );
$preload_settings = [];
foreach ( $group_settings as $option ) {
if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
$preload_settings[ $option['id'] ] = $option['value'];
}
}
$settings['preloadSettings'][ $group ] = $preload_settings;
}
}
$user_controller = new \WP_REST_Users_Controller();
$request = new \WP_REST_Request();
$request->set_query_params( array( 'context' => 'edit' ) );
$user_response = $user_controller->get_current_item( $request );
$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
$settings['currentUserData'] = $current_user_data;
$settings['reviewsEnabled'] = get_option( 'woocommerce_enable_reviews' );
$settings['manageStock'] = get_option( 'woocommerce_manage_stock' );
$settings['commentModeration'] = get_option( 'comment_moderation' );
$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
// @todo On merge, once plugin images are added to core WooCommerce, `wcAdminAssetUrl` can be retired,
// and `wcAssetUrl` can be used in its place throughout the codebase.
$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
$settings['wcVersion'] = WC_VERSION;
$settings['siteUrl'] = site_url();
$settings['shopUrl'] = get_permalink( wc_get_page_id( 'shop' ) );
$settings['homeUrl'] = home_url();
$settings['dateFormat'] = get_option( 'date_format' );
$settings['timeZone'] = wc_timezone_string();
$settings['plugins'] = array(
'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
'activePlugins' => Plugins::get_active_plugins(),
);
// Plugins that depend on changing the translation work on the server but not the client -
// WooCommerce Branding is an example of this - so pass through the translation of
// 'WooCommerce' to wcSettings.
$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = self::get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
? $settings['dataEndpoints']
: [];
foreach ( $preload_data_endpoints as $key => $endpoint ) {
// Handle error case: rest_do_request() doesn't guarantee success.
if ( empty( $preload_data[ $endpoint ] ) ) {
$settings['dataEndpoints'][ $key ] = array();
} else {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
}
}
$settings = self::get_custom_settings( $settings );
if ( PageController::is_embed_page() ) {
$settings['embedBreadcrumbs'] = self::get_embed_breadcrumbs();
}
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
return $settings;
}
/**
* Format order statuses by removing a leading 'wc-' if present.
*
* @param array $statuses Order statuses.
* @return array formatted statuses.
*/
public static function get_order_statuses( $statuses ) {
$formatted_statuses = array();
foreach ( $statuses as $key => $value ) {
$formatted_key = preg_replace( '/^wc-/', '', $key );
$formatted_statuses[ $formatted_key ] = $value;
}
return $formatted_statuses;
}
/**
* Get all order statuses present in analytics tables that aren't registered.
*
* @return array Unregistered order statuses.
*/
public static function get_unregistered_order_statuses() {
$registered_statuses = wc_get_order_statuses();
$all_synced_statuses = OrdersDataStore::get_all_statuses();
$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
$formatted_status_keys = self::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
$formatted_statuses = array_keys( $formatted_status_keys );
return array_combine( $formatted_statuses, $formatted_statuses );
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*/
public static function add_settings_group( $groups ) {
$groups[] = array(
'id' => 'wc_admin',
'label' => __( 'WooCommerce Admin', 'woocommerce' ),
'description' => __( 'Settings for WooCommerce admin reporting.', 'woocommerce' ),
);
return $groups;
}
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*/
public static function add_settings( $settings ) {
$unregistered_statuses = self::get_unregistered_order_statuses();
$registered_statuses = self::get_order_statuses( wc_get_order_statuses() );
$all_statuses = array_merge( $unregistered_statuses, $registered_statuses );
$settings[] = array(
'id' => 'woocommerce_excluded_report_order_statuses',
'option_key' => 'woocommerce_excluded_report_order_statuses',
'label' => __( 'Excluded report order statuses', 'woocommerce' ),
'description' => __( 'Statuses that should not be included when calculating report totals.', 'woocommerce' ),
'default' => array( 'pending', 'cancelled', 'failed' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_actionable_order_statuses',
'option_key' => 'woocommerce_actionable_order_statuses',
'label' => __( 'Actionable order statuses', 'woocommerce' ),
'description' => __( 'Statuses that require extra action on behalf of the store admin.', 'woocommerce' ),
'default' => array( 'processing', 'on-hold' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_default_date_range',
'option_key' => 'woocommerce_default_date_range',
'label' => __( 'Default Date Range', 'woocommerce' ),
'description' => __( 'Default Date Range', 'woocommerce' ),
'default' => 'period=month&compare=previous_year',
'type' => 'text',
);
return $settings;
}
/**
* Gets custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*/
public static function get_custom_settings( $settings ) {
$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
if ( ! empty( $setting['id'] ) ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
}
return $settings;
}
/**
* Return an object defining the currecy options for the site's current currency
*
* @return array Settings for the current currency {
* Array of settings.
*
* @type string $code Currency code.
* @type string $precision Number of decimals.
* @type string $symbol Symbol for currency.
* }
*/
public static function get_currency_settings() {
$code = get_woocommerce_currency();
return apply_filters(
'wc_currency_settings',
array(
'code' => $code,
'precision' => wc_get_price_decimals(),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
'symbolPosition' => get_option( 'woocommerce_currency_pos' ),
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'priceFormat' => html_entity_decode( get_woocommerce_price_format() ),
)
);
}
/**
* Delete woocommerce_onboarding_homepage_post_id field when the homepage is deleted
*
* @param int $post_id The deleted post id.
*/
public static function delete_homepage( $post_id ) {
if ( 'page' !== get_post_type( $post_id ) ) {
return;
}
$homepage_id = intval( get_option( 'woocommerce_onboarding_homepage_post_id', false ) );
if ( $homepage_id === $post_id ) {
delete_option( 'woocommerce_onboarding_homepage_post_id' );
}
}
}
Admin/RemoteFreeExtensions/EvaluateExtension.php 0000644 00000002262 15233366071 0016075 0 ustar 00 <?php
/**
* Evaluates the spec and returns a status.
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RuleEvaluator;
/**
* Evaluates the extension and returns it.
*/
class EvaluateExtension {
/**
* Evaluates the extension and returns it.
*
* @param object $extension The extension to evaluate.
* @return object The evaluated extension.
*/
public static function evaluate( $extension ) {
$rule_evaluator = new RuleEvaluator();
if ( isset( $extension->is_visible ) ) {
$is_visible = $rule_evaluator->evaluate( $extension->is_visible );
$extension->is_visible = $is_visible;
} else {
$extension->is_visible = true;
}
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
$activated_plugins = PluginsHelper::get_active_plugin_slugs();
$extension->is_installed = in_array( explode( ':', $extension->key )[0], $installed_plugins, true );
$extension->is_activated = in_array( explode( ':', $extension->key )[0], $activated_plugins, true );
return $extension;
}
}
Admin/RemoteFreeExtensions/RemoteFreeExtensionsDataSourcePoller.php 0000644 00000001371 15233366071 0021700 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
/**
* Specs data source poller class for remote free extensions.
*/
class RemoteFreeExtensionsDataSourcePoller extends \Automattic\WooCommerce\Admin\DataSourcePoller {
const ID = 'remote_free_extensions';
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/obw-free-extensions/3.0/extensions.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self(
self::ID,
self::DATA_SOURCES,
array(
'spec_key' => 'key',
)
);
}
return self::$instance;
}
}
Admin/RemoteFreeExtensions/Init.php 0000644 00000004004 15233366071 0013331 0 ustar 00 <?php
/**
* Handles running payment method specs
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\DefaultFreeExtensions;
/**
* Remote Payment Methods engine.
* This goes through the specs and gets eligible payment methods.
*/
class Init {
/**
* Constructor.
*/
public function __construct() {
add_action( 'woocommerce_updated', array( __CLASS__, 'delete_specs_transient' ) );
}
/**
* Go through the specs and run them.
*
* @param array $allowed_bundles Optional array of allowed bundles to be returned.
* @return array
*/
public static function get_extensions( $allowed_bundles = array() ) {
$bundles = array();
$specs = self::get_specs();
foreach ( $specs as $spec ) {
$spec = (object) $spec;
$bundle = (array) $spec;
$bundle['plugins'] = array();
if ( ! empty( $allowed_bundles ) && ! in_array( $spec->key, $allowed_bundles, true ) ) {
continue;
}
foreach ( $spec->plugins as $plugin ) {
$extension = EvaluateExtension::evaluate( (object) $plugin );
if ( ! property_exists( $extension, 'is_visible' ) || $extension->is_visible ) {
$bundle['plugins'][] = $extension;
}
}
$bundles[] = $bundle;
}
return $bundles;
}
/**
* Delete the specs transient.
*/
public static function delete_specs_transient() {
RemoteFreeExtensionsDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return DefaultFreeExtensions::get_all();
}
$specs = RemoteFreeExtensionsDataSourcePoller::get_instance()->get_specs_from_data_sources();
// Fetch specs if they don't yet exist.
if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) {
return DefaultFreeExtensions::get_all();
}
return $specs;
}
}
Admin/RemoteFreeExtensions/DefaultFreeExtensions.php 0000644 00000050440 15233366071 0016701 0 ustar 00 <?php
/**
* Gets a list of fallback methods if remote fetching is disabled.
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;
defined( 'ABSPATH' ) || exit;
/**
* Default Free Extensions
*/
class DefaultFreeExtensions {
/**
* Get default specs.
*
* @return array Default specs.
*/
public static function get_all() {
$bundles = [
[
'key' => 'obw/basics',
'title' => __( 'Get the basics', 'woocommerce' ),
'plugins' => [
self::get_plugin( 'woocommerce-payments' ),
self::get_plugin( 'woocommerce-services:shipping' ),
self::get_plugin( 'woocommerce-services:tax' ),
self::get_plugin( 'jetpack' ),
],
],
[
'key' => 'obw/grow',
'title' => __( 'Grow your store', 'woocommerce' ),
'plugins' => [
self::get_plugin( 'mailpoet' ),
self::get_plugin( 'google-listings-and-ads' ),
self::get_plugin( 'facebook-for-woocommerce' ),
self::get_plugin( 'tiktok-for-business:alt' ),
],
],
[
'key' => 'task-list/reach',
'title' => __( 'Reach out to customers', 'woocommerce' ),
'plugins' => [
self::get_plugin( 'mailpoet:alt' ),
self::get_plugin( 'mailchimp-for-woocommerce' ),
self::get_plugin( 'creative-mail-by-constant-contact' ),
],
],
[
'key' => 'task-list/grow',
'title' => __( 'Grow your store', 'woocommerce' ),
'plugins' => [
self::get_plugin( 'google-listings-and-ads:alt' ),
self::get_plugin( 'tiktok-for-business' ),
self::get_plugin( 'facebook-for-woocommerce:alt' ),
self::get_plugin( 'pinterest-for-woocommerce' ),
],
],
];
$bundles = wp_json_encode( $bundles );
return json_decode( $bundles );
}
/**
* Get the plugin arguments by slug.
*
* @param string $slug Slug.
* @return array
*/
public static function get_plugin( $slug ) {
$plugins = array(
'google-listings-and-ads' => [
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Drive sales with %1$sGoogle Listings and Ads%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/google-listings-and-ads" target="_blank">',
'</a>'
),
'image_url' => plugins_url( '/assets/images/onboarding/google-listings-and-ads.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
'is_visible' => [
[
'type' => 'not',
'operand' => [
[
'type' => 'plugins_activated',
'plugins' => [ 'google-listings-and-ads' ],
],
],
],
],
],
'google-listings-and-ads:alt' => [
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => __( 'Reach more shoppers and drive sales for your store. Integrate with Google to list your products for free and launch paid ad campaigns.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/google-listings-and-ads.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
],
'facebook-for-woocommerce' => [
'name' => __( 'Facebook for WooCommerce', 'woocommerce' ),
'description' => __( 'List products and create ads on Facebook and Instagram with <a href="https://woocommerce.com/products/facebook/">Facebook for WooCommerce</a>', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-facebook',
'is_built_by_wc' => false,
],
'facebook-for-woocommerce:alt' => [
'name' => __( 'Facebook for WooCommerce', 'woocommerce' ),
'description' => __( 'List products and create ads on Facebook and Instagram.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-facebook',
'is_built_by_wc' => false,
],
'pinterest-for-woocommerce' => [
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
'description' => __( 'Get your products in front of Pinterest users searching for ideas and things to buy. Get started with Pinterest and make your entire product catalog browsable.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
'is_built_by_wc' => true,
],
'mailpoet' => [
'name' => __( 'MailPoet', 'woocommerce' ),
'description' => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_built_by_wc' => true,
],
'mailchimp-for-woocommerce' => [
'name' => __( 'Mailchimp', 'woocommerce' ),
'description' => __( 'Send targeted campaigns, recover abandoned carts and much more with Mailchimp.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailchimp-for-woocommerce.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailchimp-woocommerce',
'is_built_by_wc' => false,
],
'creative-mail-by-constant-contact' => [
'name' => __( 'Creative Mail for WooCommerce', 'woocommerce' ),
'description' => __( 'Create on-brand store campaigns, fast email promotions and customer retargeting with Creative Mail.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/creative-mail-by-constant-contact.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=creativemail',
'is_built_by_wc' => false,
],
'woocommerce-payments' => [
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Accept credit cards and other popular payment methods with %1$sWooCommerce Payments%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/woocommerce-payments" target="_blank">',
'</a>'
),
'is_visible' => [
[
'type' => 'or',
'operands' => [
[
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'ES',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'IE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'IT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'NZ',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'NL',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PL',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CH',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'HK',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'SG',
'operation' => '=',
],
],
],
DefaultPaymentGateways::get_rules_for_cbd( false ),
],
'is_built_by_wc' => true,
],
'woocommerce-services:shipping' => [
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Print shipping labels with %1$sWooCommerce Shipping%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/shipping" target="_blank">',
'</a>'
),
'is_visible' => [
[
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
],
[
'type' => 'not',
'operand' => [
[
'type' => 'plugins_activated',
'plugins' => [ 'woocommerce-services' ],
],
],
],
[
'type' => 'or',
'operands' => [
[
[
'type' => 'option',
'transformers' => [
[
'use' => 'dot_notation',
'arguments' => [
'path' => 'product_types',
],
],
[
'use' => 'count',
],
],
'option_name' => 'woocommerce_onboarding_profile',
'value' => 1,
'default' => array(),
'operation' => '!=',
],
],
[
[
'type' => 'option',
'transformers' => [
[
'use' => 'dot_notation',
'arguments' => [
'path' => 'product_types.0',
],
],
],
'option_name' => 'woocommerce_onboarding_profile',
'value' => 'downloads',
'default' => '',
'operation' => '!=',
],
],
],
],
],
'is_built_by_wc' => true,
],
'woocommerce-services:tax' => [
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Get automated sales tax with %1$sWooCommerce Tax%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/tax" target="_blank">',
'</a>'
),
'is_visible' => [
[
'type' => 'or',
'operands' => [
[
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'GR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'DK',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'SE',
'operation' => '=',
],
],
],
[
'type' => 'not',
'operand' => [
[
'type' => 'plugins_activated',
'plugins' => [ 'woocommerce-services' ],
],
],
],
],
'is_built_by_wc' => true,
],
'jetpack' => [
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Enhance speed and security with %1$sJetpack%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/jetpack" target="_blank">',
'</a>'
),
'is_visible' => [
[
'type' => 'not',
'operand' => [
[
'type' => 'plugins_activated',
'plugins' => [ 'jetpack' ],
],
],
],
],
'is_built_by_wc' => false,
],
'mailpoet' => [
'name' => __( 'MailPoet', 'woocommerce' ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Level up your email marketing with %1$sMailPoet%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/mailpoet" target="_blank">',
'</a>'
),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_visible' => [
[
'type' => 'not',
'operand' => [
[
'type' => 'plugins_activated',
'plugins' => [ 'mailpoet' ],
],
],
],
],
'is_built_by_wc' => true,
],
'mailpoet:alt' => [
'name' => __( 'MailPoet', 'woocommerce' ),
'description' => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_built_by_wc' => true,
],
'tiktok-for-business' => [
'name' => __( 'TikTok for WooCommerce', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
'description' =>
__( 'Grow your online sales by promoting your products on TikTok to over one billion monthly active users around the world.', 'woocommerce' ),
'manage_url' => 'admin.php?page=tiktok',
'is_visible' => [
[
'type' => 'or',
'operands' => [
[
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'MX',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CZ',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'DK',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'FI',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'GR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'HU',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'IE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'IT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'NL',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PL',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'RO',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'ES',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'SE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'CH',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'NO',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'NZ',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'SG',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'MY',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'PH',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'ID',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'VN',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'TH',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'KR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'IL',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'AE',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'RU',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'UA',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'TR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'SA',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'BR',
'operation' => '=',
],
[
'type' => 'base_location_country',
'value' => 'JP',
'operation' => '=',
],
],
],
],
'is_built_by_wc' => false,
],
'tiktok-for-business:alt' => [
'name' => __( 'TikTok for WooCommerce', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Create ad campaigns and reach one billion global users with %1$sTikTok for WooCommerce%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/tiktok-for-woocommerce" target="_blank">',
'</a>'
),
'manage_url' => 'admin.php?page=tiktok',
'is_built_by_wc' => false,
],
);
$plugin = $plugins[ $slug ];
$plugin['key'] = $slug;
return $plugin;
}
}
Admin/ProductReviews/ReviewsCommentsOverrides.php 0000644 00000010201 15233366071 0016307 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WP_Comment_Query;
/**
* Tweaks the WordPress comments page to exclude reviews.
*/
class ReviewsCommentsOverrides {
use AccessiblePrivateMethods;
const REVIEWS_MOVED_NOTICE_ID = 'product_reviews_moved';
/**
* Constructor.
*/
public function __construct() {
self::add_action( 'admin_notices', array( $this, 'display_notices' ) );
self::add_filter( 'woocommerce_dismiss_admin_notice_capability', array( $this, 'get_dismiss_capability' ), 10, 2 );
self::add_filter( 'comments_list_table_query_args', array( $this, 'exclude_reviews_from_comments' ) );
}
/**
* Renders admin notices.
*/
protected function display_notices() : void {
$screen = get_current_screen();
if ( empty( $screen ) || $screen->base !== 'edit-comments' ) {
return;
}
$this->maybe_display_reviews_moved_notice();
}
/**
* May render an admin notice informing the user that reviews were moved to a new page.
*
* @return void
*/
protected function maybe_display_reviews_moved_notice() : void {
if ( $this->should_display_reviews_moved_notice() ) {
$this->display_reviews_moved_notice();
}
}
/**
* Checks if the admin notice informing the user that reviews were moved to a new page should be displayed.
*
* @return bool
*/
protected function should_display_reviews_moved_notice() : bool {
// Do not display if the user does not have the capability to see the new page.
if ( ! WC()->call_function( 'current_user_can', Reviews::get_capability() ) ) {
return false;
}
// Do not display if the current user has dismissed this notice.
if ( WC()->call_function( 'get_user_meta', get_current_user_id(), 'dismissed_' . static::REVIEWS_MOVED_NOTICE_ID . '_notice', true ) ) {
return false;
}
return true;
}
/**
* Renders an admin notice informing the user that reviews were moved to a new page.
*
* @return void
*/
protected function display_reviews_moved_notice() : void {
$dismiss_url = wp_nonce_url(
add_query_arg(
[
'wc-hide-notice' => urlencode( static::REVIEWS_MOVED_NOTICE_ID ),
]
),
'woocommerce_hide_notices_nonce',
'_wc_notice_nonce'
);
?>
<div class="notice notice-info is-dismissible">
<p><strong><?php esc_html_e( 'Product reviews have moved!', 'woocommerce' ); ?></strong></p>
<p><?php esc_html_e( 'Product reviews can now be managed from Products > Reviews.', 'woocommerce' ); ?></p>
<p class="submit">
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=product&page=product-reviews' ) ); ?>" class="button-primary"><?php esc_html_e( 'Visit new location', 'woocommerce' ); ?></a>
</p>
<button type="button" class="notice-dismiss" onclick="window.location = '<?php echo esc_url( $dismiss_url ); ?>';"><span class="screen-reader-text"><?php esc_html_e( 'Dismiss this notice.', 'woocommerce' ); ?></span></button>
</div>
<?php
}
/**
* Gets the capability required to dismiss the notice.
*
* This is required so that users who do not have the manage_woocommerce capability (e.g. Editors) can still dismiss
* the notice displayed in the Comments page.
*
* @param string|mixed $default_capability The default required capability.
* @param string|mixed $notice_name The notice name.
* @return string
*/
protected function get_dismiss_capability( $default_capability, $notice_name ) {
return $notice_name === self::REVIEWS_MOVED_NOTICE_ID ? Reviews::get_capability() : $default_capability;
}
/**
* Excludes product reviews from showing in the comments page.
*
* @param array|mixed $args {@see WP_Comment_Query} query args.
* @return array
*/
protected function exclude_reviews_from_comments( $args ) : array {
if ( ! empty( $args['post_type'] ) && $args['post_type'] !== 'any' ) {
$post_types = (array) $args['post_type'];
} else {
$post_types = get_post_types();
}
$index = array_search( 'product', $post_types );
if ( $index !== false ) {
unset( $post_types[ $index ] );
}
if ( ! is_array( $args ) ) {
$args = [];
}
$args['post_type'] = $post_types;
return $args;
}
}
Admin/ProductReviews/Reviews.php 0000644 00000052100 15233366071 0012722 0 ustar 00 <?php
/**
* Products > Reviews
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WP_Ajax_Response;
use WP_Comment;
use WP_Screen;
/**
* Handles backend logic for the Reviews component.
*/
class Reviews {
use AccessiblePrivateMethods;
/**
* Admin page identifier.
*/
const MENU_SLUG = 'product-reviews';
/**
* Reviews page hook name.
*
* @var string|null
*/
protected $reviews_page_hook = null;
/**
* Reviews list table instance.
*
* @var ReviewsListTable|null
*/
protected $reviews_list_table;
/**
* Constructor.
*/
public function __construct() {
self::add_action( 'admin_menu', [ $this, 'add_reviews_page' ] );
self::add_action( 'admin_enqueue_scripts', [ $this, 'load_javascript' ] );
// These ajax callbacks need a low priority to ensure they run before their WordPress core counterparts.
self::add_action( 'wp_ajax_edit-comment', [ $this, 'handle_edit_review' ], -1 );
self::add_action( 'wp_ajax_replyto-comment', [ $this, 'handle_reply_to_review' ], -1 );
self::add_filter( 'parent_file', [ $this, 'edit_review_parent_file' ] );
self::add_filter( 'gettext', [ $this, 'edit_comments_screen_text' ], 10, 2 );
self::add_action( 'admin_notices', [ $this, 'display_notices' ] );
}
/**
* Gets the required capability to access the reviews page and manage product reviews.
*
* @param string $context The context for which the capability is needed (e.g. `view` or `moderate`).
* @return string
*/
public static function get_capability( string $context = 'view' ) : string {
/**
* Filters whether the current user can manage product reviews.
*
* This is aligned to {@see \wc_rest_check_product_reviews_permissions()}
*
* @since 6.7.0
*
* @param string $capability The capability (defaults to `moderate_comments` for viewing and `edit_products` for editing).
* @param string $context The context for which the capability is needed.
*/
return (string) apply_filters( 'woocommerce_product_reviews_page_capability', $context === 'view' ? 'moderate_comments' : 'edit_products', $context );
}
/**
* Registers the Product Reviews submenu page.
*
* @return void
*/
private function add_reviews_page() : void {
$this->reviews_page_hook = add_submenu_page(
'edit.php?post_type=product',
__( 'Reviews', 'woocommerce' ),
__( 'Reviews', 'woocommerce' ) . $this->get_pending_count_bubble(),
static::get_capability(),
static::MENU_SLUG,
[ $this, 'render_reviews_list_table' ]
);
self::add_action( "load-{$this->reviews_page_hook}", array( $this, 'load_reviews_screen' ) );
}
/**
* Retrieves the URL to the product reviews page.
*
* @return string
*/
public static function get_reviews_page_url() : string {
return add_query_arg(
[
'post_type' => 'product',
'page' => static::MENU_SLUG,
],
admin_url( 'edit.php' )
);
}
/**
* Determines whether the current page is the reviews page.
*
* @global WP_Screen $current_screen
*
* @return bool
*/
public function is_reviews_page() : bool {
global $current_screen;
return isset( $current_screen->base ) && $current_screen->base === 'product_page_' . static::MENU_SLUG;
}
/**
* Loads the JavaScript required for inline replies and quick edit.
*
* @return void
*/
private function load_javascript() : void {
if ( $this->is_reviews_page() ) {
wp_enqueue_script( 'admin-comments' );
enqueue_comment_hotkeys_js();
}
}
/**
* Determines if the object is a review or a reply to a review.
*
* @param WP_Comment|mixed $object Object to check.
* @return bool
*/
protected function is_review_or_reply( $object ) : bool {
$is_review_or_reply = $object instanceof WP_Comment && in_array( $object->comment_type, [ 'review', 'comment' ], true ) && get_post_type( $object->comment_post_ID ) === 'product';
/**
* Filters whether the object is a review or a reply to a review.
*
* @since 6.7.0
*
* @param bool $is_review_or_reply Whether the object in context is a review or a reply to a review.
* @param WP_Comment|mixed $object The object in context.
*/
return (bool) apply_filters( 'woocommerce_product_reviews_is_product_review_or_reply', $is_review_or_reply, $object );
}
/**
* Ajax callback for editing a review.
*
* This functionality is taken from {@see wp_ajax_edit_comment()} and is largely copy and pasted. The only thing
* we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
* to use our own {@see ReviewsListTable} class to support our custom columns.
*
* This ajax callback is registered with a lower priority than WordPress core's so that our code can run
* first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
* to allow the WordPress core callback to take over.
*
* @return void
*/
private function handle_edit_review(): void {
check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );
$comment_id = isset( $_POST['comment_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_ID'] ) ) : 0;
if ( empty( $comment_id ) || ! current_user_can( 'edit_comment', $comment_id ) ) {
wp_die( -1 );
}
$review = get_comment( $comment_id );
// Bail silently if this is not a review, or a reply to a review. That allows `wp_ajax_edit_comment()` to handle any further actions.
if ( ! $this->is_review_or_reply( $review ) ) {
return;
}
if ( empty( $review->comment_ID ) ) {
wp_die( -1 );
}
if ( empty( $_POST['content'] ) ) {
wp_die( esc_html__( 'Error: Please type your review text.', 'woocommerce' ) );
}
if ( isset( $_POST['status'] ) ) {
$_POST['comment_status'] = sanitize_text_field( wp_unslash( $_POST['status'] ) );
}
$updated = edit_comment();
if ( is_wp_error( $updated ) ) {
wp_die( esc_html( $updated->get_error_message() ) );
}
$position = isset( $_POST['position'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['position'] ) ) : -1;
$wp_list_table = $this->make_reviews_list_table();
ob_start();
$wp_list_table->single_row( $review );
$review_list_item = ob_get_clean();
$x = new WP_Ajax_Response();
$x->add(
array(
'what' => 'edit_comment',
'id' => $review->comment_ID,
'data' => $review_list_item,
'position' => $position,
)
);
$x->send();
}
/**
* Ajax callback for replying to a review inline.
*
* This functionality is taken from {@see wp_ajax_replyto_comment()} and is largely copy and pasted. The only thing
* we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
* to use our own {@see ReviewsListTable} class to support our custom columns.
*
* This ajax callback is registered with a lower priority than WordPress core's so that our code can run
* first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
* to allow the WordPress core callback to take over.
*
* @return void
*/
private function handle_reply_to_review() : void {
check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );
$comment_post_ID = isset( $_POST['comment_post_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_post_ID'] ) ) : 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
$post = get_post( $comment_post_ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
if ( ! $post ) {
wp_die( -1 );
}
// Inline Review replies will use the `detail` mode. If that's not what we have, then let WordPress core take over.
if ( isset( $_REQUEST['mode'] ) && $_REQUEST['mode'] === 'dashboard' ) {
return;
}
// If this is not a a reply to a review, bail silently to let WordPress core take over.
if ( get_post_type( $post ) !== 'product' ) {
return;
}
if ( ! current_user_can( 'edit_post', $comment_post_ID ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
wp_die( -1 );
}
if ( empty( $post->post_status ) ) {
wp_die( 1 );
} elseif ( in_array( $post->post_status, array( 'draft', 'pending', 'trash' ), true ) ) {
wp_die( esc_html__( 'Error: You can\'t reply to a review on a draft product.', 'woocommerce' ) );
}
$user = wp_get_current_user();
if ( $user->exists() ) {
$user_ID = $user->ID;
$comment_author = wp_slash( $user->display_name );
$comment_author_email = wp_slash( $user->user_email );
$comment_author_url = wp_slash( $user->user_url );
// WordPress core already sanitizes `content` during the `pre_comment_content` hook, which is why it's not needed here, {@see wp_filter_comment()} and {@see kses_init_filters()}.
$comment_content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$comment_type = isset( $_POST['comment_type'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_type'] ) ) : 'comment';
if ( current_user_can( 'unfiltered_html' ) ) {
if ( ! isset( $_POST['_wp_unfiltered_html_comment'] ) ) {
$_POST['_wp_unfiltered_html_comment'] = '';
}
if ( wp_create_nonce( 'unfiltered-html-comment' ) != $_POST['_wp_unfiltered_html_comment'] ) {
kses_remove_filters(); // Start with a clean slate.
kses_init_filters(); // Set up the filters.
remove_filter( 'pre_comment_content', 'wp_filter_post_kses' );
add_filter( 'pre_comment_content', 'wp_filter_kses' );
}
}
} else {
wp_die( esc_html__( 'Sorry, you must be logged in to reply to a review.', 'woocommerce' ) );
}
if ( $comment_content === '' ) {
wp_die( esc_html__( 'Error: Please type your reply text.', 'woocommerce' ) );
}
$comment_parent = 0;
if ( isset( $_POST['comment_ID'] ) ) {
$comment_parent = absint( wp_unslash( $_POST['comment_ID'] ) );
}
$comment_auto_approved = false;
$commentdata = compact( 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_type', 'comment_parent', 'user_ID' );
// Automatically approve parent comment.
if ( ! empty( $_POST['approve_parent'] ) ) {
$parent = get_comment( $comment_parent );
if ( $parent && $parent->comment_approved === '0' && $parent->comment_post_ID === $comment_post_ID ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
if ( ! current_user_can( 'edit_comment', $parent->comment_ID ) ) {
wp_die( -1 );
}
if ( wp_set_comment_status( $parent, 'approve' ) ) {
$comment_auto_approved = true;
}
}
}
$comment_id = wp_new_comment( $commentdata );
if ( is_wp_error( $comment_id ) ) {
wp_die( esc_html( $comment_id->get_error_message() ) );
}
$comment = get_comment( $comment_id );
if ( ! $comment ) {
wp_die( 1 );
}
$position = ( isset( $_POST['position'] ) && (int) $_POST['position'] ) ? (int) $_POST['position'] : '-1';
ob_start();
$wp_list_table = $this->make_reviews_list_table();
$wp_list_table->single_row( $comment );
$comment_list_item = ob_get_clean();
$response = array(
'what' => 'comment',
'id' => $comment->comment_ID,
'data' => $comment_list_item,
'position' => $position,
);
$counts = wp_count_comments();
$response['supplemental'] = array(
'in_moderation' => $counts->moderated,
'i18n_comments_text' => sprintf(
/* translators: %s: Number of reviews. */
_n( '%s Review', '%s Reviews', $counts->approved, 'woocommerce' ),
number_format_i18n( $counts->approved )
),
'i18n_moderation_text' => sprintf(
/* translators: %s: Number of reviews. */
_n( '%s Review in moderation', '%s Reviews in moderation', $counts->moderated, 'woocommerce' ),
number_format_i18n( $counts->moderated )
),
);
if ( $comment_auto_approved && isset( $parent ) ) {
$response['supplemental']['parent_approved'] = $parent->comment_ID;
$response['supplemental']['parent_post_id'] = $parent->comment_post_ID;
}
$x = new WP_Ajax_Response();
$x->add( $response );
$x->send();
}
/**
* Displays notices on the Reviews page.
*
* @return void
*/
protected function display_notices() : void {
if ( $this->is_reviews_page() ) {
$this->maybe_display_reviews_bulk_action_notice();
}
}
/**
* May display the bulk action admin notice.
*
* @return void
*/
protected function maybe_display_reviews_bulk_action_notice() : void {
$messages = $this->get_bulk_action_notice_messages();
echo ! empty( $messages ) ? '<div id="moderated" class="updated"><p>' . implode( "<br/>\n", $messages ) . '</p></div>' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the applicable bulk action admin notice messages.
*
* @return array
*/
protected function get_bulk_action_notice_messages() : array {
$approved = isset( $_REQUEST['approved'] ) ? (int) $_REQUEST['approved'] : 0;
$unapproved = isset( $_REQUEST['unapproved'] ) ? (int) $_REQUEST['unapproved'] : 0;
$deleted = isset( $_REQUEST['deleted'] ) ? (int) $_REQUEST['deleted'] : 0;
$trashed = isset( $_REQUEST['trashed'] ) ? (int) $_REQUEST['trashed'] : 0;
$untrashed = isset( $_REQUEST['untrashed'] ) ? (int) $_REQUEST['untrashed'] : 0;
$spammed = isset( $_REQUEST['spammed'] ) ? (int) $_REQUEST['spammed'] : 0;
$unspammed = isset( $_REQUEST['unspammed'] ) ? (int) $_REQUEST['unspammed'] : 0;
$messages = [];
if ( $approved > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review approved', '%s reviews approved', $approved, 'woocommerce' ), $approved );
}
if ( $unapproved > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review unapproved', '%s reviews unapproved', $unapproved, 'woocommerce' ), $unapproved );
}
if ( $spammed > 0 ) {
$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review marked as spam.', '%s reviews marked as spam.', $spammed, 'woocommerce' ), $spammed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=unspam&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
}
if ( $unspammed > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review restored from the spam', '%s reviews restored from the spam', $unspammed, 'woocommerce' ), $unspammed );
}
if ( $trashed > 0 ) {
$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review moved to the Trash.', '%s reviews moved to the Trash.', $trashed, 'woocommerce' ), $trashed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=untrash&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
}
if ( $untrashed > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review restored from the Trash', '%s reviews restored from the Trash', $untrashed, 'woocommerce' ), $untrashed );
}
if ( $deleted > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review permanently deleted', '%s reviews permanently deleted', $deleted, 'woocommerce' ), $deleted );
}
return $messages;
}
/**
* Counts the number of pending product reviews/replies, and returns the notification bubble if there's more than zero.
*
* @return string Empty string if there are no pending reviews, or bubble HTML if there are.
*/
protected function get_pending_count_bubble() : string {
$count = (int) get_comments(
[
'type__in' => [ 'review', 'comment' ],
'status' => '0',
'post_type' => 'product',
'count' => true,
]
);
/**
* Provides an opportunity to alter the pending comment count used within
* the product reviews admin list table.
*
* @since 7.0.0
*
* @param array $count Current count of comments pending review.
*/
$count = apply_filters( 'woocommerce_product_reviews_pending_count', $count );
if ( empty( $count ) ) {
return '';
}
return ' <span class="awaiting-mod count-' . esc_attr( $count ) . '"><span class="pending-count">' . esc_html( $count ) . '</span></span>';
}
/**
* Highlights Product -> Reviews admin menu item when editing a review or a reply to a review.
*
* @global string $submenu_file
*
* @param string|mixed $parent_file Parent menu item.
* @return string
*/
protected function edit_review_parent_file( $parent_file ) {
global $submenu_file, $current_screen;
if ( isset( $current_screen->id, $_GET['c'] ) && $current_screen->id === 'comment' ) {
$comment_id = absint( $_GET['c'] );
$comment = get_comment( $comment_id );
if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) {
$comment = get_comment( $comment->comment_parent );
}
if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) {
$parent_file = 'edit.php?post_type=product';
$submenu_file = 'product-reviews'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
return $parent_file;
}
/**
* Replaces Edit/Moderate Comment title/headline with Edit Review, when editing/moderating a review.
*
* @param string|mixed $translation Translated text.
* @param string|mixed $text Text to translate.
* @return string|mixed Translated text.
*/
protected function edit_comments_screen_text( $translation, $text ) {
global $comment;
// Bail out if not a text we should replace.
if ( ! in_array( $text, [ 'Edit Comment', 'Moderate Comment' ], true ) ) {
return $translation;
}
// Try to get comment from query params when not in context already.
if ( ! $comment && isset( $_GET['action'], $_GET['c'] ) && $_GET['action'] === 'editcomment' ) {
$comment_id = absint( $_GET['c'] );
$comment = get_comment( $comment_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
$is_reply = false;
if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) {
$is_reply = true;
$comment = get_comment( $comment->comment_parent ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
// Only replace the translated text if we are editing a comment left on a product (ie. a review).
if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) {
if ( $text === 'Edit Comment' ) {
$translation = $is_reply
? __( 'Edit Review Reply', 'woocommerce' )
: __( 'Edit Review', 'woocommerce' );
} elseif ( $text === 'Moderate Comment' ) {
$translation = $is_reply
? __( 'Moderate Review Reply', 'woocommerce' )
: __( 'Moderate Review', 'woocommerce' );
}
}
return $translation;
}
/**
* Returns a new instance of `ReviewsListTable`, with the screen argument specified.
*
* @return ReviewsListTable
*/
protected function make_reviews_list_table() : ReviewsListTable {
return new ReviewsListTable( [ 'screen' => $this->reviews_page_hook ? $this->reviews_page_hook : 'product_page_product-reviews' ] );
}
/**
* Initializes the list table.
*
* @return void
*/
protected function load_reviews_screen() : void {
$this->reviews_list_table = $this->make_reviews_list_table();
$this->reviews_list_table->process_bulk_action();
}
/**
* Renders the Reviews page.
*
* @return void
*/
public function render_reviews_list_table() : void {
$this->reviews_list_table->prepare_items();
ob_start();
?>
<div class="wrap">
<h2><?php echo esc_html( get_admin_page_title() ); ?></h2>
<?php $this->reviews_list_table->views(); ?>
<form id="reviews-filter" method="get">
<?php $page = isset( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : static::MENU_SLUG; ?>
<input type="hidden" name="page" value="<?php echo esc_attr( $page ); ?>" />
<input type="hidden" name="post_type" value="product" />
<input type="hidden" name="pagegen_timestamp" value="<?php echo esc_attr( current_time( 'mysql', true ) ); ?>" />
<?php $this->reviews_list_table->search_box( __( 'Search Reviews', 'woocommerce' ), 'reviews' ); ?>
<?php $this->reviews_list_table->display(); ?>
</form>
</div>
<?php
wp_comment_reply( '-1', true, 'detail' );
wp_comment_trashnotice();
/**
* Filters the contents of the product reviews list table output.
*
* @since 6.7.0
*
* @param string $output The HTML output of the list table.
* @param ReviewsListTable $reviews_list_table The reviews list table instance.
*/
echo apply_filters( 'woocommerce_product_reviews_list_table', ob_get_clean(), $this->reviews_list_table ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
Admin/ProductReviews/ReviewsListTable.php 0000644 00000133165 15233366071 0014541 0 ustar 00 <?php
/**
* Product > Reviews
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use WC_Product;
use WP_Comment;
use WP_Comments_List_Table;
use WP_List_Table;
use WP_Post;
/**
* Handles the Product Reviews page.
*/
class ReviewsListTable extends WP_List_Table {
/**
* Memoization flag to determine if the current user can edit the current review.
*
* @var bool
*/
private $current_user_can_edit_review = false;
/**
* Memoization flag to determine if the current user can moderate reviews.
*
* @var bool
*/
private $current_user_can_moderate_reviews;
/**
* Current rating of reviews to display.
*
* @var int
*/
private $current_reviews_rating = 0;
/**
* Current product the reviews should be displayed for.
*
* @var WC_Product|null Product or null for all products.
*/
private $current_product_for_reviews;
/**
* Constructor.
*
* @param array|string $args Array or string of arguments.
*/
public function __construct( $args = [] ) {
parent::__construct(
wp_parse_args(
$args,
[
'plural' => 'product-reviews',
'singular' => 'product-review',
]
)
);
$this->current_user_can_moderate_reviews = current_user_can( Reviews::get_capability( 'moderate' ) );
}
/**
* Prepares reviews for display.
*
* @return void
*/
public function prepare_items() : void {
$this->set_review_status();
$this->set_review_type();
$this->current_reviews_rating = isset( $_REQUEST['review_rating'] ) ? absint( $_REQUEST['review_rating'] ) : 0;
$this->set_review_product();
$args = [
'number' => $this->get_per_page(),
'post_type' => 'product',
];
// Include the order & orderby arguments.
$args = wp_parse_args( $this->get_sort_arguments(), $args );
// Handle the review item types filter.
$args = wp_parse_args( $this->get_filter_type_arguments(), $args );
// Handle the reviews rating filter.
$args = wp_parse_args( $this->get_filter_rating_arguments(), $args );
// Handle the review product filter.
$args = wp_parse_args( $this->get_filter_product_arguments(), $args );
// Include the review status arguments.
$args = wp_parse_args( $this->get_status_arguments(), $args );
// Include the search argument.
$args = wp_parse_args( $this->get_search_arguments(), $args );
// Include the offset argument.
$args = wp_parse_args( $this->get_offset_arguments(), $args );
/**
* Provides an opportunity to alter the comment query arguments used within
* the product reviews admin list table.
*
* @since 7.0.0
*
* @param array $args Comment query args.
*/
$args = (array) apply_filters( 'woocommerce_product_reviews_list_table_prepare_items_args', $args );
$comments = get_comments( $args );
update_comment_cache( $comments );
$this->items = $comments;
$this->set_pagination_args(
[
'total_items' => get_comments( $this->get_total_comments_arguments( $args ) ),
'per_page' => $this->get_per_page(),
]
);
}
/**
* Returns the number of items to show per page.
*
* @return int Customized per-page value if available, or 20 as the default.
*/
protected function get_per_page() : int {
return $this->get_items_per_page( 'edit_comments_per_page' );
}
/**
* Sets the product to filter reviews by.
*
* @return void
*/
protected function set_review_product() : void {
$product_id = isset( $_REQUEST['product_id'] ) ? absint( $_REQUEST['product_id'] ) : null;
$product = $product_id ? wc_get_product( $product_id ) : null;
if ( $product instanceof WC_Product ) {
$this->current_product_for_reviews = $product;
}
}
/**
* Sets the `$comment_status` global based on the current request.
*
* @global string $comment_status
*
* @return void
*/
protected function set_review_status() : void {
global $comment_status;
$comment_status = sanitize_text_field( wp_unslash( $_REQUEST['comment_status'] ?? 'all' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
if ( ! in_array( $comment_status, [ 'all', 'moderated', 'approved', 'spam', 'trash' ], true ) ) {
$comment_status = 'all'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
/**
* Sets the `$comment_type` global based on the current request.
*
* @global string $comment_type
*
* @return void
*/
protected function set_review_type() : void {
global $comment_type;
$review_type = sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ?? 'all' ) );
if ( 'all' !== $review_type && ! empty( $review_type ) ) {
$comment_type = $review_type; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
/**
* Builds the `orderby` and `order` arguments based on the current request.
*
* @return array
*/
protected function get_sort_arguments() : array {
$orderby = sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ?? '' ) );
$order = sanitize_text_field( wp_unslash( $_REQUEST['order'] ?? '' ) );
$args = [];
if ( ! in_array( $orderby, $this->get_sortable_columns(), true ) ) {
$orderby = 'comment_date_gmt';
}
// If ordering by "rating", then we need to adjust to sort by meta value.
if ( 'rating' === $orderby ) {
$orderby = 'meta_value_num';
$args['meta_key'] = 'rating';
}
if ( ! in_array( strtolower( $order ), [ 'asc', 'desc' ], true ) ) {
$order = 'desc';
}
return wp_parse_args(
[
'orderby' => $orderby,
'order' => strtolower( $order ),
],
$args
);
}
/**
* Builds the `type` argument based on the current request.
*
* @return array
*/
protected function get_filter_type_arguments() : array {
$args = [];
$item_type = isset( $_REQUEST['review_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ) ) : 'all';
if ( 'all' === $item_type ) {
return $args;
}
$args['type'] = $item_type;
return $args;
}
/**
* Builds the `meta_query` arguments based on the current request.
*
* @return array
*/
protected function get_filter_rating_arguments() : array {
$args = [];
if ( empty( $this->current_reviews_rating ) ) {
return $args;
}
$args['meta_query'] = [
[
'key' => 'rating',
'value' => (int) $this->current_reviews_rating,
'compare' => '=',
'type' => 'NUMERIC',
],
];
return $args;
}
/**
* Gets the `post_id` argument based on the current request.
*
* @return array
*/
public function get_filter_product_arguments() : array {
$args = [];
if ( $this->current_product_for_reviews instanceof WC_Product ) {
$args['post_id'] = $this->current_product_for_reviews->get_id();
}
return $args;
}
/**
* Gets the `status` argument based on the current request.
*
* @return array
*/
protected function get_status_arguments() : array {
$args = [];
global $comment_status;
if ( ! empty( $comment_status ) && 'all' !== $comment_status && array_key_exists( $comment_status, $this->get_status_filters() ) ) {
$args['status'] = $this->convert_status_to_query_value( $comment_status );
}
return $args;
}
/**
* Gets the `search` argument based on the current request.
*
* @return array
*/
protected function get_search_arguments() : array {
$args = [];
if ( ! empty( $_REQUEST['s'] ) ) {
$args['search'] = sanitize_text_field( wp_unslash( $_REQUEST['s'] ) );
}
return $args;
}
/**
* Returns the `offset` argument based on the current request.
*
* @return array
*/
protected function get_offset_arguments() : array {
$args = [];
if ( isset( $_REQUEST['start'] ) ) {
$args['offset'] = absint( wp_unslash( $_REQUEST['start'] ) );
} else {
$args['offset'] = ( $this->get_pagenum() - 1 ) * $this->get_per_page();
}
return $args;
}
/**
* Returns the arguments used to count the total number of comments.
*
* @param array $default_query_args Query args for the main request.
* @return array
*/
protected function get_total_comments_arguments( array $default_query_args ) : array {
return wp_parse_args(
[
'count' => true,
'offset' => 0,
'number' => 0,
],
$default_query_args
);
}
/**
* Displays the product reviews HTML table.
*
* Reimplements {@see WP_Comment_::display()} but we change the ID to match the one output by {@see WP_Comments_List_Table::display()}.
* This will automatically handle additional CSS for consistency with the comments page.
*
* @return void
*/
public function display() : void {
$this->display_tablenav( 'top' );
$this->screen->render_screen_reader_content( 'heading_list' );
?>
<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
<thead>
<tr>
<?php $this->print_column_headers(); ?>
</tr>
</thead>
<tbody id="the-comment-list" data-wp-lists="list:comment">
<?php $this->display_rows_or_placeholder(); ?>
</tbody>
<tfoot>
<tr>
<?php $this->print_column_headers( false ); ?>
</tr>
</tfoot>
</table>
<?php
$this->display_tablenav( 'bottom' );
}
/**
* Render a single row HTML.
*
* @global WP_Post $post
* @global WP_Comment $comment
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
public function single_row( $item ) : void {
global $post, $comment;
// Overrides the comment global for properly rendering rows.
$comment = $item; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$the_comment_class = (string) wp_get_comment_status( $comment->comment_ID );
$the_comment_class = implode( ' ', get_comment_class( $the_comment_class, $comment->comment_ID, $comment->comment_post_ID ) );
// Sets the post for the product in context.
$post = get_post( $comment->comment_post_ID ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$this->current_user_can_edit_review = current_user_can( 'edit_comment', $comment->comment_ID );
?>
<tr id="comment-<?php echo esc_attr( $comment->comment_ID ); ?>" class="comment <?php echo esc_attr( $the_comment_class ); ?>">
<?php $this->single_row_columns( $comment ); ?>
</tr>
<?php
}
/**
* Generate and display row actions links.
*
* @see WP_Comments_List_Table::handle_row_actions() for consistency.
*
* @global string $comment_status Status for the current listed comments.
*
* @param WP_Comment|mixed $item The product review or reply in context.
* @param string|mixed $column_name Current column name.
* @param string|mixed $primary Primary column name.
* @return string
*/
protected function handle_row_actions( $item, $column_name, $primary ) : string {
global $comment_status;
if ( $primary !== $column_name || ! $this->current_user_can_edit_review ) {
return '';
}
$review_status = wp_get_comment_status( $item );
$url = add_query_arg(
[
'c' => urlencode( $item->comment_ID ),
],
admin_url( 'comment.php' )
);
$approve_url = wp_nonce_url( add_query_arg( 'action', 'approvecomment', $url ), "approve-comment_$item->comment_ID" );
$unapprove_url = wp_nonce_url( add_query_arg( 'action', 'unapprovecomment', $url ), "approve-comment_$item->comment_ID" );
$spam_url = wp_nonce_url( add_query_arg( 'action', 'spamcomment', $url ), "delete-comment_$item->comment_ID" );
$unspam_url = wp_nonce_url( add_query_arg( 'action', 'unspamcomment', $url ), "delete-comment_$item->comment_ID" );
$trash_url = wp_nonce_url( add_query_arg( 'action', 'trashcomment', $url ), "delete-comment_$item->comment_ID" );
$untrash_url = wp_nonce_url( add_query_arg( 'action', 'untrashcomment', $url ), "delete-comment_$item->comment_ID" );
$delete_url = wp_nonce_url( add_query_arg( 'action', 'deletecomment', $url ), "delete-comment_$item->comment_ID" );
$actions = [
'approve' => '',
'unapprove' => '',
'reply' => '',
'quickedit' => '',
'edit' => '',
'spam' => '',
'unspam' => '',
'trash' => '',
'untrash' => '',
'delete' => '',
];
if ( $comment_status && 'all' !== $comment_status ) {
if ( 'approved' === $review_status ) {
$actions['unapprove'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-u vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unapprove_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&new=unapproved" ),
esc_attr__( 'Unapprove this review', 'woocommerce' ),
esc_html__( 'Unapprove', 'woocommerce' )
);
} elseif ( 'unapproved' === $review_status ) {
$actions['approve'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-a vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $approve_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&new=approved" ),
esc_attr__( 'Approve this review', 'woocommerce' ),
esc_html__( 'Approve', 'woocommerce' )
);
}
} else {
$actions['approve'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-a aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $approve_url ),
esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=approved" ),
esc_attr__( 'Approve this review', 'woocommerce' ),
esc_html__( 'Approve', 'woocommerce' )
);
$actions['unapprove'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-u aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unapprove_url ),
esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=unapproved" ),
esc_attr__( 'Unapprove this review', 'woocommerce' ),
esc_html__( 'Unapprove', 'woocommerce' )
);
}
if ( 'spam' !== $review_status ) {
$actions['spam'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-s vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $spam_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::spam=1" ),
esc_attr__( 'Mark this review as spam', 'woocommerce' ),
/* translators: "Mark as spam" link. */
esc_html_x( 'Spam', 'verb', 'woocommerce' )
);
} else {
$actions['unspam'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unspam_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:unspam=1" ),
esc_attr__( 'Restore this review from the spam', 'woocommerce' ),
esc_html_x( 'Not Spam', 'review', 'woocommerce' )
);
}
if ( 'trash' === $review_status ) {
$actions['untrash'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $untrash_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:untrash=1" ),
esc_attr__( 'Restore this review from the Trash', 'woocommerce' ),
esc_html__( 'Restore', 'woocommerce' )
);
}
if ( 'spam' === $review_status || 'trash' === $review_status || ! EMPTY_TRASH_DAYS ) {
$actions['delete'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $delete_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::delete=1" ),
esc_attr__( 'Delete this review permanently', 'woocommerce' ),
esc_html__( 'Delete Permanently', 'woocommerce' )
);
} else {
$actions['trash'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $trash_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::trash=1" ),
esc_attr__( 'Move this review to the Trash', 'woocommerce' ),
esc_html_x( 'Trash', 'verb', 'woocommerce' )
);
}
if ( 'spam' !== $review_status && 'trash' !== $review_status ) {
$actions['edit'] = sprintf(
'<a href="%s" aria-label="%s">%s</a>',
esc_url(
add_query_arg(
[
'action' => 'editcomment',
'c' => urlencode( $item->comment_ID ),
],
admin_url( 'comment.php' )
)
),
esc_attr__( 'Edit this review', 'woocommerce' ),
esc_html__( 'Edit', 'woocommerce' )
);
$format = '<button type="button" data-comment-id="%d" data-post-id="%d" data-action="%s" class="%s button-link" aria-expanded="false" aria-label="%s">%s</button>';
$actions['quickedit'] = sprintf(
$format,
esc_attr( $item->comment_ID ),
esc_attr( $item->comment_post_ID ),
'edit',
'vim-q comment-inline',
esc_attr__( 'Quick edit this review inline', 'woocommerce' ),
esc_html__( 'Quick Edit', 'woocommerce' )
);
$actions['reply'] = sprintf(
$format,
esc_attr( $item->comment_ID ),
esc_attr( $item->comment_post_ID ),
'replyto',
'vim-r comment-inline',
esc_attr__( 'Reply to this review', 'woocommerce' ),
esc_html__( 'Reply', 'woocommerce' )
);
}
$always_visible = 'excerpt' === get_user_setting( 'posts_list_mode', 'list' );
$output = '<div class="' . ( $always_visible ? 'row-actions visible' : 'row-actions' ) . '">';
$i = 0;
foreach ( array_filter( $actions ) as $action => $link ) {
++$i;
if ( ( ( 'approve' === $action || 'unapprove' === $action ) && 2 === $i ) || 1 === $i ) {
$sep = '';
} else {
$sep = ' | ';
}
if ( ( 'reply' === $action || 'quickedit' === $action ) && ! wp_doing_ajax() ) {
$action .= ' hide-if-no-js';
} elseif ( ( 'untrash' === $action && 'trash' === $review_status ) || ( 'unspam' === $action && 'spam' === $review_status ) ) {
if ( '1' === get_comment_meta( $item->comment_ID, '_wp_trash_meta_status', true ) ) {
$action .= ' approve';
} else {
$action .= ' unapprove';
}
}
$output .= "<span class='$action'>$sep$link</span>";
}
$output .= '</div>';
$output .= '<button type="button" class="toggle-row"><span class="screen-reader-text">' . esc_html__( 'Show more details', 'woocommerce' ) . '</span></button>';
return $output;
}
/**
* Gets the columns for the table.
*
* @return array Table columns and their headings.
*/
public function get_columns() : array {
$columns = [
'cb' => '<input type="checkbox" />',
'type' => _x( 'Type', 'review type', 'woocommerce' ),
'author' => __( 'Author', 'woocommerce' ),
'rating' => __( 'Rating', 'woocommerce' ),
'comment' => _x( 'Review', 'column name', 'woocommerce' ),
'response' => __( 'Product', 'woocommerce' ),
'date' => _x( 'Submitted on', 'column name', 'woocommerce' ),
];
/**
* Filters the table columns.
*
* @since 6.7.0
*
* @param array $columns
*/
return (array) apply_filters( 'woocommerce_product_reviews_table_columns', $columns );
}
/**
* Gets the name of the default primary column.
*
* @return string Name of the primary colum.
*/
protected function get_primary_column_name() : string {
return 'comment';
}
/**
* Gets a list of sortable columns.
*
* Key is the column ID and value is which database column we perform the sorting on.
* The `rating` column uses a unique key instead, as that requires sorting by meta value.
*
* @return array
*/
protected function get_sortable_columns() : array {
return [
'author' => 'comment_author',
'response' => 'comment_post_ID',
'date' => 'comment_date_gmt',
'type' => 'comment_type',
'rating' => 'rating',
];
}
/**
* Returns a list of available bulk actions.
*
* @global string $comment_status
*
* @return array
*/
protected function get_bulk_actions() : array {
global $comment_status;
$actions = [];
if ( in_array( $comment_status, [ 'all', 'approved' ], true ) ) {
$actions['unapprove'] = __( 'Unapprove', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'all', 'moderated' ], true ) ) {
$actions['approve'] = __( 'Approve', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'all', 'moderated', 'approved', 'trash' ], true ) ) {
$actions['spam'] = _x( 'Mark as spam', 'review', 'woocommerce' );
}
if ( 'trash' === $comment_status ) {
$actions['untrash'] = __( 'Restore', 'woocommerce' );
} elseif ( 'spam' === $comment_status ) {
$actions['unspam'] = _x( 'Not spam', 'review', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'trash', 'spam' ], true ) || ! EMPTY_TRASH_DAYS ) {
$actions['delete'] = __( 'Delete permanently', 'woocommerce' );
} else {
$actions['trash'] = __( 'Move to Trash', 'woocommerce' );
}
return $actions;
}
/**
* Returns the current action select in bulk actions menu.
*
* This is overridden in order to support `delete_all` for use in {@see ReviewsListTable::process_bulk_action()}
*
* {@see WP_Comments_List_Table::current_action()} for reference.
*
* @return string|false
*/
public function current_action() {
if ( isset( $_REQUEST['delete_all'] ) || isset( $_REQUEST['delete_all2'] ) ) {
return 'delete_all';
}
return parent::current_action();
}
/**
* Processes the bulk actions.
*
* @return void
*/
public function process_bulk_action() : void {
if ( ! $this->current_user_can_moderate_reviews ) {
return;
}
if ( $this->current_action() ) {
check_admin_referer( 'bulk-product-reviews' );
$query_string = remove_query_arg( [ 'page', '_wpnonce' ], wp_unslash( ( $_SERVER['QUERY_STRING'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// Replace current nonce with bulk-comments nonce.
$comments_nonce = wp_create_nonce( 'bulk-comments' );
$query_string = add_query_arg( '_wpnonce', $comments_nonce, $query_string );
// Redirect to edit-comments.php, which will handle processing the action for us.
wp_safe_redirect( esc_url_raw( admin_url( 'edit-comments.php?' . $query_string ) ) );
exit;
} elseif ( ! empty( $_GET['_wp_http_referer'] ) ) {
wp_safe_redirect( remove_query_arg( [ '_wp_http_referer', '_wpnonce' ] ) );
exit;
}
}
/**
* Returns an array of supported statuses and their labels.
*
* @return array
*/
protected function get_status_filters() : array {
return [
/* translators: %s: Number of reviews. */
'all' => _nx_noop(
'All <span class="count">(%s)</span>',
'All <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'moderated' => _nx_noop(
'Pending <span class="count">(%s)</span>',
'Pending <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'approved' => _nx_noop(
'Approved <span class="count">(%s)</span>',
'Approved <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'spam' => _nx_noop(
'Spam <span class="count">(%s)</span>',
'Spam <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'trash' => _nx_noop(
'Trash <span class="count">(%s)</span>',
'Trash <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
];
}
/**
* Returns the available status filters.
*
* @see WP_Comments_List_Table::get_views() for consistency.
*
* @global int $post_id
* @global string $comment_status
* @global string $comment_type
*
* @return array An associative array of fully-formed comment status links. Includes 'All', 'Pending', 'Approved', 'Spam', and 'Trash'.
*/
protected function get_views() : array {
global $post_id, $comment_status, $comment_type;
$status_links = [];
$status_labels = $this->get_status_filters();
if ( ! EMPTY_TRASH_DAYS ) {
unset( $status_labels['trash'] );
}
$link = $this->get_view_url( (string) $comment_type, (int) $post_id );
foreach ( $status_labels as $status => $label ) {
$current_link_attributes = '';
if ( $status === $comment_status ) {
$current_link_attributes = ' class="current" aria-current="page"';
}
$link = add_query_arg( 'comment_status', urlencode( $status ), $link );
$number_reviews_for_status = $this->get_review_count( $status, (int) $post_id );
$count_html = sprintf(
'<span class="%s-count">%s</span>',
( 'moderated' === $status ) ? 'pending' : $status,
number_format_i18n( $number_reviews_for_status )
);
$status_links[ $status ] = '<a href="' . esc_url( $link ) . '"' . $current_link_attributes . '>' . sprintf( translate_nooped_plural( $label, $number_reviews_for_status ), $count_html ) . '</a>';
}
return $status_links;
}
/**
* Gets the base URL for a view, excluding the status (that should be appended).
*
* @param string $comment_type Comment type filter.
* @param int $post_id Current post ID.
* @return string
*/
protected function get_view_url( string $comment_type, int $post_id ) : string {
$link = Reviews::get_reviews_page_url();
if ( ! empty( $comment_type ) && 'all' !== $comment_type ) {
$link = add_query_arg( 'comment_type', urlencode( $comment_type ), $link );
}
if ( ! empty( $post_id ) ) {
$link = add_query_arg( 'p', absint( $post_id ), $link );
}
return $link;
}
/**
* Gets the number of reviews (including review replies) for a given status.
*
* @param string $status Status key from {@see ReviewsListTable::get_status_filters()}.
* @param int $product_id ID of the product if we're filtering by product in this request. Otherwise, `0` for no product filters.
* @return int
*/
protected function get_review_count( string $status, int $product_id ) : int {
return (int) get_comments(
[
'type__in' => [ 'review', 'comment' ],
'status' => $this->convert_status_to_query_value( $status ),
'post_type' => 'product',
'post_id' => $product_id,
'count' => true,
]
);
}
/**
* Converts a status key into its equivalent `comment_approved` database column value.
*
* @param string $status Status key from {@see ReviewsListTable::get_status_filters()}.
* @return string
*/
protected function convert_status_to_query_value( string $status ) : string {
// These keys exactly match the database column.
if ( in_array( $status, [ 'spam', 'trash' ], true ) ) {
return $status;
}
switch ( $status ) {
case 'moderated':
return '0';
case 'approved':
return '1';
default:
return 'all';
}
}
/**
* Outputs the text to display when there are no reviews to display.
*
* @see WP_List_Table::no_items()
*
* @global string $comment_status
*
* @return void
*/
public function no_items() : void {
global $comment_status;
if ( 'moderated' === $comment_status ) {
esc_html_e( 'No reviews awaiting moderation.', 'woocommerce' );
} else {
esc_html_e( 'No reviews found.', 'woocommerce' );
}
}
/**
* Renders the checkbox column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_cb( $item ) : void {
ob_start();
if ( $this->current_user_can_edit_review ) {
?>
<label class="screen-reader-text" for="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"><?php esc_html_e( 'Select review', 'woocommerce' ); ?></label>
<input
id="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"
type="checkbox"
name="delete_comments[]"
value="<?php echo esc_attr( $item->comment_ID ); ?>"
/>
<?php
}
echo $this->filter_column_output( 'cb', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the review column.
*
* @see WP_Comments_List_Table::column_comment() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_comment( $item ) : void {
$in_reply_to = $this->get_in_reply_to_review_text( $item );
ob_start();
if ( $in_reply_to ) {
echo $in_reply_to . '<br><br>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
printf(
'%1$s%2$s%3$s',
'<div class="comment-text">',
get_comment_text( $item->comment_ID ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
'</div>'
);
if ( $this->current_user_can_edit_review ) {
?>
<div id="inline-<?php echo esc_attr( $item->comment_ID ); ?>" class="hidden">
<textarea class="comment" rows="1" cols="1"><?php echo esc_textarea( $item->comment_content ); ?></textarea>
<div class="author-email"><?php echo esc_attr( $item->comment_author_email ); ?></div>
<div class="author"><?php echo esc_attr( $item->comment_author ); ?></div>
<div class="author-url"><?php echo esc_attr( $item->comment_author_url ); ?></div>
<div class="comment_status"><?php echo esc_html( $item->comment_approved ); ?></div>
</div>
<?php
}
echo $this->filter_column_output( 'comment', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the in-reply-to-review text.
*
* @param WP_Comment|mixed $reply Reply to review.
* @return string
*/
private function get_in_reply_to_review_text( $reply ) : string {
$review = $reply->comment_parent ? get_comment( $reply->comment_parent ) : null;
if ( ! $review ) {
return '';
}
$parent_review_link = get_comment_link( $review );
$review_author_name = get_comment_author( $review );
return sprintf(
/* translators: %s: Parent review link with review author name. */
ent2ncr( __( 'In reply to %s.', 'woocommerce' ) ),
'<a href="' . esc_url( $parent_review_link ) . '">' . esc_html( $review_author_name ) . '</a>'
);
}
/**
* Renders the author column.
*
* @see WP_Comments_List_Table::column_author() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_author( $item ) : void {
global $comment_status;
$author_url = $this->get_item_author_url();
$author_url_display = $this->get_item_author_url_for_display( $author_url );
if ( get_option( 'show_avatars' ) ) {
$author_avatar = get_avatar( $item, 32, 'mystery' );
} else {
$author_avatar = '';
}
ob_start();
echo '<strong>' . $author_avatar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
comment_author();
echo '</strong><br>';
if ( ! empty( $author_url ) ) :
?>
<a title="<?php echo esc_attr( $author_url ); ?>" href="<?php echo esc_url( $author_url ); ?>" rel="noopener noreferrer"><?php echo esc_html( $author_url_display ); ?></a>
<br>
<?php
endif;
if ( $this->current_user_can_edit_review ) :
if ( ! empty( $item->comment_author_email ) && is_email( $item->comment_author_email ) ) :
?>
<a href="mailto:<?php echo esc_attr( $item->comment_author_email ); ?>"><?php echo esc_html( $item->comment_author_email ); ?></a>
<?php
endif;
$link = add_query_arg(
[
's' => urlencode( get_comment_author_IP( $item->comment_ID ) ),
'page' => Reviews::MENU_SLUG,
'mode' => 'detail',
],
'admin.php'
);
if ( 'spam' === $comment_status ) :
$link = add_query_arg( [ 'comment_status' => 'spam' ], $link );
endif;
?>
<a href="<?php echo esc_url( $link ); ?>"><?php comment_author_IP( $item->comment_ID ); ?></a>
<?php
endif;
echo $this->filter_column_output( 'author', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the item author URL.
*
* @return string
*/
private function get_item_author_url() : string {
$author_url = get_comment_author_url();
$protocols = [ 'https://', 'http://' ];
if ( in_array( $author_url, $protocols ) ) {
$author_url = '';
}
return $author_url;
}
/**
* Gets the item author URL for display.
*
* @param string $author_url The review or reply author URL (raw).
* @return string
*/
private function get_item_author_url_for_display( $author_url ) : string {
$author_url_display = untrailingslashit( preg_replace( '|^http(s)?://(www\.)?|i', '', $author_url ) );
if ( strlen( $author_url_display ) > 50 ) {
$author_url_display = wp_html_excerpt( $author_url_display, 49, '…' );
}
return $author_url_display;
}
/**
* Renders the "submitted on" column.
*
* Note that the output is consistent with {@see WP_Comments_List_Table::column_date()}.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_date( $item ) : void {
$submitted = sprintf(
/* translators: 1 - Product review date, 2: Product review time. */
__( '%1$s at %2$s', 'woocommerce' ),
/* translators: Review date format. See https://www.php.net/manual/datetime.format.php */
get_comment_date( __( 'Y/m/d', 'woocommerce' ), $item ),
/* translators: Review time format. See https://www.php.net/manual/datetime.format.php */
get_comment_date( __( 'g:i a', 'woocommerce' ), $item )
);
ob_start();
?>
<div class="submitted-on">
<?php
if ( 'approved' === wp_get_comment_status( $item ) && ! empty( $item->comment_post_ID ) ) :
printf(
'<a href="%1$s">%2$s</a>',
esc_url( get_comment_link( $item ) ),
esc_html( $submitted )
);
else :
echo esc_html( $submitted );
endif;
?>
</div>
<?php
echo $this->filter_column_output( 'date', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the product column.
*
* @see WP_Comments_List_Table::column_response() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_response( $item ) : void {
$product_post = get_post();
ob_start();
if ( $product_post ) :
?>
<div class="response-links">
<?php
if ( current_user_can( 'edit_product', $product_post->ID ) ) :
$post_link = "<a href='" . esc_url( get_edit_post_link( $product_post->ID ) ) . "' class='comments-edit-item-link'>";
$post_link .= esc_html( get_the_title( $product_post->ID ) ) . '</a>';
else :
$post_link = esc_html( get_the_title( $product_post->ID ) );
endif;
echo $post_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$post_type_object = get_post_type_object( $product_post->post_type );
?>
<a href="<?php echo esc_url( get_permalink( $product_post->ID ) ); ?>" class="comments-view-item-link">
<?php echo esc_html( $post_type_object->labels->view_item ); ?>
</a>
<span class="post-com-count-wrapper post-com-count-<?php echo esc_attr( $product_post->ID ); ?>">
<?php $this->comments_bubble( $product_post->ID, get_pending_comments_num( $product_post->ID ) ); ?>
</span>
</div>
<?php
endif;
echo $this->filter_column_output( 'response', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the type column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_type( $item ) : void {
$type = 'review' === $item->comment_type
? '☆ ' . __( 'Review', 'woocommerce' )
: __( 'Reply', 'woocommerce' );
echo $this->filter_column_output( 'type', esc_html( $type ), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the rating column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_rating( $item ) : void {
$rating = get_comment_meta( $item->comment_ID, 'rating', true );
ob_start();
if ( ! empty( $rating ) && is_numeric( $rating ) ) {
$rating = (int) $rating;
$accessibility_label = sprintf(
/* translators: 1: number representing a rating */
__( '%1$d out of 5', 'woocommerce' ),
$rating
);
$stars = str_repeat( '★', $rating );
$stars .= str_repeat( '☆', 5 - $rating );
?>
<span aria-label="<?php echo esc_attr( $accessibility_label ); ?>"><?php echo esc_html( $stars ); ?></span>
<?php
}
echo $this->filter_column_output( 'rating', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders any custom columns.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @param string|mixed $column_name Name of the column being rendered.
* @return void
*/
protected function column_default( $item, $column_name ) : void {
ob_start();
/**
* Fires when the default column output is displayed for a single row.
*
* This action can be used to render custom columns that have been added.
*
* @since 6.7.0
*
* @param WP_Comment $item The review or reply being rendered.
*/
do_action( 'woocommerce_product_reviews_table_column_' . $column_name, $item );
echo $this->filter_column_output( $column_name, ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Runs a filter hook for a given column content.
*
* @param string|mixed $column_name The column being output.
* @param string|mixed $output The output content (may include HTML).
* @param WP_Comment|mixed $item The review or reply being rendered.
* @return string
*/
protected function filter_column_output( $column_name, $output, $item ) : string {
/**
* Filters the output of a column.
*
* @since 6.7.0
*
* @param string $output The column output.
* @param WP_Comment $item The product review being rendered.
*/
return (string) apply_filters( 'woocommerce_product_reviews_table_column_' . $column_name . '_content', $output, $item );
}
/**
* Renders the extra controls to be displayed between bulk actions and pagination.
*
* @global string $comment_status
* @global string $comment_type
*
* @param string|mixed $which Position (top or bottom).
* @return void
*/
protected function extra_tablenav( $which ) : void {
global $comment_status, $comment_type;
echo '<div class="alignleft actions">';
if ( 'top' === $which ) {
ob_start();
echo '<input type="hidden" name="comment_status" value="' . esc_attr( $comment_status ?? 'all' ) . '" />';
$this->review_type_dropdown( $comment_type );
$this->review_rating_dropdown( $this->current_reviews_rating );
$this->product_search( $this->current_product_for_reviews );
echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, [ 'id' => 'post-query-submit' ] );
}
if ( ( 'spam' === $comment_status || 'trash' === $comment_status ) && $this->has_items() && $this->current_user_can_moderate_reviews ) {
wp_nonce_field( 'bulk-destroy', '_destroy_nonce' );
$title = 'spam' === $comment_status
? esc_attr__( 'Empty Spam', 'woocommerce' )
: esc_attr__( 'Empty Trash', 'woocommerce' );
submit_button( $title, 'apply', 'delete_all', false );
}
echo '</div>';
}
/**
* Displays a review type drop-down for filtering reviews in the Product Reviews list table.
*
* @see WP_Comments_List_Table::comment_type_dropdown() for consistency.
*
* @param string|mixed $current_type The current comment item type slug.
* @return void
*/
protected function review_type_dropdown( $current_type ) : void {
/**
* Sets the possible options used in the Product Reviews List Table's filter-by-review-type
* selector.
*
* @since 7.0.0
*
* @param array Map of possible review types.
*/
$item_types = apply_filters(
'woocommerce_product_reviews_list_table_item_types',
array(
'all' => __( 'All types', 'woocommerce' ),
'comment' => __( 'Replies', 'woocommerce' ),
'review' => __( 'Reviews', 'woocommerce' ),
)
);
?>
<label class="screen-reader-text" for="filter-by-review-type"><?php esc_html_e( 'Filter by review type', 'woocommerce' ); ?></label>
<select id="filter-by-review-type" name="review_type">
<?php foreach ( $item_types as $type => $label ) : ?>
<option value="<?php echo esc_attr( $type ); ?>" <?php selected( $type, $current_type ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Displays a review rating drop-down for filtering reviews in the Product Reviews list table.
*
* @param int|string|mixed $current_rating Rating to display reviews for.
* @return void
*/
public function review_rating_dropdown( $current_rating ) : void {
$rating_options = [
'0' => __( 'All ratings', 'woocommerce' ),
'1' => '★',
'2' => '★★',
'3' => '★★★',
'4' => '★★★★',
'5' => '★★★★★',
];
?>
<label class="screen-reader-text" for="filter-by-review-rating"><?php esc_html_e( 'Filter by review rating', 'woocommerce' ); ?></label>
<select id="filter-by-review-rating" name="review_rating">
<?php foreach ( $rating_options as $rating => $label ) : ?>
<?php
$title = 0 === (int) $rating
? $label
: sprintf(
/* translators: %s: Star rating (1-5). */
__( '%s-star rating', 'woocommerce' ),
$rating
);
?>
<option value="<?php echo esc_attr( $rating ); ?>" <?php selected( $rating, (string) $current_rating ); ?> title="<?php echo esc_attr( $title ); ?>"><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Displays a product search input for filtering reviews by product in the Product Reviews list table.
*
* @param WC_Product|null $current_product The current product (or null when displaying all reviews).
* @return void
*/
protected function product_search( ?WC_Product $current_product ) : void {
?>
<label class="screen-reader-text" for="filter-by-product"><?php esc_html_e( 'Filter by product', 'woocommerce' ); ?></label>
<select
id="filter-by-product"
class="wc-product-search"
name="product_id"
style="width: 200px;"
data-placeholder="<?php esc_attr_e( 'Search for a product…', 'woocommerce' ); ?>"
data-action="woocommerce_json_search_products"
data-allow_clear="true">
<?php if ( $current_product instanceof WC_Product ) : ?>
<option value="<?php echo esc_attr( $current_product->get_id() ); ?>" selected="selected"><?php echo esc_html( $current_product->get_formatted_name() ); ?></option>
<?php endif; ?>
</select>
<?php
}
/**
* Displays a review count bubble.
*
* Based on {@see WP_List_Table::comments_bubble()}, but overridden, so we can customize the URL and text output.
*
* @param int|mixed $post_id The product ID.
* @param int|mixed $pending_comments Number of pending reviews.
*
* @return void
*/
protected function comments_bubble( $post_id, $pending_comments ) : void {
$approved_review_count = get_comments_number();
$approved_reviews_number = number_format_i18n( $approved_review_count );
$pending_reviews_number = number_format_i18n( $pending_comments );
$approved_only_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s review', '%s reviews', $approved_review_count, 'woocommerce' ),
$approved_reviews_number
);
$approved_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s approved review', '%s approved reviews', $approved_review_count, 'woocommerce' ),
$approved_reviews_number
);
$pending_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s pending review', '%s pending reviews', $pending_comments, 'woocommerce' ),
$pending_reviews_number
);
if ( ! $approved_review_count && ! $pending_comments ) {
// No reviews at all.
printf(
'<span aria-hidden="true">—</span><span class="screen-reader-text">%s</span>',
esc_html__( 'No reviews', 'woocommerce' )
);
} elseif ( $approved_review_count && 'trash' === get_post_status( $post_id ) ) {
// Don't link the comment bubble for a trashed product.
printf(
'<span class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
);
} elseif ( $approved_review_count ) {
// Link the comment bubble to approved reviews.
printf(
'<a href="%s" class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
esc_url(
add_query_arg(
[
'product_id' => urlencode( $post_id ),
'comment_status' => 'approved',
],
Reviews::get_reviews_page_url()
)
),
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
);
} else {
// Don't link the comment bubble when there are no approved reviews.
printf(
'<span class="post-com-count post-com-count-no-comments"><span class="comment-count comment-count-no-comments" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html__( 'No approved reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
);
}
if ( $pending_comments ) {
printf(
'<a href="%s" class="post-com-count post-com-count-pending"><span class="comment-count-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
esc_url(
add_query_arg(
[
'product_id' => urlencode( $post_id ),
'comment_status' => 'moderated',
],
Reviews::get_reviews_page_url()
)
),
esc_html( $pending_reviews_number ),
esc_html( $pending_phrase )
);
} else {
printf(
'<span class="post-com-count post-com-count-pending post-com-count-no-pending"><span class="comment-count comment-count-no-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $pending_reviews_number ),
$approved_review_count ? esc_html__( 'No pending reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
);
}
}
}
Admin/ProductReviews/ReviewsUtil.php 0000644 00000001535 15233366071 0013566 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
/**
* A utility class for handling comments that are product reviews.
*/
class ReviewsUtil {
/**
* Removes product reviews from the edit-comments page to fix the "Mine" tab counter.
*
* @param array|mixed $clauses A compacted array of comment query clauses.
* @return array|mixed
*/
public static function comments_clauses_without_product_reviews( $clauses ) {
global $wpdb, $current_screen;
if ( isset( $current_screen->base ) && 'edit-comments' === $current_screen->base ) {
$clauses['join'] .= " LEFT JOIN {$wpdb->posts} AS wp_posts_to_exclude_reviews ON comment_post_ID = wp_posts_to_exclude_reviews.ID ";
$clauses['where'] .= ( $clauses['where'] ? ' AND ' : '' ) . " wp_posts_to_exclude_reviews.post_type NOT IN ('product') ";
}
return $clauses;
}
}
Admin/WCAdminSharedSettings.php 0000644 00000003006 15233366071 0012444 0 ustar 00 <?php
/**
* Manages the WC Admin settings that need to be pre-loaded.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Internal\Admin\WCAdminSharedSettings class.
*/
class WCAdminSharedSettings {
/**
* Settings prefix used for the window.wcSettings object.
*
* @var string
*/
private $settings_prefix = 'admin';
/**
* Class instance.
*
* @var WCAdminSharedSettings instance
*/
protected static $instance = null;
/**
* Hook into WooCommerce Blocks.
*/
protected function __construct() {
if ( did_action( 'woocommerce_blocks_loaded' ) ) {
$this->on_woocommerce_blocks_loaded();
} else {
add_action( 'woocommerce_blocks_loaded', array( $this, 'on_woocommerce_blocks_loaded' ), 10 );
}
}
/**
* Get class instance.
*
* @return object Instance.
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Adds settings to the Blocks AssetDataRegistry when woocommerce_blocks is loaded.
*
* @return void
*/
public function on_woocommerce_blocks_loaded() {
if ( class_exists( '\Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry' ) ) {
\Automattic\WooCommerce\Blocks\Package::container()->get( \Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry::class )->add(
$this->settings_prefix,
function() {
return apply_filters( 'woocommerce_admin_shared_settings', array() );
},
true
);
}
}
}
Admin/WCAdminAssets.php 0000644 00000027652 15233366071 0010774 0 ustar 00 <?php
/**
* Register the scripts, and styles used within WooCommerce Admin.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use \_WP_Dependency;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* WCAdminAssets Class.
*/
class WCAdminAssets {
/**
* Class instance.
*
* @var WCAdminAssets instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
Features::get_instance();
add_action( 'admin_enqueue_scripts', array( $this, 'register_scripts' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'inject_wc_settings_dependencies' ), 14 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 15 );
}
/**
* Gets the path for the asset depending on file type.
*
* @param string $ext File extension.
* @return string Folder path of asset.
*/
public static function get_path( $ext ) {
return ( $ext === 'css' ) ? WC_ADMIN_DIST_CSS_FOLDER : WC_ADMIN_DIST_JS_FOLDER;
}
/**
* Determines if a minified JS file should be served.
*
* @param boolean $script_debug Only serve unminified files if script debug is on.
* @return boolean If js asset should use minified version.
*/
public static function should_use_minified_js_file( $script_debug ) {
// minified files are only shipped in non-core versions of wc-admin, return false if minified files are not available.
if ( ! Features::exists( 'minified-js' ) ) {
return false;
}
// Otherwise we will serve un-minified files if SCRIPT_DEBUG is on, or if anything truthy is passed in-lieu of SCRIPT_DEBUG.
return ! $script_debug;
}
/**
* Gets the URL to an asset file.
*
* @param string $file File name (without extension).
* @param string $ext File extension.
* @return string URL to asset.
*/
public static function get_url( $file, $ext ) {
$suffix = '';
// Potentially enqueue minified JavaScript.
if ( $ext === 'js' ) {
$script_debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
$suffix = self::should_use_minified_js_file( $script_debug ) ? '.min' : '';
}
return plugins_url( self::get_path( $ext ) . $file . $suffix . '.' . $ext, WC_ADMIN_PLUGIN_FILE );
}
/**
* Gets the file modified time as a cache buster if we're in dev mode, or the plugin version otherwise.
*
* @param string $ext File extension.
* @return string The cache buster value to use for the given file.
*/
public static function get_file_version( $ext ) {
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
return filemtime( WC_ADMIN_ABSPATH . self::get_path( $ext ) );
}
return WC_VERSION;
}
/**
* Gets a script asset registry filename. The asset registry lists dependencies for the given script.
*
* @param string $script_path_name Path to where the script asset registry is contained.
* @param string $file File name (without extension).
* @return string complete asset filename.
*
* @throws \Exception Throws an exception when a readable asset registry file cannot be found.
*/
public static function get_script_asset_filename( $script_path_name, $file ) {
$minification_supported = Features::exists( 'minified-js' );
$script_min_filename = $file . '.min.asset.php';
$script_nonmin_filename = $file . '.asset.php';
$script_asset_path = WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/';
// Check minification is supported first, to avoid multiple is_readable checks when minification is
// not supported.
if ( $minification_supported && is_readable( $script_asset_path . $script_min_filename ) ) {
return $script_min_filename;
} elseif ( is_readable( $script_asset_path . $script_nonmin_filename ) ) {
return $script_nonmin_filename;
} else {
// could not find an asset file, throw an error.
throw new \Exception( 'Could not find asset registry for ' . $script_path_name );
}
}
/**
* Render a preload link tag for a dependency, optionally
* checked against a provided allowlist.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*
* @param WP_Dependency $dependency The WP_Dependency being preloaded.
* @param string $type Dependency type - 'script' or 'style'.
* @param array $allowlist Optional. List of allowed dependency handles.
*/
private function maybe_output_preload_link_tag( $dependency, $type, $allowlist = array() ) {
if (
(
! empty( $allowlist ) &&
! in_array( $dependency->handle, $allowlist, true )
) ||
( ! empty( $this->preloaded_dependencies[ $type ] ) &&
in_array( $dependency->handle, $this->preloaded_dependencies[ $type ], true ) )
) {
return;
}
$this->preloaded_dependencies[ $type ][] = $dependency->handle;
$source = $dependency->ver ? add_query_arg( 'ver', $dependency->ver, $dependency->src ) : $dependency->src;
echo '<link rel="preload" href="', esc_url( $source ), '" as="', esc_attr( $type ), '" />', "\n";
}
/**
* Output a preload link tag for dependencies (and their sub dependencies)
* with an optional allowlist.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*
* @param string $type Dependency type - 'script' or 'style'.
* @param array $allowlist Optional. List of allowed dependency handles.
*/
private function output_header_preload_tags_for_type( $type, $allowlist = array() ) {
if ( $type === 'script' ) {
$dependencies_of_type = wp_scripts();
} elseif ( $type === 'style' ) {
$dependencies_of_type = wp_styles();
} else {
return;
}
foreach ( $dependencies_of_type->queue as $dependency_handle ) {
$dependency = $dependencies_of_type->query( $dependency_handle, 'registered' );
if ( $dependency === false ) {
continue;
}
// Preload the subdependencies first.
foreach ( $dependency->deps as $sub_dependency_handle ) {
$sub_dependency = $dependencies_of_type->query( $sub_dependency_handle, 'registered' );
if ( $sub_dependency ) {
$this->maybe_output_preload_link_tag( $sub_dependency, $type, $allowlist );
}
}
$this->maybe_output_preload_link_tag( $dependency, $type, $allowlist );
}
}
/**
* Output preload link tags for all enqueued stylesheets and scripts.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*/
private function output_header_preload_tags() {
$wc_admin_scripts = array(
WC_ADMIN_APP,
'wc-components',
);
$wc_admin_styles = array(
WC_ADMIN_APP,
'wc-components',
'wc-material-icons',
);
// Preload styles.
$this->output_header_preload_tags_for_type( 'style', $wc_admin_styles );
// Preload scripts.
$this->output_header_preload_tags_for_type( 'script', $wc_admin_scripts );
}
/**
* Loads the required scripts on the correct pages.
*/
public function enqueue_assets() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
wp_enqueue_script( WC_ADMIN_APP );
wp_enqueue_style( WC_ADMIN_APP );
wp_enqueue_style( 'wc-material-icons' );
wp_enqueue_style( 'wc-onboarding' );
// Preload our assets.
$this->output_header_preload_tags();
}
/**
* Registers all the neccessary scripts and styles to show the admin experience.
*/
public function register_scripts() {
if ( ! function_exists( 'wp_set_script_translations' ) ) {
return;
}
$js_file_version = self::get_file_version( 'js' );
$css_file_version = self::get_file_version( 'css' );
$scripts = array(
'wc-explat',
'wc-experimental',
'wc-customer-effort-score',
// NOTE: This should be removed when Gutenberg is updated and the notices package is removed from WooCommerce Admin.
'wc-notices',
'wc-number',
'wc-tracks',
'wc-date',
'wc-components',
WC_ADMIN_APP,
'wc-csv',
'wc-store-data',
'wc-currency',
'wc-navigation',
);
$scripts_map = array(
WC_ADMIN_APP => 'app',
'wc-csv' => 'csv-export',
'wc-store-data' => 'data',
);
$translated_scripts = array(
'wc-currency',
'wc-date',
'wc-components',
'wc-customer-effort-score',
WC_ADMIN_APP,
);
foreach ( $scripts as $script ) {
$script_path_name = isset( $scripts_map[ $script ] ) ? $scripts_map[ $script ] : str_replace( 'wc-', '', $script );
try {
$script_assets_filename = self::get_script_asset_filename( $script_path_name, 'index' );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
wp_register_script(
$script,
self::get_url( $script_path_name . '/index', 'js' ),
$script_assets ['dependencies'],
$js_file_version,
true
);
if ( in_array( $script, $translated_scripts, true ) ) {
wp_set_script_translations( $script, 'woocommerce' );
}
} catch ( \Exception $e ) {
// Avoid crashing WordPress if an asset file could not be loaded.
wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, $script_path_name );
}
}
wp_register_style(
'wc-components',
self::get_url( 'components/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-components', 'rtl', 'replace' );
wp_register_style(
'wc-customer-effort-score',
self::get_url( 'customer-effort-score/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-customer-effort-score', 'rtl', 'replace' );
wp_register_style(
'wc-experimental',
self::get_url( 'experimental/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-experimental', 'rtl', 'replace' );
wp_localize_script(
WC_ADMIN_APP,
'wcAdminAssets',
array(
'path' => plugins_url( self::get_path( 'js' ), WC_ADMIN_PLUGIN_FILE ),
'version' => $js_file_version,
)
);
wp_register_style(
WC_ADMIN_APP,
self::get_url( 'app/style', 'css' ),
array( 'wc-components', 'wc-customer-effort-score', 'wp-components', 'wc-experimental' ),
$css_file_version
);
wp_style_add_data( WC_ADMIN_APP, 'rtl', 'replace' );
wp_register_style(
'wc-onboarding',
self::get_url( 'onboarding/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-onboarding', 'rtl', 'replace' );
}
/**
* Injects wp-shared-settings as a dependency if it's present.
*/
public function inject_wc_settings_dependencies() {
if ( wp_script_is( 'wc-settings', 'registered' ) ) {
$handles_for_injection = [
'wc-csv',
'wc-currency',
'wc-customer-effort-score',
'wc-navigation',
// NOTE: This should be removed when Gutenberg is updated and
// the notices package is removed from WooCommerce Admin.
'wc-notices',
'wc-number',
'wc-date',
'wc-components',
'wc-tracks',
];
foreach ( $handles_for_injection as $handle ) {
$script = wp_scripts()->query( $handle, 'registered' );
if ( $script instanceof _WP_Dependency ) {
$script->deps[] = 'wc-settings';
}
}
}
}
/**
* Loads a script
*
* @param string $script_path_name The script path name.
* @param string $script_name Filename of the script to load.
* @param bool $need_translation Whether the script need translations.
*/
public static function register_script( $script_path_name, $script_name, $need_translation = false ) {
$script_assets_filename = self::get_script_asset_filename( $script_path_name, $script_name );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
wp_enqueue_script(
'wc-admin-' . $script_name,
self::get_url( $script_path_name . '/' . $script_name, 'js' ),
array_merge( array( WC_ADMIN_APP ), $script_assets ['dependencies'] ),
self::get_file_version( 'js' ),
true
);
if ( $need_translation ) {
wp_set_script_translations( 'wc-admin-' . $script_name, 'woocommerce' );
}
}
}
Admin/WCAdminUser.php 0000644 00000007414 15233366071 0010442 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
/**
* WCAdminUser Class.
*/
class WCAdminUser {
/**
* Class instance.
*
* @var WCAdminUser instance
*/
protected static $instance = null;
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_user_data' ) );
}
/**
* Get class instance.
*
* @return object Instance.
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Registers WooCommerce specific user data to the WordPress user API.
*/
public function register_user_data() {
register_rest_field(
'user',
'is_super_admin',
array(
'get_callback' => function() {
return is_super_admin();
},
'schema' => null,
)
);
register_rest_field(
'user',
'woocommerce_meta',
array(
'get_callback' => array( $this, 'get_user_data_values' ),
'update_callback' => array( $this, 'update_user_data_values' ),
'schema' => null,
)
);
}
/**
* For all the registered user data fields ( Loader::get_user_data_fields ), fetch the data
* for returning via the REST API.
*
* @param WP_User $user Current user.
*/
public function get_user_data_values( $user ) {
$values = array();
foreach ( $this->get_user_data_fields() as $field ) {
$values[ $field ] = self::get_user_data_field( $user['id'], $field );
}
return $values;
}
/**
* For all the registered user data fields ( Loader::get_user_data_fields ), update the data
* for the REST API.
*
* @param array $values The new values for the meta.
* @param WP_User $user The current user.
* @param string $field_id The field id for the user meta.
*/
public function update_user_data_values( $values, $user, $field_id ) {
if ( empty( $values ) || ! is_array( $values ) || 'woocommerce_meta' !== $field_id ) {
return;
}
$fields = $this->get_user_data_fields();
$updates = array();
foreach ( $values as $field => $value ) {
if ( in_array( $field, $fields, true ) ) {
$updates[ $field ] = $value;
self::update_user_data_field( $user->ID, $field, $value );
}
}
return $updates;
}
/**
* We store some WooCommerce specific user meta attached to users endpoint,
* so that we can track certain preferences or values such as the inbox activity panel last open time.
* Additional fields can be added in the function below, and then used via wc-admin's currentUser data.
*
* @return array Fields to expose over the WP user endpoint.
*/
public function get_user_data_fields() {
return apply_filters( 'woocommerce_admin_get_user_data_fields', array() );
}
/**
* Helper to update user data fields.
*
* @param int $user_id User ID.
* @param string $field Field name.
* @param mixed $value Field value.
*/
public static function update_user_data_field( $user_id, $field, $value ) {
update_user_meta( $user_id, 'woocommerce_admin_' . $field, $value );
}
/**
* Helper to retrive user data fields.
*
* Migrates old key prefixes as well.
*
* @param int $user_id User ID.
* @param string $field Field name.
* @return mixed The user field value.
*/
public static function get_user_data_field( $user_id, $field ) {
$meta_value = get_user_meta( $user_id, 'woocommerce_admin_' . $field, true );
// Migrate old meta values (prefix changed from `wc_admin_` to `woocommerce_admin_`).
if ( '' === $meta_value ) {
$old_meta_value = get_user_meta( $user_id, 'wc_admin_' . $field, true );
if ( '' !== $old_meta_value ) {
self::update_user_data_field( $user_id, $field, $old_meta_value );
delete_user_meta( $user_id, 'wc_admin_' . $field );
$meta_value = $old_meta_value;
}
}
return $meta_value;
}
}
Admin/Notes/CustomizeStoreWithBlocks.php 0000644 00000004545 15233366071 0014404 0 ustar 00 <?php
/**
* WooCommerce Admin: Customize your online store with WooCommerce blocks.
*
* Adds a note to customize the client online store with WooCommerce blocks.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Customize_Store_With_Blocks.
*/
class CustomizeStoreWithBlocks {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customize-store-with-blocks';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We want to show the note after fourteen days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 14 * DAY_IN_SECONDS ) ) {
return;
}
// Don't show if there aren't products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Customize your online store with WooCommerce blocks', 'woocommerce' ) );
$note->set_content( __( 'With our blocks, you can select and display products, categories, filters, and more virtually anywhere on your site — no need to use shortcodes or edit lines of code. Learn more about how to use each one of them.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'customize-store-with-blocks',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-customize-your-online-store-with-woocommerce-blocks/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Admin/Notes/OnboardingPayments.php 0000644 00000003364 15233366071 0013214 0 ustar 00 <?php
/**
* WooCommerce Admin: Payments reminder note.
*
* Adds a notes to complete the payment methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Onboarding_Payments.
*/
class OnboardingPayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-onboarding-payments-reminder';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after five days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 5 * DAY_IN_SECONDS ) ) {
return;
}
// Check to see if any gateways have been added.
$gateways = WC()->payment_gateways->get_available_payment_gateways();
$enabled_gateways = array_filter(
$gateways,
function( $gateway ) {
return 'yes' === $gateway->enabled;
}
);
if ( ! empty( $enabled_gateways ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start accepting payments on your store!', 'woocommerce' ) );
$note->set_content( __( 'Take payments with the provider that’s right for you - choose from 100+ payment gateways for WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'view-payment-gateways',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED,
true
);
return $note;
}
}
Admin/Notes/SellingOnlineCourses.php 0000644 00000004603 15233366071 0013514 0 ustar 00 <?php
/**
* WooCommerce Admin: Selling Online Courses note
*
* Adds a note to encourage selling online courses.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
use \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* Selling_Online_Courses
*/
class SellingOnlineCourses {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-selling-online-courses';
/**
* Attach hooks.
*/
public function __construct() {
add_action(
'update_option_' . OnboardingProfile::DATA_OPTION,
array( $this, 'check_onboarding_profile' ),
10,
3
);
}
/**
* Check to see if the profiler options match before possibly adding note.
*
* @param object $old_value The old option value.
* @param object $value The new option value.
* @param string $option The name of the option.
*/
public static function check_onboarding_profile( $old_value, $value, $option ) {
// Skip adding if this store is in the education/learning industry.
if ( ! isset( $value['industry'] ) ) {
return;
}
$industry_slugs = array_column( $value['industry'], 'slug' );
if ( ! in_array( 'education-and-learning', $industry_slugs, true ) ) {
return;
}
self::possibly_add_note();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Do you want to sell online courses?', 'woocommerce' ) );
$note->set_content( __( 'Online courses are a great solution for any business that can teach a new skill. Since courses don’t require physical product development or shipping, they’re affordable, fast to create, and can generate passive income for years to come. In this article, we provide you more information about selling courses using WooCommerce.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-sell-online-courses-wordpress/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Admin/Notes/WelcomeToWooCommerceForStoreUsers.php 0000644 00000003027 15233366071 0016151 0 ustar 00 <?php
/**
* WooCommerce Admin: Welcome to WooCommerce for store users.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Welcome to WooCommerce for store users.
*/
class WelcomeToWooCommerceForStoreUsers {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-welcome-to-woocommerce-for-store-users';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'init', array( $this, 'possibly_add_note' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only add if coming from Calypso.
if ( ! isset( $_GET['from-calypso'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
return;
}
$note = new Note();
$note->set_title( __( 'Welcome to your new store management experience', 'woocommerce' ) );
$note->set_content( __( "We've designed your navigation and home screen to help you focus on the things that matter most in managing your online store.", 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://wordpress.com/support/new-woocommerce-experience-on-wordpress-dot-com/"',
Note::E_WC_ADMIN_NOTE_ACTIONED,
true
);
return $note;
}
}
Admin/Notes/PaymentsMoreInfoNeeded.php 0000644 00000004364 15233366071 0013756 0 ustar 00 <?php
/**
* WooCommerce Admin Payments More Info Needed Inbox Note Provider
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsMoreInfoNeeded
*/
class PaymentsMoreInfoNeeded {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-more-info-needed';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// If user has installed WCPay, don't show this note.
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
if ( in_array( 'woocommerce-payments', $installed_plugins, true ) ) {
return false;
}
// User has dismissed the WCPay Welcome Page.
if ( 'yes' !== get_option( 'wc_calypso_bridge_payments_dismissed', 'no' ) ) {
return false;
}
// More than 30 days since viewing the welcome page.
$exit_survey_timestamp = get_option( 'wc_pay_exit_survey_more_info_needed_timestamp', false );
if ( ! $exit_survey_timestamp ||
( time() - $exit_survey_timestamp < 30 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
$content = __( 'We recently asked you if you wanted more information about WooCommerce Payments. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Payments made simple with WooCommerce Payments', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more here', 'woocommerce' ), 'https://woocommerce.com/payments/' );
return $note;
}
}
Admin/Notes/MigrateFromShopify.php 0000644 00000004330 15233366071 0013161 0 ustar 00 <?php
/**
* WooCommerce Admin: Migrate from Shopify to WooCommerce.
*
* Adds a note to ask the client if they want to migrate from Shopify to WooCommerce.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Migrate_From_Shopify.
*/
class MigrateFromShopify {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-migrate-from-shopify';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
$two_days = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
if (
! isset( $onboarding_profile['setup_client'] ) ||
! isset( $onboarding_profile['selling_venues'] ) ||
! isset( $onboarding_profile['other_platform'] )
) {
return;
}
// Make sure the client is not setup.
if ( $onboarding_profile['setup_client'] ) {
return;
}
// We will show the notification when the client already is selling and is using Shopify.
if (
'other' !== $onboarding_profile['selling_venues'] ||
'shopify' !== $onboarding_profile['other_platform']
) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you want to migrate from Shopify to WooCommerce?', 'woocommerce' ) );
$note->set_content( __( 'Changing eCommerce platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'migrate-from-shopify',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/migrate-from-shopify-to-woocommerce/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Admin/Notes/MarketingJetpack.php 0000644 00000007265 15233366071 0012640 0 ustar 00 <?php
/**
* WooCommerce Admin Jetpack Marketing Note Provider.
*
* Adds notes to the merchant's inbox concerning Jetpack Backup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Suggest Jetpack Backup to Woo users.
*
* Note: This should probably live in the Jetpack plugin in the future.
*
* @see https://developer.woocommerce.com/2020/10/16/using-the-admin-notes-inbox-in-woocommerce/
*/
class MarketingJetpack {
// Shared Note Traits.
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-marketing-jetpack-backup';
/**
* Product IDs that include Backup.
*/
const BACKUP_IDS = [
2010,
2011,
2012,
2013,
2014,
2015,
2100,
2101,
2102,
2103,
2005,
2006,
2000,
2003,
2001,
2004,
];
/**
* Maybe add a note on Jetpack Backups for Jetpack sites older than a week without Backups.
*/
public static function possibly_add_note() {
/**
* Check if Jetpack is installed.
*/
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
if ( ! in_array( 'jetpack', $installed_plugins, true ) ) {
return;
}
$data_store = \WC_Data_Store::load( 'admin-note' );
// Do we already have this note?
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If Jetpack Backups was purchased after the note was created, mark this note as actioned.
if ( self::has_backups() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
// Check requirements.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', DAY_IN_SECONDS * 3 ) || ! self::can_be_added() || self::has_backups() ) {
return;
}
// Add note.
$note = self::get_note();
$note->save();
}
/**
* Get the note.
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Protect your WooCommerce Store with Jetpack Backup.', 'woocommerce' ) );
$note->set_content( __( 'Store downtime means lost sales. One-click restores get you back online quickly if something goes wrong.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_layout( 'thumbnail' );
$note->set_image(
WC_ADMIN_IMAGES_FOLDER_URL . '/admin_notes/marketing-jetpack-2x.png'
);
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin-notes' );
$note->add_action(
'jetpack-backup-woocommerce',
__( 'Get backups', 'woocommerce' ),
esc_url( 'https://jetpack.com/upgrade/backup-woocommerce/?utm_source=inbox&utm_medium=automattic_referred&utm_campaign=jp_backup_to_woo' ),
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Check if this blog already has a Jetpack Backups product.
*
* @return boolean Whether or not this blog has backups.
*/
protected static function has_backups() {
$product_ids = [];
$plan = get_option( 'jetpack_active_plan' );
if ( ! empty( $plan ) ) {
$product_ids[] = $plan['product_id'];
}
$products = get_option( 'jetpack_site_products' );
if ( ! empty( $products ) ) {
foreach ( $products as $product ) {
$product_ids[] = $product['product_id'];
}
}
return (bool) array_intersect( self::BACKUP_IDS, $product_ids );
}
}
Admin/Notes/AddFirstProduct.php 0000644 00000005447 15233366071 0012456 0 ustar 00 <?php
/**
* WooCommerce Admin: Add First Product.
*
* Adds a note (type `email`) to bring the client back to the store setup flow.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Add_First_Product.
*/
class AddFirstProduct {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-add-first-product-note';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::wc_admin_active_for( 2 * DAY_IN_SECONDS ) || self::wc_admin_active_for( 5 * DAY_IN_SECONDS ) ) {
return;
}
// Don't show if there is a product.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
if ( 0 !== count( $products ) ) {
return;
}
// Don't show if there is an orders.
$args = array(
'limit' => 1,
'return' => 'ids',
);
$orders = wc_get_orders( $args );
if ( 0 !== count( $orders ) ) {
return;
}
// If you're updating the following please use sprintf to separate HTML tags.
// https://github.com/woocommerce/woocommerce-admin/pull/6617#discussion_r596889685.
$content_lines = array(
'{greetings}<br/><br/>',
/* translators: %s: line break */
sprintf( __( 'Nice one; you\'ve created a WooCommerce store! Now it\'s time to add your first product and get ready to start selling.%s', 'woocommerce' ), '<br/><br/>' ),
__( 'There are three ways to add your products: you can <strong>create products manually, import them at once via CSV file</strong>, or <strong>migrate them from another service</strong>.<br/><br/>', 'woocommerce' ),
/* translators: %1$s is an open anchor tag (<a>) and %2$s is a close link tag (</a>). */
sprintf( __( '%1$1sExplore our docs%2$2s for more information, or just get started!', 'woocommerce' ), '<a href="https://woocommerce.com/document/managing-products/?utm_source=help_panel&utm_medium=product">', '</a>' ),
);
$additional_data = array(
'role' => 'administrator',
);
$note = new Note();
$note->set_title( __( 'Add your first product', 'woocommerce' ) );
$note->set_content( implode( '', $content_lines ) );
$note->set_content_data( (object) $additional_data );
$note->set_image(
plugins_url(
'/images/admin_notes/dashboard-widget-setup.png',
WC_ADMIN_PLUGIN_FILE
)
);
$note->set_type( Note::E_WC_ADMIN_NOTE_EMAIL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'add-first-product', __( 'Add a product', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&task=products' ) );
return $note;
}
}
Admin/Notes/TestCheckout.php 0000644 00000005331 15233366071 0012012 0 ustar 00 <?php
/**
* WooCommerce Admin Test Checkout.
*
* Adds a note to remind the user to test their store checkout.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Test_Checkout
*/
class TestCheckout {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-test-checkout';
/**
* Completed tasks option name.
*/
const TASK_LIST_TRACKED_TASKS = 'woocommerce_task_list_tracked_completed_tasks';
/**
* Constructor.
*/
public function __construct() {
add_action( 'update_option_' . self::TASK_LIST_TRACKED_TASKS, array( $this, 'possibly_add_note' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Make sure payments task was completed.
$completed_tasks = get_option( self::TASK_LIST_TRACKED_TASKS, array() );
if ( ! in_array( 'payments', $completed_tasks, true ) ) {
return;
}
// Make sure that products were added within the previous 1/2 hour.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'status' => 'publish',
'orderby' => 'date',
'order' => 'ASC',
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$oldest_product_timestamp = $products[0]->get_date_created()->getTimestamp();
$half_hour_in_seconds = 30 * MINUTE_IN_SECONDS;
if ( ( time() - $oldest_product_timestamp ) > $half_hour_in_seconds ) {
return;
}
$content = __( 'Make sure that your checkout is working properly before you launch your store. Go through your checkout process in its entirety: from adding a product to your cart, choosing a shipping location, and making a payment.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Don\'t forget to test your checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'test-checkout', __( 'Test checkout', 'woocommerce' ), wc_get_page_permalink( 'shop' ) );
return $note;
}
}
Admin/Notes/TrackingOptIn.php 0000644 00000005407 15233366071 0012125 0 ustar 00 <?php
/**
* WooCommerce Admin Usage Tracking Opt In Note Provider.
*
* Adds a Usage Tracking Opt In extension note.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Tracking_Opt_In
*/
class TrackingOptIn {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-usage-tracking-opt-in';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'woocommerce_note_action_tracking-opt-in', array( $this, 'opt_in_to_tracking' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only show this note to stores that are opted out.
if ( 'yes' === get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
// We want to show the note after one week.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
/* translators: 1: open link to WooCommerce.com settings, 2: open link to WooCommerce.com tracking documentation, 3: close link tag. */
$content_format = __(
'Gathering usage data allows us to improve WooCommerce. Your store will be considered as we evaluate new features, judge the quality of an update, or determine if an improvement makes sense. You can always visit the %1$sSettings%3$s and choose to stop sharing data. %2$sRead more%3$s about what data we collect.',
'woocommerce'
);
$note_content = sprintf(
$content_format,
'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=woocommerce_com' ) ) . '" target="_blank">',
'<a href="https://woocommerce.com/usage-tracking?utm_medium=product" target="_blank">',
'</a>'
);
$note = new Note();
$note->set_title( __( 'Help WooCommerce improve with usage tracking', 'woocommerce' ) );
$note->set_content( $note_content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'tracking-opt-in', __( 'Activate usage tracking', 'woocommerce' ), false, Note::E_WC_ADMIN_NOTE_ACTIONED, true );
return $note;
}
/**
* Opt in to usage tracking when note is actioned.
*
* @param Note $note Note being acted upon.
*/
public function opt_in_to_tracking( $note ) {
if ( self::NOTE_NAME === $note->get_name() ) {
// Opt in to tracking and schedule the first data update.
// Same mechanism as in WC_Admin_Setup_Wizard::wc_setup_store_setup_save().
update_option( 'woocommerce_allow_tracking', 'yes' );
wp_schedule_single_event( time() + 10, 'woocommerce_tracker_send_event', array( true ) );
}
}
}
Admin/Notes/MerchantEmailNotifications.php 0000644 00000006601 15233366071 0014651 0 ustar 00 <?php
/**
* Handles merchant email notifications
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
defined( 'ABSPATH' ) || exit;
/**
* Merchant email notifications.
* This gets all non-sent notes type `email` and sends them.
*/
class MerchantEmailNotifications {
/**
* Initialize the merchant email notifications.
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'trigger_notification_action' ) );
}
/**
* Trigger the note action.
*/
public static function trigger_notification_action() {
/* phpcs:disable WordPress.Security.NonceVerification */
if (
! isset( $_GET['external_redirect'] ) ||
1 !== intval( $_GET['external_redirect'] ) ||
! isset( $_GET['user'] ) ||
! isset( $_GET['note'] ) ||
! isset( $_GET['action'] )
) {
return;
}
$note_id = intval( $_GET['note'] );
$action_id = intval( $_GET['action'] );
$user_id = intval( $_GET['user'] );
/* phpcs:enable */
$note = Notes::get_note( $note_id );
if ( ! $note || Note::E_WC_ADMIN_NOTE_EMAIL !== $note->get_type() ) {
return;
}
$triggered_action = Notes::get_action_by_id( $note, $action_id );
if ( ! $triggered_action ) {
return;
}
Notes::trigger_note_action( $note, $triggered_action );
$url = $triggered_action->query;
// We will use "wp_safe_redirect" when it's an internal redirect.
if ( strpos( $url, 'http' ) === false ) {
wp_safe_redirect( $url );
} else {
header( 'Location: ' . $url );
}
exit();
}
/**
* Send all the notifications type `email`.
*/
public static function run() {
$data_store = Notes::load_data_store();
$notes = $data_store->get_notes(
array(
'type' => array( Note::E_WC_ADMIN_NOTE_EMAIL ),
'status' => array( 'unactioned' ),
)
);
foreach ( $notes as $note ) {
$note = Notes::get_note( $note->note_id );
if ( $note ) {
self::send_merchant_notification( $note );
$note->set_status( 'sent' );
$note->save();
}
}
}
/**
* Send the notification to the merchant.
*
* @param object $note The note to send.
*/
public static function send_merchant_notification( $note ) {
\WC_Emails::instance();
$users = self::get_notification_recipients( $note );
$email = new EmailNotification( $note );
foreach ( $users as $user ) {
if ( is_email( $user->user_email ) ) {
$name = self::get_merchant_preferred_name( $user );
$email->trigger( $user->user_email, $user->ID, $name );
}
}
}
/**
* Get the preferred name for user. First choice is
* the user's first name, and then display_name.
*
* @param WP_User $user Recipient to send the note to.
* @return string User's name.
*/
public static function get_merchant_preferred_name( $user ) {
$first_name = get_user_meta( $user->ID, 'first_name', true );
if ( $first_name ) {
return $first_name;
}
if ( $user->display_name ) {
return $user->display_name;
}
return '';
}
/**
* Get users by role to notify.
*
* @param object $note The note to send.
* @return array Users to notify
*/
public static function get_notification_recipients( $note ) {
$content_data = $note->get_content_data();
$role = 'administrator';
if ( isset( $content_data->role ) ) {
$role = $content_data->role;
}
$args = array( 'role' => $role );
return get_users( $args );
}
}
Admin/Notes/FirstProduct.php 0000644 00000004177 15233366071 0012044 0 ustar 00 <?php
/**
* WooCommerce Admin: Do you need help with adding your first product?
*
* Adds a note to ask the client if they need help adding their first product.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* First_Product.
*/
class FirstProduct {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-first-product';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after seven days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Don't show if there are products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$count = $products->total;
if ( 0 !== $count ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need help with adding your first product?', 'woocommerce' ) );
$note->set_content( __( 'This video tutorial will help you go through the process of adding your first product in WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'first-product-watch-tutorial',
__( 'Watch tutorial', 'woocommerce' ),
'https://www.youtube.com/watch?v=sFtXa00Jf_o&list=PLHdG8zvZd0E575Ia8Mu3w1h750YLXNfsC&index=24'
);
return $note;
}
}
Admin/Notes/CustomizingProductCatalog.php 0000644 00000004217 15233366071 0014556 0 ustar 00 <?php
/**
* WooCommerce Admin: How to customize your product catalog note provider
*
* Adds a note with a link to the customizer a day after adding the first product
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Class CustomizingProductCatalog
*
* @package Automattic\WooCommerce\Admin\Notes
*/
class CustomizingProductCatalog {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customizing-product-catalog';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'status' => array( 'publish' ),
'orderby' => 'post_date',
'order' => 'DESC',
)
);
$products = $query->get_products();
// we need at least 1 product.
if ( 0 === $products->total ) {
return;
}
$product = $products->products[0];
$created_timestamp = $product->get_date_created()->getTimestamp();
$is_a_day_old = ( time() - $created_timestamp ) >= DAY_IN_SECONDS;
// the product must be at least 1 day old.
if ( ! $is_a_day_old ) {
return;
}
// store must not been active more than 14 days.
if ( self::wc_admin_active_for( DAY_IN_SECONDS * 14 ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'How to customize your product catalog', 'woocommerce' ) );
$note->set_content( __( 'You want your product catalog and images to look great and align with your brand. This guide will give you all the tips you need to get your products looking great in your store.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'day-after-first-product',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/document/woocommerce-customizer/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Admin/Notes/OrderMilestones.php 0000644 00000022230 15233366071 0012520 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) Order Milestones Note Provider.
*
* Adds a note to the merchant's inbox when certain order milestones are reached.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Order_Milestones
*/
class OrderMilestones {
/**
* Name of the "other milestones" note.
*/
const NOTE_NAME = 'wc-admin-orders-milestone';
/**
* Option key name to store last order milestone.
*/
const LAST_ORDER_MILESTONE_OPTION_KEY = 'woocommerce_admin_last_orders_milestone';
/**
* Hook to process order milestones.
*/
const PROCESS_ORDERS_MILESTONE_HOOK = 'wc_admin_process_orders_milestone';
/**
* Allowed order statuses for calculating milestones.
*
* @var array
*/
protected $allowed_statuses = array(
'pending',
'processing',
'completed',
);
/**
* Orders count cache.
*
* @var int
*/
protected $orders_count = null;
/**
* Further order milestone thresholds.
*
* @var array
*/
protected $milestones = array(
1,
10,
100,
250,
500,
1000,
5000,
10000,
500000,
1000000,
);
/**
* Delay hook attachment until after the WC post types have been registered.
*
* This is required for retrieving the order count.
*/
public function __construct() {
/**
* Filter Order statuses that will count towards milestones.
*
* @since 3.5.0
*
* @param array $allowed_statuses Order statuses that will count towards milestones.
*/
$this->allowed_statuses = apply_filters( 'woocommerce_admin_order_milestone_statuses', $this->allowed_statuses );
add_action( 'woocommerce_after_register_post_type', array( $this, 'init' ) );
register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'clear_scheduled_event' ) );
}
/**
* Hook everything up.
*/
public function init() {
if ( ! wp_next_scheduled( self::PROCESS_ORDERS_MILESTONE_HOOK ) ) {
wp_schedule_event( time(), 'hourly', self::PROCESS_ORDERS_MILESTONE_HOOK );
}
add_action( 'wc_admin_installed', array( $this, 'backfill_last_milestone' ) );
add_action( self::PROCESS_ORDERS_MILESTONE_HOOK, array( $this, 'possibly_add_note' ) );
}
/**
* Clear out our hourly milestone hook upon plugin deactivation.
*/
public function clear_scheduled_event() {
wp_clear_scheduled_hook( self::PROCESS_ORDERS_MILESTONE_HOOK );
}
/**
* Get the total count of orders (in the allowed statuses).
*
* @param bool $no_cache Optional. Skip cache.
* @return int Total orders count.
*/
public function get_orders_count( $no_cache = false ) {
if ( $no_cache || is_null( $this->orders_count ) ) {
$status_counts = array_map( 'wc_orders_count', $this->allowed_statuses );
$this->orders_count = array_sum( $status_counts );
}
return $this->orders_count;
}
/**
* Backfill the store's current milestone.
*
* Used to avoid celebrating milestones that were reached before plugin activation.
*/
public function backfill_last_milestone() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return;
}
$this->set_last_milestone( $this->get_current_milestone() );
}
/**
* Get the store's last milestone.
*
* @return int Last milestone reached.
*/
public function get_last_milestone() {
return get_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, 0 );
}
/**
* Update the last reached milestone.
*
* @param int $milestone Last milestone reached.
*/
public function set_last_milestone( $milestone ) {
update_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, $milestone );
}
/**
* Calculate the current orders milestone.
*
* Based on the threshold values in $this->milestones.
*
* @return int Current orders milestone.
*/
public function get_current_milestone() {
$milestone_reached = 0;
$orders_count = $this->get_orders_count();
foreach ( $this->milestones as $milestone ) {
if ( $milestone <= $orders_count ) {
$milestone_reached = $milestone;
}
}
return $milestone_reached;
}
/**
* Get the appropriate note title for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note title for the milestone.
*/
public static function get_note_title_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'First order received', 'woocommerce' );
case 10:
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return sprintf(
/* translators: Number of orders processed. */
__( 'Congratulations on processing %s orders!', 'woocommerce' ),
wc_format_decimal( $milestone )
);
default:
return '';
}
}
/**
* Get the appropriate note content for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note content for the milestone.
*/
public static function get_note_content_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'Congratulations on getting your first order! Now is a great time to learn how to manage your orders.', 'woocommerce' );
case 10:
return __( "You've hit the 10 orders milestone! Look at you go. Browse some WooCommerce success stories for inspiration.", 'woocommerce' );
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return __( 'Another order milestone! Take a look at your Orders Report to review your orders to date.', 'woocommerce' );
default:
return '';
}
}
/**
* Get the appropriate note action for a given milestone.
*
* @param int $milestone Order milestone.
* @return array Note actoion (name, label, query) for the milestone.
*/
public static function get_note_action_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return array(
'name' => 'learn-more',
'label' => __( 'Learn more', 'woocommerce' ),
'query' => 'https://woocommerce.com/document/managing-orders/?utm_source=inbox&utm_medium=product',
);
case 10:
return array(
'name' => 'browse',
'label' => __( 'Browse', 'woocommerce' ),
'query' => 'https://woocommerce.com/success-stories/?utm_source=inbox&utm_medium=product',
);
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return array(
'name' => 'review-orders',
'label' => __( 'Review your orders', 'woocommerce' ),
'query' => '?page=wc-admin&path=/analytics/orders',
);
default:
return array(
'name' => '',
'label' => '',
'query' => '',
);
}
}
/**
* Convenience method to see if the milestone notes are enabled.
*
* @return boolean True if milestone notifications are enabled.
*/
public function are_milestones_enabled() {
/**
* Filter to allow for disabling order milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$milestone_notes_enabled = apply_filters( 'woocommerce_admin_order_milestones_enabled', true );
return $milestone_notes_enabled;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
if ( ! isset( $content_data->current_milestone ) ) {
return false;
}
return self::get_note_by_milestone(
$content_data->current_milestone
);
}
/**
* Get the note by milestones.
*
* @param int $current_milestone Current milestone.
*
* @return Note
*/
public static function get_note_by_milestone( $current_milestone ) {
$content_data = (object) array(
'current_milestone' => $current_milestone,
);
$note = new Note();
$note->set_title( self::get_note_title_for_milestone( $current_milestone ) );
$note->set_content( self::get_note_content_for_milestone( $current_milestone ) );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note_action = self::get_note_action_for_milestone( $current_milestone );
$note->add_action( $note_action['name'], $note_action['label'], $note_action['query'] );
return $note;
}
/**
* Checks if a note can and should be added.
*
* @return bool
*/
public function can_be_added() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return false;
}
$last_milestone = $this->get_last_milestone();
$current_milestone = $this->get_current_milestone();
if ( $current_milestone <= $last_milestone ) {
return false;
}
return true;
}
/**
* Add milestone notes for other significant thresholds.
*/
public function possibly_add_note() {
if ( ! self::can_be_added() ) {
return;
}
$current_milestone = $this->get_current_milestone();
$this->set_last_milestone( $current_milestone );
// We only want one milestone note at any time.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = $this->get_note_by_milestone( $current_milestone );
$note->save();
}
}
Admin/Notes/LaunchChecklist.php 0000644 00000003270 15233366071 0012451 0 ustar 00 <?php
/**
* WooCommerce Admin Launch Checklist Note.
*
* Adds a note to cover pre-launch checklist items for store owners.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Launch_Checklist
*/
class LaunchChecklist {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-launch-checklist';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if completing the task list or completed 3 tasks in 10 days.
$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
$ten_days_in_seconds = 10 * DAY_IN_SECONDS;
if (
! get_option( 'woocommerce_task_list_complete' ) &&
(
count( $completed_tasks ) < 3 ||
self::is_wc_admin_active_in_date_range( 'week-1-4', $ten_days_in_seconds )
)
) {
return;
}
$content = __( 'To make sure you never get that sinking "what did I forget" feeling, we\'ve put together the essential pre-launch checklist.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Ready to launch your store?', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/posts/pre-launch-checklist-the-essentials/?utm_source=inbox&utm_medium=product' );
return $note;
}
}
Admin/Notes/RealTimeOrderAlerts.php 0000644 00000003014 15233366071 0013252 0 ustar 00 <?php
/**
* WooCommerce Admin Real Time Order Alerts Note.
*
* Adds a note to download the mobile app to monitor store activity.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Real_Time_Order_Alerts
*/
class RealTimeOrderAlerts {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-real-time-order-alerts';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if the store is 3 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-3-6' ) ) {
return;
}
// Check that the previous mobile app note was not actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
$content = __( 'Get notifications about store activity, including new orders and product reviews directly on your mobile devices with the Woo app.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Get real-time order alerts anywhere', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product' );
return $note;
}
}
Admin/Notes/CouponPageMoved.php 0000644 00000007471 15233366071 0012447 0 ustar 00 <?php
/**
* WooCommerce Admin Coupon Page Moved provider.
*
* Adds a notice when the store manager access the coupons page via the old WooCommerce > Coupons menu.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\CouponsMovedTrait;
use stdClass;
use WC_Data_Store;
/**
* Coupon_Page_Moved class.
*/
class CouponPageMoved {
use NoteTraits, CouponsMovedTrait;
const NOTE_NAME = 'wc-admin-coupon-page-moved';
/**
* Initialize our hooks.
*/
public function init() {
if ( ! wc_coupons_enabled() ) {
return;
}
add_action( 'admin_init', [ $this, 'possibly_add_note' ] );
add_action( 'admin_init', [ $this, 'redirect_to_coupons' ] );
add_action( 'woocommerce_admin_newly_installed', [ $this, 'disable_legacy_menu_for_new_install' ] );
}
/**
* Checks if a note can and should be added.
*
* @return bool
*/
public static function can_be_added() {
if ( ! wc_coupons_enabled() ) {
return false;
}
// Don't add the notice if the legacy coupon menu is already disabled.
if ( ! self::should_display_legacy_menu() ) {
return false;
}
// Don't add the notice if it's been hidden by the user before.
if ( self::has_dismissed_note() ) {
return false;
}
// If we already have a notice, don't add a new one.
if ( self::has_unactioned_note() ) {
return false;
}
return isset( $_GET[ self::$query_key ] ) && (bool) $_GET[ self::$query_key ]; // phpcs:ignore WordPress.Security.NonceVerification
}
/**
* Get the note object for this class.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Coupon management has moved!', 'woocommerce' ) );
$note->set_content( __( 'Coupons can now be managed from Marketing > Coupons. Click the button below to remove the legacy WooCommerce > Coupons menu item.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_UPDATE );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( new stdClass() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'remove-legacy-coupon-menu',
__( 'Remove legacy coupon menu', 'woocommerce' ),
wc_admin_url( '&action=remove-coupon-menu' ),
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Find notes that have not been actioned.
*
* @return bool
*/
protected static function has_unactioned_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
return $note->get_status() === 'unactioned';
}
/**
* Whether any notes have been dismissed by the user previously.
*
* @return bool
*/
protected static function has_dismissed_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
return ! $note->get_is_deleted();
}
/**
* Get the data store object.
*
* @return DataStore The data store object.
*/
protected static function get_data_store() {
return WC_Data_Store::load( 'admin-note' );
}
/**
* Safe redirect to the coupon page to force page refresh.
*/
public function redirect_to_coupons() {
/* phpcs:disable WordPress.Security.NonceVerification */
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['action'] ) ||
'remove-coupon-menu' !== $_GET['action'] ||
! defined( 'WC_ADMIN_PLUGIN_FILE' )
) {
return;
}
/* phpcs:enable */
$this->display_legacy_menu( false );
wp_safe_redirect( self::get_management_url( 'coupons' ) );
exit;
}
/**
* Disable legacy coupon menu when installing for the first time.
*/
public function disable_legacy_menu_for_new_install() {
$this->display_legacy_menu( false );
}
}
Admin/Notes/InstallJPAndWCSPlugins.php 0000644 00000011126 15233366071 0013606 0 ustar 00 <?php
/**
* WooCommerce Admin Add Install Jetpack and WooCommerce Shipping & Tax Plugin Note Provider.
*
* Adds a note to the merchant's inbox prompting them to install the Jetpack
* and WooCommerce Shipping & Tax plugins after it fails to install during
* WooCommerce setup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\Notes;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
use \Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Install_JP_And_WCS_Plugins
*/
class InstallJPAndWCSPlugins {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-install-jp-and-wcs-plugins';
/**
* Constructor.
*/
public function __construct() {
add_action( 'woocommerce_note_action_install-jp-and-wcs-plugins', array( $this, 'install_jp_and_wcs_plugins' ) );
add_action( 'activated_plugin', array( $this, 'action_note' ) );
add_action( 'woocommerce_plugins_install_api_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_install_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_activate_error', array( $this, 'on_install_error' ) );
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$content = __( 'We noticed that there was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again and enjoy all the advantages of having the plugins connected to your store! Sorry for the inconvenience. The "Jetpack" and "WooCommerce Shipping & Tax" plugins will be installed & activated for free.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Uh oh... There was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again.', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'install-jp-and-wcs-plugins',
__( 'Install plugins', 'woocommerce' ),
false,
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Action the Install Jetpack and WooCommerce Shipping & Tax note, if any exists,
* and as long as both the Jetpack and WooCommerce Shipping & Tax plugins have been
* activated.
*/
public static function action_note() {
// Make sure that both plugins are active before actioning the note.
$active_plugin_slugs = PluginsHelper::get_active_plugin_slugs();
$jp_active = in_array( 'jetpack', $active_plugin_slugs, true );
$wcs_active = in_array( 'woocommerce-services', $active_plugin_slugs, true );
if ( ! $jp_active || ! $wcs_active ) {
return;
}
// Action any notes with a matching name.
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
foreach ( $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
if ( $note ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
}
}
/**
* Install the Jetpack and WooCommerce Shipping & Tax plugins in response to the action
* being clicked in the admin note.
*
* @param Note $note The note being actioned.
*/
public function install_jp_and_wcs_plugins( $note ) {
if ( self::NOTE_NAME !== $note->get_name() ) {
return;
}
$this->install_and_activate_plugin( 'jetpack' );
$this->install_and_activate_plugin( 'woocommerce-services' );
}
/**
* Installs and activates the specified plugin.
*
* @param string $plugin The plugin slug.
*/
private function install_and_activate_plugin( $plugin ) {
$install_request = array( 'plugin' => $plugin );
$installer = new \Automattic\WooCommerce\Admin\API\OnboardingPlugins();
$result = $installer->install_plugin( $install_request );
// @todo Use the error statuses to decide whether or not to action the note.
if ( is_wp_error( $result ) ) {
return;
}
$activate_request = array( 'plugins' => $plugin );
$installer->activate_plugins( $activate_request );
}
/**
* Create an alert notification in response to an error installing a plugin.
*
* @param string $slug The slug of the plugin being installed.
*/
public function on_install_error( $slug ) {
// Exit early if we're not installing the Jetpack or the WooCommerce Shipping & Tax plugins.
if ( 'jetpack' !== $slug && 'woocommerce-services' !== $slug ) {
return;
}
self::possibly_add_note();
}
}
Admin/Notes/PerformanceOnMobile.php 0000644 00000003217 15233366071 0013274 0 ustar 00 <?php
/**
* WooCommerce Admin Performance on mobile note.
*
* Adds a note to download the mobile app, performance on mobile.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Performance_On_Mobile
*/
class PerformanceOnMobile {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-performance-on-mobile';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least 9 months old.
$nine_months_in_seconds = MONTH_IN_SECONDS * 9;
if ( ! self::wc_admin_active_for( $nine_months_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Track your store performance on mobile', 'woocommerce' ) );
$note->set_content( __( 'Monitor your sales and high performing products with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Admin/Notes/EditProductsOnTheMove.php 0000644 00000003266 15233366071 0013610 0 ustar 00 <?php
/**
* WooCommerce Admin Edit products on the move note.
*
* Adds a note to download the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Edit_Products_On_The_Move
*/
class EditProductsOnTheMove {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-edit-products-on-the-move';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least a year old.
$year_in_seconds = 365 * DAY_IN_SECONDS;
if ( ! self::wc_admin_active_for( $year_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
if ( PerformanceOnMobile::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Edit products on the move', 'woocommerce' ) );
$note->set_content( __( 'Edit and create new products from your mobile devices with the Woo app', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Admin/Notes/GivingFeedbackNotes.php 0000644 00000003002 15233366071 0013237 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) Giving feedback notes provider
*
* Adds notes to the merchant's inbox about giving feedback.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Survey;
/**
* Giving_Feedback_Notes
*/
class GivingFeedbackNotes {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-store-notice-giving-feedback-2';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
// Otherwise, create our new note.
$note = new Note();
$note->set_title( __( 'You\'re invited to share your experience', 'woocommerce' ) );
$note->set_content( __( 'Now that you’ve chosen us as a partner, our goal is to make sure we\'re providing the right tools to meet your needs. We\'re looking forward to having your feedback on the store setup experience so we can improve it in the future.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'share-feedback',
__( 'Share feedback', 'woocommerce' ),
Survey::get_url( '/store-setup-survey' )
);
return $note;
}
}
Admin/Notes/ChoosingTheme.php 0000644 00000002710 15233366071 0012137 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) choosing a theme note
*
* Adds notes to the merchant's inbox about choosing a theme.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Giving_Feedback_Notes
*/
class ChoosingTheme {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-choosing-a-theme';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We need to show choosing a theme notification after 1 day of install.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
return;
}
// Otherwise, create our new note.
$note = new Note();
$note->set_title( __( 'Choosing a theme?', 'woocommerce' ) );
$note->set_content( __( 'Check out the themes that are compatible with WooCommerce and choose one aligned with your brand and business needs.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'visit-the-theme-marketplace',
__( 'Visit the theme marketplace', 'woocommerce' ),
'https://woocommerce.com/product-category/themes/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Admin/Notes/ManageOrdersOnTheGo.php 0000644 00000003053 15233366071 0013177 0 ustar 00 <?php
/**
* WooCommerce Admin Manage orders on the go note.
*
* Adds a note to download the mobile app to manage orders on the go.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Manage_Orders_On_The_Go
*/
class ManageOrdersOnTheGo {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-manage-orders-on-the-go';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only add this note if this store is at least 6 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-6+' ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Manage your orders on the go', 'woocommerce' ) );
$note->set_content( __( 'Look for orders, customer info, and process refunds in one click with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Admin/Notes/WooSubscriptionsNotes.php 0000644 00000033601 15233366071 0013753 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) WooCommerce.com Extension Subscriptions Note Provider.
*
* Adds notes to the merchant's inbox concerning WooCommerce.com extension subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Woo_Subscriptions_Notes
*/
class WooSubscriptionsNotes {
const LAST_REFRESH_OPTION_KEY = 'woocommerce_admin-wc-helper-last-refresh';
const NOTE_NAME = 'wc-admin-wc-helper-connection';
const CONNECTION_NOTE_NAME = 'wc-admin-wc-helper-connection';
const SUBSCRIPTION_NOTE_NAME = 'wc-admin-wc-helper-subscription';
const NOTIFY_WHEN_DAYS_LEFT = 60;
/**
* We want to bubble up expiration notices when they cross certain age
* thresholds. PHP 5.2 doesn't support constant arrays, so we do this.
*
* @return array
*/
private function get_bump_thresholds() {
return array( 60, 45, 20, 7, 1 ); // days.
}
/**
* Hook all the things.
*/
public function __construct() {
add_action( 'admin_init', array( $this, 'admin_init' ) );
add_action( 'update_option_woocommerce_helper_data', array( $this, 'update_option_woocommerce_helper_data' ), 10, 2 );
}
/**
* Reacts to changes in the helper option.
*
* @param array $old_value The previous value of the option.
* @param array $value The new value of the option.
*/
public function update_option_woocommerce_helper_data( $old_value, $value ) {
if ( ! is_array( $old_value ) ) {
$old_value = array();
}
if ( ! is_array( $value ) ) {
$value = array();
}
$old_auth = array_key_exists( 'auth', $old_value ) ? $old_value['auth'] : array();
$new_auth = array_key_exists( 'auth', $value ) ? $value['auth'] : array();
$old_token = array_key_exists( 'access_token', $old_auth ) ? $old_auth['access_token'] : '';
$new_token = array_key_exists( 'access_token', $new_auth ) ? $new_auth['access_token'] : '';
// The site just disconnected.
if ( ! empty( $old_token ) && empty( $new_token ) ) {
$this->remove_notes();
$this->add_no_connection_note();
return;
}
// The site is connected.
if ( $this->is_connected() ) {
$this->remove_notes();
$this->refresh_subscription_notes();
return;
}
}
/**
* Things to do on admin_init.
*/
public function admin_init() {
$this->check_connection();
if ( $this->is_connected() ) {
$refresh_notes = false;
// Did the user just do something on the helper page?.
if ( isset( $_GET['wc-helper-status'] ) ) { // @codingStandardsIgnoreLine.
$refresh_notes = true;
}
// Has it been more than a day since we last checked?
// Note: We do it this way and not wp_scheduled_task since WC_Helper_Options is not loaded for cron.
$time_now_gmt = current_time( 'timestamp', 0 );
$last_refresh = intval( get_option( self::LAST_REFRESH_OPTION_KEY, 0 ) );
if ( $last_refresh + DAY_IN_SECONDS <= $time_now_gmt ) {
update_option( self::LAST_REFRESH_OPTION_KEY, $time_now_gmt );
$refresh_notes = true;
}
if ( $refresh_notes ) {
$this->refresh_subscription_notes();
}
}
}
/**
* Checks the connection. Adds a note (as necessary) if there is no connection.
*/
public function check_connection() {
if ( ! $this->is_connected() ) {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::CONNECTION_NOTE_NAME );
if ( ! empty( $note_ids ) ) {
// We already have a connection note. Exit early.
return;
}
$this->remove_notes();
$this->add_no_connection_note();
}
}
/**
* Whether or not we think the site is currently connected to WooCommerce.com.
*
* @return bool
*/
public function is_connected() {
$auth = \WC_Helper_Options::get( 'auth' );
return ( ! empty( $auth['access_token'] ) );
}
/**
* Returns the WooCommerce.com provided site ID for this site.
*
* @return int|false
*/
public function get_connected_site_id() {
if ( ! $this->is_connected() ) {
return false;
}
$auth = \WC_Helper_Options::get( 'auth' );
return absint( $auth['site_id'] );
}
/**
* Returns an array of product_ids whose subscriptions are active on this site.
*
* @return array
*/
public function get_subscription_active_product_ids() {
$site_id = $this->get_connected_site_id();
if ( ! $site_id ) {
return array();
}
$product_ids = array();
if ( $this->is_connected() ) {
$subscriptions = \WC_Helper::get_subscriptions();
foreach ( (array) $subscriptions as $subscription ) {
if ( in_array( $site_id, $subscription['connections'], true ) ) {
$product_ids[] = $subscription['product_id'];
}
}
}
return $product_ids;
}
/**
* Clears all connection or subscription notes.
*/
public function remove_notes() {
Notes::delete_notes_with_name( self::CONNECTION_NOTE_NAME );
Notes::delete_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
}
/**
* Adds a note prompting to connect to WooCommerce.com.
*/
public function add_no_connection_note() {
$note = self::get_note();
$note->save();
}
/**
* Get the WooCommerce.com connection note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Connect to WooCommerce.com', 'woocommerce' ) );
$note->set_content( __( 'Connect to get important product notifications and updates.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::CONNECTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'connect',
__( 'Connect', 'woocommerce' ),
'?page=wc-addons§ion=helper',
Note::E_WC_ADMIN_NOTE_UNACTIONED
);
return $note;
}
/**
* Gets the product_id (if any) associated with a note.
*
* @param Note $note The note object to interrogate.
* @return int|false
*/
public function get_product_id_from_subscription_note( &$note ) {
$content_data = $note->get_content_data();
if ( property_exists( $content_data, 'product_id' ) ) {
return intval( $content_data->product_id );
}
return false;
}
/**
* Removes notes for product_ids no longer active on this site.
*/
public function prune_inactive_subscription_notes() {
$active_product_ids = $this->get_subscription_active_product_ids();
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$product_id = $this->get_product_id_from_subscription_note( $note );
if ( ! empty( $product_id ) ) {
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
$note->delete();
}
}
}
}
/**
* Finds a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
* @return Note|false
*/
public function find_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$found_product_id = $this->get_product_id_from_subscription_note( $note );
if ( $product_id === $found_product_id ) {
return $note;
}
}
return false;
}
/**
* Deletes a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
*/
public function delete_any_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note->delete();
}
}
/**
* Adds or updates a note for an expiring subscription.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expiring( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
$days_until_expiration = intval( ceil( ( $expires - $time_now_gmt ) / DAY_IN_SECONDS ) );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$content_data = $note->get_content_data();
if ( property_exists( $content_data, 'days_until_expiration' ) ) {
// Note: There is no reason this property should not exist. This is just defensive programming.
$note_days_until_expiration = intval( $content_data->days_until_expiration );
if ( $days_until_expiration === $note_days_until_expiration ) {
// Note is already up to date. Bail.
return;
}
// If we have a note and we are at or have crossed a threshold, we should delete
// the old note and create a new one, thereby "bumping" the note to the top of the inbox.
$bump_thresholds = $this->get_bump_thresholds();
$crossing_threshold = false;
foreach ( (array) $bump_thresholds as $bump_threshold ) {
if ( ( $note_days_until_expiration > $bump_threshold ) && ( $days_until_expiration <= $bump_threshold ) ) {
$note->delete();
$note = false;
continue;
}
}
}
}
$note_title = sprintf(
/* translators: name of the extension subscription expiring soon */
__( '%s subscription expiring soon', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: number of days until the subscription expires */
__( 'Your subscription expires in %d days. Enable autorenew to avoid losing updates and access to support.', 'woocommerce' ),
$days_until_expiration
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => false,
'days_until_expiration' => $days_until_expiration,
);
if ( ! $note ) {
$note = new Note();
}
// Reset everything in case we are repurposing an expired note as an expiring note.
$note->set_title( $note_title );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'enable-autorenew',
__( 'Enable Autorenew', 'woocommerce' ),
'https://woocommerce.com/my-account/my-subscriptions/?utm_medium=product'
);
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->save();
}
/**
* Adds a note for an expired subscription, or updates an expiring note to expired.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expired( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$product_page = $subscription['product_url'];
$expires = intval( $subscription['expires'] );
$expires_date = gmdate( 'F jS', $expires );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note_content_data = $note->get_content_data();
if ( $note_content_data->expired ) {
// We've already got a full fledged expired note for this. Bail.
// Expired notes' content don't change with time.
return;
}
}
$note_title = sprintf(
/* translators: name of the extension subscription that expired */
__( '%s subscription expired', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: date the subscription expired, e.g. Jun 7th 2018 */
__( 'Your subscription expired on %s. Get a new subscription to continue receiving updates and access to support.', 'woocommerce' ),
$expires_date
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => true,
'expires' => $expires,
'expires_date' => $expires_date,
);
if ( ! $note ) {
$note = new Note();
}
$note->set_title( $note_title );
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'renew-subscription',
__( 'Renew Subscription', 'woocommerce' ),
$product_page
);
$note->save();
}
/**
* For each active subscription on this site, checks the expiration date and creates/updates/deletes notes.
*/
public function refresh_subscription_notes() {
if ( ! $this->is_connected() ) {
return;
}
$this->prune_inactive_subscription_notes();
$subscriptions = \WC_Helper::get_subscriptions();
$active_product_ids = $this->get_subscription_active_product_ids();
foreach ( (array) $subscriptions as $subscription ) {
// Only concern ourselves with active products.
$product_id = $subscription['product_id'];
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
continue;
}
// If the subscription will auto-renew, clean up and exit.
if ( $subscription['autorenew'] ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// If the subscription is not expiring by the first threshold, clean up and exit.
$bump_thresholds = $this->get_bump_thresholds();
$first_threshold = DAY_IN_SECONDS * $bump_thresholds[0];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
if ( $expires > $time_now_gmt + $first_threshold ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// Otherwise, if the subscription can still have auto-renew enabled, let them know that now.
if ( $expires > $time_now_gmt ) {
$this->add_or_update_subscription_expiring( $subscription );
continue;
}
// If we got this far, the subscription has completely expired, let them know.
$this->add_or_update_subscription_expired( $subscription );
}
}
}
Admin/Notes/EUVATNumber.php 0000644 00000003214 15233366071 0011440 0 ustar 00 <?php
/**
* WooCommerce Admin: EU VAT Number Note.
*
* Adds a note for EU store to install the EU VAT Number extension.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* EU_VAT_Number
*/
class EUVATNumber {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-eu-vat-number';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( 'yes' !== get_option( 'wc_connect_taxes_enabled', 'no' ) ) {
return;
}
$country_code = WC()->countries->get_base_country();
$eu_countries = WC()->countries->get_european_union_countries();
if ( ! in_array( $country_code, $eu_countries, true ) ) {
return;
}
$content = __( "If your store is based in the EU, we recommend using the EU VAT Number extension in addition to automated taxes. It provides your checkout with a field to collect and validate a customer's EU VAT number, if they have one.", 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Collect and validate EU VAT numbers at checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/products/eu-vat-number/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Admin/Notes/WooCommercePayments.php 0000644 00000014414 15233366071 0013347 0 ustar 00 <?php
/**
* WooCommerce Admin WooCommerce Payments Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the WooCommerce Payments.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\Notes;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* WooCommerce_Payments
*/
class WooCommercePayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_SLUG = 'woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_FILE = 'woocommerce-payments/woocommerce-payments.php';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'init', array( $this, 'install_on_action' ) );
add_action( 'wc-admin-woocommerce-payments_add_note', array( $this, 'add_note' ) );
}
/**
* Maybe add a note on WooCommerce Payments for US based sites older than a week without the plugin installed.
*/
public static function possibly_add_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) || 'US' !== WC()->countries->get_base_country() ) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If the WooCommerce Payments plugin was installed after the note was created, make sure it's marked as actioned.
if ( self::is_installed() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
$current_date = new \DateTime();
$publish_date = new \DateTime( '2020-04-14' );
if ( $current_date >= $publish_date ) {
$note = self::get_note();
if ( self::can_be_added() ) {
$note->save();
}
return;
} else {
$hook_name = sprintf( '%s_add_note', self::NOTE_NAME );
if ( ! WC()->queue()->get_next( $hook_name ) ) {
WC()->queue()->schedule_single( $publish_date->getTimestamp(), $hook_name );
}
}
}
/**
* Add a note about WooCommerce Payments.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Try the new way to get paid', 'woocommerce' ) );
$note->set_content(
__( 'Securely accept credit and debit cards on your site. Manage transactions without leaving your WordPress dashboard. Only with <strong>WooCommerce Payments</strong>.', 'woocommerce' ) .
'<br><br>' .
sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'By clicking "Get started", you agree to our %1$sTerms of Service%2$s', 'woocommerce' ),
'<a href="https://wordpress.com/tos/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/payments/?utm_medium=product', Note::E_WC_ADMIN_NOTE_UNACTIONED );
$note->add_action( 'get-started', __( 'Get started', 'woocommerce' ), wc_admin_url( '&action=setup-woocommerce-payments' ), Note::E_WC_ADMIN_NOTE_ACTIONED, true );
$note->add_nonce_to_action( 'get-started', 'setup-woocommerce-payments', '' );
// Create the note as "actioned" if the plugin is already installed.
if ( self::is_installed() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
}
return $note;
}
/**
* Check if the WooCommerce Payments plugin is active or installed.
*/
protected static function is_installed() {
if ( defined( 'WC_Payments' ) ) {
return true;
}
include_once ABSPATH . '/wp-admin/includes/plugin.php';
return 0 === validate_plugin( self::PLUGIN_FILE );
}
/**
* Install and activate WooCommerce Payments.
*
* @return boolean Whether the plugin was successfully activated.
*/
private function install_and_activate_wcpay() {
$install_request = array( 'plugins' => self::PLUGIN_SLUG );
$installer = new \Automattic\WooCommerce\Admin\API\Plugins();
$result = $installer->install_plugins( $install_request );
if ( is_wp_error( $result ) ) {
return false;
}
wc_admin_record_tracks_event( 'woocommerce_payments_install', array( 'context' => 'inbox' ) );
$activate_request = array( 'plugins' => self::PLUGIN_SLUG );
$result = $installer->activate_plugins( $activate_request );
if ( is_wp_error( $result ) ) {
return false;
}
return true;
}
/**
* Install & activate WooCommerce Payments plugin, and redirect to setup.
*/
public function install_on_action() {
// TODO: Need to validate this request more strictly since we're taking install actions directly?
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['action'] ) ||
'setup-woocommerce-payments' !== $_GET['action']
) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( empty( $note_ids ) ) {
return;
}
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
$action = $note->get_action( 'get-started' );
if ( ! $action ||
( isset( $action->nonce_action ) &&
(
empty( $_GET['_wpnonce'] ) ||
! wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action->nonce_action ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
)
)
) {
return;
}
if ( ! current_user_can( 'install_plugins' ) ) {
return;
}
$this->install_and_activate_wcpay();
// WooCommerce Payments is installed at this point, so link straight into the onboarding flow.
$connect_url = add_query_arg(
array(
'wcpay-connect' => '1',
'_wpnonce' => wp_create_nonce( 'wcpay-connect' ),
),
admin_url()
);
wp_safe_redirect( $connect_url );
exit;
}
}
Admin/Notes/MagentoMigration.php 0000644 00000004714 15233366071 0012655 0 ustar 00 <?php
/**
* WooCommerce Admin note on how to migrate from Magento.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* MagentoMigration
*/
class MagentoMigration {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-magento-migration';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( __CLASS__, 'possibly_add_note' ) );
add_action( 'woocommerce_admin_magento_migration_note', array( __CLASS__, 'save_note' ) );
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( empty( $onboarding_profile ) ) {
return;
}
if (
! isset( $onboarding_profile['other_platform'] ) ||
'magento' !== $onboarding_profile['other_platform']
) {
return;
}
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
WC()->queue()->schedule_single( time() + ( 5 * MINUTE_IN_SECONDS ), 'woocommerce_admin_magento_migration_note' );
}
/**
* Save the note to the database.
*/
public static function save_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'How to Migrate from Magento to WooCommerce', 'woocommerce' ) );
$note->set_content( __( 'Changing platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-migrate-from-magento-to-woocommerce/?utm_source=inbox'
);
return $note;
}
}
Admin/Notes/PaymentsRemindMeLater.php 0000644 00000004222 15233366071 0013614 0 ustar 00 <?php
/**
* WooCommerce Admin Payment Reminder Me later
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsRemindMeLater
*/
class PaymentsRemindMeLater {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-remind-me-later';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// Installed WCPay.
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
if ( in_array( 'woocommerce-payments', $installed_plugins, true ) ) {
return false;
}
// Dismissed WCPay welcome page.
if ( 'yes' === get_option( 'wc_calypso_bridge_payments_dismissed', 'no' ) ) {
return false;
}
// Less than 3 days since viewing welcome page.
$view_timestamp = get_option( 'wc_pay_welcome_page_viewed_timestamp', false );
if ( ! $view_timestamp ||
( time() - $view_timestamp < 3 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
$content = __( 'Save up to $800 in fees by managing transactions with WooCommerce Payments. With WooCommerce Payments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Save big with WooCommerce Payments', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) );
return $note;
}
}
Admin/Notes/UnsecuredReportFiles.php 0000644 00000004113 15233366071 0013516 0 ustar 00 <?php
/**
* WooCommerce Admin Unsecured Files Note.
*
* Adds a warning about potentially unsecured files.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
if ( ! class_exists( Note::class ) ) {
class_alias( WC_Admin_Note::class, Note::class );
}
/**
* Unsecured_Report_Files
*/
class UnsecuredReportFiles {
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-remove-unsecured-report-files';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Potentially unsecured files were found in your uploads directory', 'woocommerce' ) );
$note->set_content(
sprintf(
/* translators: 1: opening analytics docs link tag. 2: closing link tag */
__( 'Files that may contain %1$sstore analytics%2$s reports were found in your uploads directory - we recommend assessing and deleting any such files.', 'woocommerce' ),
'<a href="https://woocommerce.com/document/woocommerce-analytics/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_ERROR );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://developer.woocommerce.com/?p=10410',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
$note->add_action(
'dismiss',
__( 'Dismiss', 'woocommerce' ),
wc_admin_url(),
Note::E_WC_ADMIN_NOTE_ACTIONED,
false
);
return $note;
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Check if the note has been previously added.
*/
public static function note_exists() {
$data_store = \WC_Data_Store::load( 'admin-note' );
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
return ! empty( $note_ids );
}
}
Admin/Notes/WooCommerceSubscriptions.php 0000644 00000003605 15233366071 0014416 0 ustar 00 <?php
/**
* WooCommerce Admin: WooCommerce Subscriptions.
*
* Adds a note to learn more about WooCommerce Subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
use \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* WooCommerce_Subscriptions.
*/
class WooCommerceSubscriptions {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-subscriptions';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_data = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $onboarding_data['product_types'] ) || ! in_array( 'subscriptions', $onboarding_data['product_types'], true ) ) {
return;
}
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need more info about WooCommerce Subscriptions?', 'woocommerce' ) );
$note->set_content( __( 'WooCommerce Subscriptions allows you to introduce a variety of subscriptions for physical or virtual products and services. Create product-of-the-month clubs, weekly service subscriptions or even yearly software billing packages. Add sign-up fees, offer free trials, or set expiration periods.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn More', 'woocommerce' ),
'https://woocommerce.com/products/woocommerce-subscriptions/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
return $note;
}
}
Admin/Notes/PersonalizeStore.php 0000644 00000003650 15233366071 0012717 0 ustar 00 <?php
/**
* WooCommerce Admin Personalize Your Store Note Provider.
*
* Adds a note to the merchant's inbox prompting them to personalize their store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Personalize_Store
*/
class PersonalizeStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-personalize-store';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only show the note to stores with homepage.
$homepage_id = get_option( 'woocommerce_onboarding_homepage_post_id', false );
if ( ! $homepage_id ) {
return;
}
// Show the note after task list is done.
$is_task_list_complete = get_option( 'woocommerce_task_list_complete', false );
// We want to show the note after day 5.
$five_days_in_seconds = 5 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', $five_days_in_seconds ) && ! $is_task_list_complete ) {
return;
}
$content = __( 'The homepage is one of the most important entry points in your store. When done right it can lead to higher conversions and engagement. Don\'t forget to personalize the homepage that we created for your store during the onboarding.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Personalize your store\'s homepage', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'personalize-homepage', __( 'Personalize homepage', 'woocommerce' ), admin_url( 'post.php?post=' . $homepage_id . '&action=edit' ), Note::E_WC_ADMIN_NOTE_ACTIONED );
return $note;
}
}
Admin/Notes/MobileApp.php 0000644 00000002615 15233366071 0011257 0 ustar 00 <?php
/**
* WooCommerce Admin Mobile App Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Mobile_App
*/
class MobileApp {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-mobile-app';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the mobile app note after day 2.
$two_days_in_seconds = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days_in_seconds ) ) {
return;
}
$content = __( 'Install the WooCommerce mobile app to manage orders, receive sales notifications, and view key metrics — wherever you are.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Install Woo mobile app', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_medium=product' );
return $note;
}
}
Admin/Notes/EmailNotification.php 0000644 00000012250 15233366071 0013001 0 ustar 00 <?php
/**
* Handles emailing user notes.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Include dependencies.
*/
if ( ! class_exists( 'WC_Email', false ) ) {
include_once WC_ABSPATH . 'includes/emails/class-wc-email.php';
}
/**
* EmailNotification Class.
*/
class EmailNotification extends \WC_Email {
/**
* Constructor.
*
* @param Note $note The notification to send.
*/
public function __construct( $note ) {
$this->note = $note;
$this->id = 'merchant_notification';
$this->template_base = WC_ADMIN_ABSPATH . 'includes/react-admin/emails/';
$this->placeholders = array(
'{greetings}' => __( 'Hi there,', 'woocommerce' ),
);
// Call parent constructor.
parent::__construct();
}
/**
* This email has no user-facing settings.
*/
public function init_form_fields() {}
/**
* This email has no user-facing settings.
*/
public function init_settings() {}
/**
* Return template filename.
*
* @param string $type Type of email to send.
* @return string
*/
public function get_template_filename( $type = 'html' ) {
if ( ! in_array( $type, array( 'html', 'plain' ), true ) ) {
return;
}
$content_data = $this->note->get_content_data();
$template_filename = "{$type}-merchant-notification.php";
if ( isset( $content_data->{"template_{$type}"} ) && file_exists( $this->template_base . $content_data->{ "template_{$type}" } ) ) {
$template_filename = $content_data[ "template_{$type}" ];
}
return $template_filename;
}
/**
* Return email type.
*
* @return string
*/
public function get_email_type() {
return class_exists( 'DOMDocument' ) ? 'html' : 'plain';
}
/**
* Get email heading.
*
* @return string
*/
public function get_default_heading() {
$content_data = $this->note->get_content_data();
if ( isset( $content_data->heading ) ) {
return $content_data->heading;
}
return $this->note->get_title();
}
/**
* Get email headers.
*
* @return string
*/
public function get_headers() {
$header = 'Content-Type: ' . $this->get_content_type() . "\r\n";
return apply_filters( 'woocommerce_email_headers', $header, $this->id, $this->object, $this );
}
/**
* Get email subject.
*
* @return string
*/
public function get_default_subject() {
return $this->note->get_title();
}
/**
* Get note content.
*
* @return string
*/
public function get_note_content() {
return $this->note->get_content();
}
/**
* Get note image.
*
* @return string
*/
public function get_image() {
return $this->note->get_image();
}
/**
* Get email action.
*
* @return stdClass
*/
public function get_actions() {
return $this->note->get_actions();
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->get_template_filename( 'html' ),
array(
'email_actions' => $this->get_actions(),
'email_content' => $this->format_string( $this->get_note_content() ),
'email_heading' => $this->format_string( $this->get_heading() ),
'email_image' => $this->get_image(),
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
'opened_tracking_url' => $this->opened_tracking_url,
'trigger_note_action_url' => $this->trigger_note_action_url,
),
'',
$this->template_base
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->get_template_filename( 'plain' ),
array(
'email_heading' => $this->format_string( $this->get_heading() ),
'email_content' => $this->format_string( $this->get_note_content() ),
'email_actions' => $this->get_actions(),
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
'trigger_note_action_url' => $this->trigger_note_action_url,
),
'',
$this->template_base
);
}
/**
* Trigger the sending of this email.
*
* @param string $user_email Email to send the note.
* @param int $user_id User id to to track the note.
* @param string $user_name User's name.
*/
public function trigger( $user_email, $user_id, $user_name ) {
$this->recipient = $user_email;
$this->opened_tracking_url = sprintf(
'%1$s/wp-json/wc-analytics/admin/notes/tracker/%2$d/user/%3$d',
site_url(),
$this->note->get_id(),
$user_id
);
$this->trigger_note_action_url = sprintf(
'%1$s&external_redirect=1¬e=%2$d&user=%3$d&action=',
wc_admin_url(),
$this->note->get_id(),
$user_id
);
if ( $user_name ) {
/* translators: %s = merchant name */
$this->placeholders['{greetings}'] = sprintf( __( 'Hi %s,', 'woocommerce' ), $user_name );
}
$this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
Notes::record_tracks_event_with_user( $user_id, 'email_note_sent', array( 'note_name' => $this->note->get_name() ) );
}
}
Admin/Notes/NewSalesRecord.php 0000644 00000012404 15233366071 0012264 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) New Sales Record Note Provider.
*
* Adds a note to the merchant's inbox when the previous day's sales are a new record.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* New_Sales_Record
*/
class NewSalesRecord {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-new-sales-record';
/**
* Option name for the sales record date in ISO 8601 (YYYY-MM-DD) date.
*/
const RECORD_DATE_OPTION_KEY = 'woocommerce_sales_record_date';
/**
* Option name for the sales record amount.
*/
const RECORD_AMOUNT_OPTION_KEY = 'woocommerce_sales_record_amount';
/**
* Returns the total of yesterday's sales.
*
* @param string $date Date for sales to sum (i.e. YYYY-MM-DD).
* @return floatval
*/
public static function sum_sales_for_date( $date ) {
$order_query = new \WC_Order_Query( array( 'date_created' => $date ) );
$orders = $order_query->get_orders();
$total = 0;
foreach ( (array) $orders as $order ) {
$total += $order->get_total();
}
return $total;
}
/**
* Possibly add a sales record note.
*/
public static function possibly_add_note() {
/**
* Filter to allow for disabling sales record milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$sales_record_notes_enabled = apply_filters( 'woocommerce_admin_sales_record_milestone_enabled', true );
if ( ! $sales_record_notes_enabled ) {
return;
}
$yesterday = gmdate( 'Y-m-d', current_time( 'timestamp', 0 ) - DAY_IN_SECONDS );
$total = self::sum_sales_for_date( $yesterday );
// No sales yesterday? Bail.
if ( 0 >= $total ) {
return;
}
$record_date = get_option( self::RECORD_DATE_OPTION_KEY, '' );
$record_amt = floatval( get_option( self::RECORD_AMOUNT_OPTION_KEY, 0 ) );
// No previous entry? Just enter what we have and return without generating a note.
if ( empty( $record_date ) ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
return;
}
// Otherwise, if yesterdays total bested the record, update AND generate a note.
if ( $total > $record_amt ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
// We only want one sales record note at any time in the inbox, so we delete any other first.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = self::get_note_with_record_data( $record_date, $record_amt, $yesterday, $total );
$note->save();
}
}
/**
* Get the note with record data.
*
* @param string $record_date record date Y-m-d.
* @param float $record_amt record amount.
* @param string $yesterday yesterday's date Y-m-d.
* @param string $total total sales for yesterday.
*
* @return Note
*/
public static function get_note_with_record_data( $record_date, $record_amt, $yesterday, $total ) {
// Use F jS (March 7th) format for English speaking countries.
if ( substr( get_user_locale(), 0, 2 ) === 'en' ) {
$date_format = 'F jS';
} else {
// otherwise, fallback to the system date format.
$date_format = get_option( 'date_format' );
}
$formatted_yesterday = date_i18n( $date_format, strtotime( $yesterday ) );
$formatted_total = html_entity_decode( wp_strip_all_tags( wc_price( $total ) ) );
$formatted_record_date = date_i18n( $date_format, strtotime( $record_date ) );
$formatted_record_amt = html_entity_decode( wp_strip_all_tags( wc_price( $record_amt ) ) );
$content = sprintf(
/* translators: 1 and 4: Date (e.g. October 16th), 2 and 3: Amount (e.g. $160.00) */
__( 'Woohoo, %1$s was your record day for sales! Net sales was %2$s beating the previous record of %3$s set on %4$s.', 'woocommerce' ),
$formatted_yesterday,
$formatted_total,
$formatted_record_amt,
$formatted_record_date
);
$content_data = (object) array(
'old_record_date' => $record_date,
'old_record_amt' => $record_amt,
'new_record_date' => $yesterday,
'new_record_amt' => $total,
);
$report_url = '?page=wc-admin&path=/analytics/revenue&period=custom&compare=previous_year&after=' . $yesterday . '&before=' . $yesterday;
// And now, create our new note.
$note = new Note();
$note->set_title( __( 'New sales record!', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'view-report', __( 'View report', 'woocommerce' ), $report_url );
return $note;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
return self::get_note_with_record_data(
$content_data->old_record_date,
$content_data->old_record_amt,
$content_data->new_record_date,
$content_data->new_record_amt
);
}
}
Admin/Notes/OnlineClothingStore.php 0000644 00000005271 15233366071 0013341 0 ustar 00 <?php
/**
* WooCommerce Admin: Start your online clothing store.
*
* Adds a note to ask the client if they are considering starting an online
* clothing store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Online_Clothing_Store.
*/
class OnlineClothingStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-online-clothing-store';
/**
* Returns whether the industries includes fashion-apparel-accessories.
*
* @param array $industries The industries to search.
*
* @return bool Whether the industries includes fashion-apparel-accessories.
*/
private static function is_in_fashion_industry( $industries ) {
foreach ( $industries as $industry ) {
if ( 'fashion-apparel-accessories' === $industry['slug'] ) {
return true;
}
}
return false;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', 2 * DAY_IN_SECONDS ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We need to show the notification when the industry is
// fashion/apparel/accessories.
if ( ! isset( $onboarding_profile['industry'] ) ) {
return;
}
if ( ! self::is_in_fashion_industry( $onboarding_profile['industry'] ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start your online clothing store', 'woocommerce' ) );
$note->set_content( __( 'Starting a fashion website is exciting but it may seem overwhelming as well. In this article, we\'ll walk you through the setup process, teach you to create successful product listings, and show you how to market to your ideal audience.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'online-clothing-store',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/starting-an-online-clothing-store/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Admin/Events.php 0000644 00000017532 15233366071 0007567 0 ustar 00 <?php
/**
* Handle cron events.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\Features\Features;
use \Automattic\WooCommerce\Admin\RemoteInboxNotifications\DataSourcePoller;
use \Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
use \Automattic\WooCommerce\Internal\Admin\Notes\AddFirstProduct;
use \Automattic\WooCommerce\Internal\Admin\Notes\ChoosingTheme;
use \Automattic\WooCommerce\Internal\Admin\Notes\CouponPageMoved;
use \Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks;
use \Automattic\WooCommerce\Internal\Admin\Notes\CustomizingProductCatalog;
use \Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove;
use \Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber;
use \Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct;
use \Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use \Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist;
use \Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use \Automattic\WooCommerce\Internal\Admin\Notes\ManageOrdersOnTheGo;
use \Automattic\WooCommerce\Internal\Admin\Notes\MarketingJetpack;
use \Automattic\WooCommerce\Internal\Admin\Notes\MerchantEmailNotifications;
use \Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify;
use \Automattic\WooCommerce\Internal\Admin\Notes\MobileApp;
use \Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord;
use \Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments;
use \Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore;
use \Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use \Automattic\WooCommerce\Internal\Admin\Notes\PaymentsMoreInfoNeeded;
use \Automattic\WooCommerce\Internal\Admin\Notes\PaymentsRemindMeLater;
use \Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use \Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use \Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
use \Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use \Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use \Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use \Automattic\WooCommerce\Internal\Admin\Notes\UnsecuredReportFiles;
use \Automattic\WooCommerce\Internal\Admin\Notes\WelcomeToWooCommerceForStoreUsers;
use \Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use \Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions;
use \Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use \Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
use \Automattic\WooCommerce\Admin\Notes\Note;
/**
* Events Class.
*/
class Events {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Array of note class to be added or updated.
*
* @var array
*/
private static $note_classes_to_added_or_updated = array(
AddFirstProduct::class,
ChoosingTheme::class,
CustomizeStoreWithBlocks::class,
CustomizingProductCatalog::class,
EditProductsOnTheMove::class,
EUVATNumber::class,
FirstProduct::class,
LaunchChecklist::class,
MagentoMigration::class,
ManageOrdersOnTheGo::class,
MarketingJetpack::class,
MigrateFromShopify::class,
MobileApp::class,
NewSalesRecord::class,
OnboardingPayments::class,
OnlineClothingStore::class,
PaymentsMoreInfoNeeded::class,
PaymentsRemindMeLater::class,
PerformanceOnMobile::class,
PersonalizeStore::class,
RealTimeOrderAlerts::class,
TestCheckout::class,
TrackingOptIn::class,
WooCommercePayments::class,
WooCommerceSubscriptions::class,
);
/**
* The other note classes that are added in other places.
*
* @var array
*/
private static $other_note_classes = array(
CouponPageMoved::class,
InstallJPAndWCSPlugins::class,
OrderMilestones::class,
SellingOnlineCourses::class,
UnsecuredReportFiles::class,
WelcomeToWooCommerceForStoreUsers::class,
WooSubscriptionsNotes::class,
);
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Cron event handlers.
*/
public function init() {
add_action( 'wc_admin_daily', array( $this, 'do_wc_admin_daily' ) );
add_filter( 'woocommerce_get_note_from_db', array( $this, 'get_note_from_db' ), 10, 1 );
// Initialize the WC_Notes_Refund_Returns Note to attach hook.
\WC_Notes_Refund_Returns::init();
}
/**
* Daily events to run.
*
* Note: Order_Milestones::possibly_add_note is hooked to this as well.
*/
public function do_wc_admin_daily() {
$this->possibly_add_notes();
$this->possibly_delete_notes();
$this->possibly_update_notes();
if ( $this->is_remote_inbox_notifications_enabled() ) {
DataSourcePoller::get_instance()->read_specs_from_data_sources();
RemoteInboxNotificationsEngine::run();
}
if ( $this->is_merchant_email_notifications_enabled() ) {
MerchantEmailNotifications::run();
}
if ( Features::is_enabled( 'onboarding' ) ) {
( new MailchimpScheduler() )->run();
}
}
/**
* Get note.
*
* @param Note $note_from_db The note object from the database.
*/
public function get_note_from_db( $note_from_db ) {
if ( ! $note_from_db instanceof Note || get_user_locale() === $note_from_db->get_locale() ) {
return $note_from_db;
}
$note_classes = array_merge( self::$note_classes_to_added_or_updated, self::$other_note_classes );
foreach ( $note_classes as $note_class ) {
if ( defined( "$note_class::NOTE_NAME" ) && $note_class::NOTE_NAME === $note_from_db->get_name() ) {
$note_from_class = method_exists( $note_class, 'get_note' ) ? $note_class::get_note() : null;
if ( $note_from_class instanceof Note ) {
$note = clone $note_from_db;
$note->set_title( $note_from_class->get_title() );
$note->set_content( $note_from_class->get_content() );
$note->set_actions( $note_from_class->get_actions() );
return $note;
}
break;
}
}
return $note_from_db;
}
/**
* Adds notes that should be added.
*/
protected function possibly_add_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_add_note' ) ) {
$note_class::possibly_add_note();
}
}
}
/**
* Deletes notes that should be deleted.
*/
protected function possibly_delete_notes() {
PaymentsRemindMeLater::delete_if_not_applicable();
PaymentsMoreInfoNeeded::delete_if_not_applicable();
}
/**
* Updates notes that should be updated.
*/
protected function possibly_update_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_update_note' ) ) {
$note_class::possibly_update_note();
}
}
}
/**
* Checks if remote inbox notifications are enabled.
*
* @return bool Whether remote inbox notifications are enabled.
*/
protected function is_remote_inbox_notifications_enabled() {
// Check if the feature flag is disabled.
if ( ! Features::is_enabled( 'remote-inbox-notifications' ) ) {
return false;
}
// Check if the site has opted out of marketplace suggestions.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
/**
* Checks if merchant email notifications are enabled.
*
* @return bool Whether merchant email notifications are enabled.
*/
protected function is_merchant_email_notifications_enabled() {
// Check if the feature flag is disabled.
if ( get_option( 'woocommerce_merchant_email_notifications', 'no' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
}
Admin/CategoryLookup.php 0000644 00000017764 15233366071 0011301 0 ustar 00 <?php
/**
* Keeps the product category lookup table in sync with live data.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Internal\Admin\CategoryLookup class.
*/
class CategoryLookup {
/**
* Stores changes to categories we need to sync.
*
* @var array
*/
protected $edited_product_cats = array();
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init hooks.
*/
public function init() {
add_action( 'generate_category_lookup_table', array( $this, 'regenerate' ) );
add_action( 'edit_product_cat', array( $this, 'before_edit' ), 99 );
add_action( 'edited_product_cat', array( $this, 'on_edit' ), 99 );
add_action( 'created_product_cat', array( $this, 'on_create' ), 99 );
add_action( 'init', array( $this, 'define_category_lookup_tables_in_wpdb' ) );
}
/**
* Regenerate all lookup table data.
*/
public function regenerate() {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE $wpdb->wc_category_lookup" );
$terms = get_terms(
'product_cat',
array(
'hide_empty' => false,
'fields' => 'id=>parent',
)
);
$hierarchy = array();
$inserts = array();
$this->unflatten_terms( $hierarchy, $terms, 0 );
$this->get_term_insert_values( $inserts, $hierarchy );
if ( ! $inserts ) {
return;
}
$insert_string = implode(
'),(',
array_map(
function( $item ) {
return implode( ',', $item );
},
$inserts
)
);
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_tree_id,category_id) VALUES ({$insert_string})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Store edits so we know when the parent ID changes.
*
* @param int $category_id Term ID being edited.
*/
public function before_edit( $category_id ) {
$category = get_term( $category_id, 'product_cat' );
$this->edited_product_cats[ $category_id ] = $category->parent;
}
/**
* When a product category gets edited, see if we need to sync the table.
*
* @param int $category_id Term ID being edited.
*/
public function on_edit( $category_id ) {
global $wpdb;
if ( ! isset( $this->edited_product_cats[ $category_id ] ) ) {
return;
}
$category_object = get_term( $category_id, 'product_cat' );
$prev_parent = $this->edited_product_cats[ $category_id ];
$new_parent = $category_object->parent;
// No edits - no need to modify relationships.
if ( $prev_parent === $new_parent ) {
return;
}
$this->delete( $category_id, $prev_parent );
$this->update( $category_id );
}
/**
* When a product category gets created, add a new lookup row.
*
* @param int $category_id Term ID being created.
*/
public function on_create( $category_id ) {
// If WooCommerce is being installed on a multisite, lookup tables haven't been created yet.
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return;
}
$this->update( $category_id );
}
/**
* Delete lookup table data from a tree.
*
* @param int $category_id Category ID to delete.
* @param int $category_tree_id Tree to delete from.
* @return void
*/
protected function delete( $category_id, $category_tree_id ) {
global $wpdb;
if ( ! $category_tree_id ) {
return;
}
$ancestors = get_ancestors( $category_tree_id, 'product_cat', 'taxonomy' );
$ancestors[] = $category_tree_id;
$children = get_term_children( $category_id, 'product_cat' );
$children[] = $category_id;
$id_list = implode( ',', array_map( 'intval', array_unique( array_filter( $children ) ) ) );
foreach ( $ancestors as $ancestor ) {
$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d AND category_id IN ({$id_list})", $ancestor ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
/**
* Updates lookup table data for a category by ID.
*
* @param int $category_id Category ID to update.
*/
protected function update( $category_id ) {
global $wpdb;
$ancestors = get_ancestors( $category_id, 'product_cat', 'taxonomy' );
$children = get_term_children( $category_id, 'product_cat' );
$inserts = array();
$inserts[] = $this->get_insert_sql( $category_id, $category_id );
$children_ids = array_map( 'intval', array_unique( array_filter( $children ) ) );
foreach ( $ancestors as $ancestor ) {
$inserts[] = $this->get_insert_sql( $category_id, $ancestor );
foreach ( $children_ids as $child_category_id ) {
$inserts[] = $this->get_insert_sql( $child_category_id, $ancestor );
}
}
$insert_string = implode( ',', $inserts );
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_id, category_tree_id) VALUES {$insert_string}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get category lookup table values to insert.
*
* @param int $category_id Category ID to insert.
* @param int $category_tree_id Tree to insert into.
* @return string
*/
protected function get_insert_sql( $category_id, $category_tree_id ) {
global $wpdb;
return $wpdb->prepare( '(%d,%d)', $category_id, $category_tree_id );
}
/**
* Used to construct insert query recursively.
*
* @param array $inserts Array of data to insert.
* @param array $terms Terms to insert.
* @param array $parents Parent IDs the terms belong to.
*/
protected function get_term_insert_values( &$inserts, $terms, $parents = array() ) {
foreach ( $terms as $term ) {
$insert_parents = array_merge( array( $term['term_id'] ), $parents );
foreach ( $insert_parents as $parent ) {
$inserts[] = array(
$parent,
$term['term_id'],
);
}
$this->get_term_insert_values( $inserts, $term['descendants'], $insert_parents );
}
}
/**
* Convert flat terms array into nested array.
*
* @param array $hierarchy Array to put terms into.
* @param array $terms Array of terms (id=>parent).
* @param integer $parent Parent ID.
*/
protected function unflatten_terms( &$hierarchy, &$terms, $parent = 0 ) {
foreach ( $terms as $term_id => $parent_id ) {
if ( (int) $parent_id === $parent ) {
$hierarchy[ $term_id ] = array(
'term_id' => $term_id,
'descendants' => array(),
);
unset( $terms[ $term_id ] );
}
}
foreach ( $hierarchy as $term_id => $terms_array ) {
$this->unflatten_terms( $hierarchy[ $term_id ]['descendants'], $terms, $term_id );
}
}
/**
* Get category descendants.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_descendants( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_id FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d",
$category_id
)
)
);
}
/**
* Return all ancestor category ids for a category.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_ancestors( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_tree_id FROM $wpdb->wc_category_lookup WHERE category_id = %d",
$category_id
)
)
);
}
/**
* Add category lookup table to $wpdb object.
*/
public static function define_category_lookup_tables_in_wpdb() {
global $wpdb;
// List of tables without prefixes.
$tables = array(
'wc_category_lookup' => 'wc_category_lookup',
);
foreach ( $tables as $name => $table ) {
$wpdb->$name = $wpdb->prefix . $table;
$wpdb->tables[] = $table;
}
}
}
Admin/WcPayWelcomePage.php 0000644 00000016460 15233366071 0011456 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\WooCommercePayments;
use Automattic\WooCommerce\Admin\WCAdminHelper;
/**
* Class WCPayWelcomePage
*
* @package Automattic\WooCommerce\Admin\Features
*/
class WcPayWelcomePage {
const EXPERIMENT_NAME = 'woocommerce_payments_menu_promo_us_2022';
const OTHER_GATEWAYS = [
'affirm',
'afterpay',
'amazon_payments_advanced_express',
'amazon_payments_advanced',
'authorize_net_cim_credit_card',
'authorize_net_cim_echeck',
'bacs',
'bambora_credit_card',
'braintree_credit_card',
'braintree_paypal',
'chase_paymentech',
'cybersource_credit_card',
'elavon_converge_credit_card',
'elavon_converge_echeck',
'gocardless',
'intuit_payments_credit_card',
'intuit_payments_echeck',
'kco',
'klarna_payments',
'payfast',
'paypal',
'paytrace',
'ppcp-gateway',
'psigate',
'sagepaymentsusaapi',
'square_credit_card',
'stripe_alipay',
'stripe_multibanco',
'stripe',
'trustcommerce',
'usa_epay_credit_card',
];
/**
* WCPayWelcomePage constructor.
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_payments_welcome_page' ) );
}
/**
* Registers the WooCommerce Payments welcome page.
*/
public function register_payments_welcome_page() {
global $menu;
// WC Payment must not be installed.
if ( WooCommercePayments::is_installed() ) {
return;
}
// Live store for at least 90 days.
if ( ! WCAdminHelper::is_wc_admin_active_for( DAY_IN_SECONDS * 90 ) ) {
return;
}
// Must be a US based business.
if ( WC()->countries->get_base_country() !== 'US' ) {
return;
}
// Has another payment gateway installed.
if ( ! $this->is_another_payment_gateway_installed() ) {
return;
}
// No existing WCPay account.
if ( $this->has_wcpay_account() ) {
return;
}
// Suggestions may be disabled via a setting.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return;
}
/**
* Filter allow marketplace suggestions.
*
* User can disabled all suggestions via filter.
*
* @since 3.6.0
*/
if ( ! apply_filters( 'woocommerce_allow_marketplace_suggestions', true ) ) {
return;
}
// Manually dismissed.
if ( get_option( 'wc_calypso_bridge_payments_dismissed', 'no' ) === 'yes' ) {
return;
}
// Users must be in the experiment.
if ( ! $this->is_user_in_treatment_mode() ) {
return;
}
$menu_icon = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnNjciCiAgIHNvZGlwb2RpOmRvY25hbWU9IndjcGF5X21lbnVfaWNvbi5zdmciCiAgIHdpZHRoPSI4NTIiCiAgIGhlaWdodD0iNjg0IgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjEgKGM0ZThmOWUsIDIwMjEtMDUtMjQpIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM3MSIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9Im5hbWVkdmlldzY5IgogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIKICAgICBib3JkZXJjb2xvcj0iIzY2NjY2NiIKICAgICBib3JkZXJvcGFjaXR5PSIxLjAiCiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMC4wIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjAiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOnpvb209IjI1NiIKICAgICBpbmtzY2FwZTpjeD0iLTg0Ljg1NzQyMiIKICAgICBpbmtzY2FwZTpjeT0iLTgzLjI5NDkyMiIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjEzMTIiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iMTA4MSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iMTE2IgogICAgIGlua3NjYXBlOndpbmRvdy15PSIyMDIiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmc2NyIgLz4KICA8cGF0aAogICAgIHRyYW5zZm9ybT0ic2NhbGUoLTEsIDEpIHRyYW5zbGF0ZSgtODUwLCAwKSIKICAgICBkPSJNIDc2OCw4NiBWIDU5OCBIIDg0IFYgODYgWiBtIDAsNTk4IGMgNDgsMCA4NCwtMzggODQsLTg2IFYgODYgQyA4NTIsMzggODE2LDAgNzY4LDAgSCA4NCBDIDM2LDAgMCwzOCAwLDg2IHYgNTEyIGMgMCw0OCAzNiw4NiA4NCw4NiB6IE0gMzg0LDEyOCB2IDQ0IGggLTg2IHYgODQgaCAxNzAgdiA0NCBIIDM0MCBjIC0yNCwwIC00MiwxOCAtNDIsNDIgdiAxMjggYyAwLDI0IDE4LDQyIDQyLDQyIGggNDQgdiA0NCBoIDg0IHYgLTQ0IGggODYgViA0MjggSCAzODQgdiAtNDQgaCAxMjggYyAyNCwwIDQyLC0xOCA0MiwtNDIgViAyMTQgYyAwLC0yNCAtMTgsLTQyIC00MiwtNDIgaCAtNDQgdiAtNDQgeiIKICAgICBmaWxsPSIjYTJhYWIyIgogICAgIGlkPSJwYXRoNjUiIC8+Cjwvc3ZnPgo=';
$menu_data = array(
'id' => 'wc-calypso-bridge-payments-welcome-page',
'title' => __( 'Payments', 'woocommerce' ),
'path' => '/wc-pay-welcome-page',
'position' => '56',
'nav_args' => [
'title' => __( 'WooCommerce Payments', 'woocommerce' ),
'is_category' => false,
'menuId' => 'plugins',
'is_top_level' => true,
],
'icon' => $menu_icon,
);
wc_admin_register_page( $menu_data );
// Registering a top level menu via wc_admin_register_page doesn't work when the new
// nav is enabled. The new nav disabled everything, except the 'WooCommerce' menu.
// We need to register this menu via add_menu_page so that it doesn't become a child of
// WooCommerce menu.
if ( get_option( 'woocommerce_navigation_enabled', 'no' ) === 'yes' ) {
$menu_with_nav_data = array(
__( 'Payments', 'woocommerce' ),
__( 'Payments', 'woocommerce' ),
'view_woocommerce_reports',
'admin.php?page=wc-admin&path=/wc-pay-welcome-page',
null,
$menu_icon,
56,
);
call_user_func_array( 'add_menu_page', $menu_with_nav_data );
}
// Add badge.
foreach ( $menu as $index => $menu_item ) {
if ( 'wc-admin&path=/wc-pay-welcome-page' === $menu_item[2]
|| 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' === $menu_item[2] ) {
//phpcs:ignore
$menu[ $index ][0] .= ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';
}
}
}
/**
* Whether a WCPay account exists. By checking account data cache.
*
* @return boolean
*/
private function has_wcpay_account(): bool {
$account_data = get_option( 'wcpay_account_data' );
return isset( $account_data['data'] ) && is_array( $account_data['data'] ) && ! empty( $account_data['data'] );
}
/**
* Checks if user is in the experiment.
*
* @return bool Whether the user is in the treatment group.
*/
private function is_user_in_treatment_mode() {
$anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
$allow_tracking = get_option( 'woocommerce_allow_tracking' ) === 'yes';
$abtest = new \WooCommerce\Admin\Experimental_Abtest(
$anon_id,
'woocommerce',
$allow_tracking
);
return $abtest->get_variation( self::EXPERIMENT_NAME ) === 'treatment';
}
/**
* Checks if there is another payment gateway installed using a static list of US gateways from WC Store.
*
* @return bool Whether there is another payment gateway installed.
*/
private function is_another_payment_gateway_installed() {
$available_gateways = wp_list_pluck( WC()->payment_gateways()->get_available_payment_gateways(), 'id' );
foreach ( $available_gateways as $gateway ) {
if ( in_array( $gateway, self::OTHER_GATEWAYS, true ) ) {
return true;
}
}
return false;
}
}
Admin/Translations.php 0000644 00000022132 15233366071 0010774 0 ustar 00 <?php
/**
* Register the scripts, and handles items needed for managing translations within WooCommerce Admin.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* Translations Class.
*/
class Translations {
/**
* Class instance.
*
* @var Translations instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'potentially_load_translation_script_file' ), 15 );
// Combine JSON translation files (from chunks) when language packs are updated.
add_action( 'upgrader_process_complete', array( $this, 'combine_translation_chunk_files' ), 10, 2 );
// Handler for WooCommerce and WooCommerce Admin plugin activation.
add_action( 'woocommerce_activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
add_action( 'activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
}
/**
* Generate a filename to cache translations from JS chunks.
*
* @param string $domain Text domain.
* @param string $locale Locale being retrieved.
* @return string Filename.
*/
private function get_combined_translation_filename( $domain, $locale ) {
$filename = implode( '-', array( $domain, $locale, WC_ADMIN_APP ) ) . '.json';
return $filename;
}
/**
* Find and combine translation chunk files.
*
* Only targets files that aren't represented by a registered script (e.g. not passed to wp_register_script()).
*
* @param string $lang_dir Path to language files.
* @param string $domain Text domain.
* @param string $locale Locale being retrieved.
* @return array Combined translation chunk data.
*/
private function get_translation_chunk_data( $lang_dir, $domain, $locale ) {
// So long as this function is called during the 'upgrader_process_complete' action,
// the filesystem object should be hooked up.
global $wp_filesystem;
// Grab all JSON files in the current language pack.
$json_i18n_filenames = glob( $lang_dir . $domain . '-' . $locale . '-*.json' );
$combined_translation_data = array();
if ( false === $json_i18n_filenames ) {
return $combined_translation_data;
}
foreach ( $json_i18n_filenames as $json_filename ) {
if ( ! $wp_filesystem->is_readable( $json_filename ) ) {
continue;
}
$file_contents = $wp_filesystem->get_contents( $json_filename );
$chunk_data = \json_decode( $file_contents, true );
if ( empty( $chunk_data ) ) {
continue;
}
if ( ! isset( $chunk_data['comment']['reference'] ) ) {
continue;
}
$reference_file = $chunk_data['comment']['reference'];
// Only combine "app" files (not scripts registered with WP).
if (
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'app/index.js' ) &&
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'chunks/' )
) {
continue;
}
if ( empty( $combined_translation_data ) ) {
// Use the first translation file as the base structure.
$combined_translation_data = $chunk_data;
} else {
// Combine all messages from all chunk files.
$combined_translation_data['locale_data']['messages'] = array_merge(
$combined_translation_data['locale_data']['messages'],
$chunk_data['locale_data']['messages']
);
}
}
// Remove inaccurate reference comment.
unset( $combined_translation_data['comment'] );
return $combined_translation_data;
}
/**
* Combine and save translations for a specific locale.
*
* Note that this assumes \WP_Filesystem is already initialized with write access.
*
* @param string $language_dir Path to language files.
* @param string $plugin_domain Text domain.
* @param string $locale Locale being retrieved.
*/
private function build_and_save_translations( $language_dir, $plugin_domain, $locale ) {
global $wp_filesystem;
$translations_from_chunks = $this->get_translation_chunk_data( $language_dir, $plugin_domain, $locale );
if ( empty( $translations_from_chunks ) ) {
return;
}
$cache_filename = $this->get_combined_translation_filename( $plugin_domain, $locale );
$chunk_translations_json = wp_json_encode( $translations_from_chunks );
// Cache combined translations strings to a file.
$wp_filesystem->put_contents( $language_dir . $cache_filename, $chunk_translations_json );
}
/**
* Combine translation chunks when plugin is activated.
*
* This function combines JSON translation data auto-extracted by GlotPress
* from Webpack-generated JS chunks into a single file. This is necessary
* since the JS chunks are not known to WordPress via wp_register_script()
* and wp_set_script_translations().
*/
private function generate_translation_strings() {
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$locale = determine_locale();
$lang_dir = WP_LANG_DIR . '/plugins/';
// Bail early if not localized.
if ( 'en_US' === $locale ) {
return;
}
if ( ! function_exists( 'get_filesystem_method' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$access_type = get_filesystem_method();
if ( 'direct' === $access_type ) {
\WP_Filesystem();
$this->build_and_save_translations( $lang_dir, $plugin_domain, $locale );
} else {
// I'm reluctant to add support for other filesystems here as it would require
// user's input on activating plugin - which I don't think is common.
return;
}
}
/**
* Loads the required translation scripts on the correct pages.
*/
public function potentially_load_translation_script_file() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Grab translation strings from Webpack-generated chunks.
add_filter( 'load_script_translation_file', array( $this, 'load_script_translation_file' ), 10, 3 );
}
/**
* Load translation strings from language packs for dynamic imports.
*
* @param string $file File location for the script being translated.
* @param string $handle Script handle.
* @param string $domain Text domain.
*
* @return string New file location for the script being translated.
*/
public function load_script_translation_file( $file, $handle, $domain ) {
// Make sure the main app script is being loaded.
if ( WC_ADMIN_APP !== $handle ) {
return $file;
}
// Make sure we're handing the correct domain (could be woocommerce or woocommerce-admin).
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
if ( $plugin_domain !== $domain ) {
return $file;
}
$locale = determine_locale();
$cache_filename = $this->get_combined_translation_filename( $domain, $locale );
return WP_LANG_DIR . '/plugins/' . $cache_filename;
}
/**
* Run when plugin is activated (can be WooCommerce or WooCommerce Admin).
*
* @param string $filename Activated plugin filename.
*/
public function potentially_generate_translation_strings( $filename ) {
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$activated_plugin_domain = explode( '/', $filename )[0];
// Ensure we're only running only on activation hook that originates from our plugin.
if ( $plugin_domain === $activated_plugin_domain ) {
$this->generate_translation_strings();
}
}
/**
* Combine translation chunks when files are updated.
*
* This function combines JSON translation data auto-extracted by GlotPress
* from Webpack-generated JS chunks into a single file that can be used in
* subsequent requests. This is necessary since the JS chunks are not known
* to WordPress via wp_register_script() and wp_set_script_translations().
*
* @param Language_Pack_Upgrader $instance Upgrader instance.
* @param array $hook_extra Info about the upgraded language packs.
*/
public function combine_translation_chunk_files( $instance, $hook_extra ) {
if (
! is_a( $instance, 'Language_Pack_Upgrader' ) ||
! isset( $hook_extra['translations'] ) ||
! is_array( $hook_extra['translations'] )
) {
return;
}
// Make sure we're handing the correct domain (could be woocommerce or woocommerce-admin).
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$locales = array();
$language_dir = WP_LANG_DIR . '/plugins/';
// Gather the locales that were updated in this operation.
foreach ( $hook_extra['translations'] as $translation ) {
if (
'plugin' === $translation['type'] &&
$plugin_domain === $translation['slug']
) {
$locales[] = $translation['language'];
}
}
// Build combined translation files for all updated locales.
foreach ( $locales as $locale ) {
// So long as this function is hooked to the 'upgrader_process_complete' action,
// WP_Filesystem should be hooked up to be able to call build_and_save_translations.
$this->build_and_save_translations( $language_dir, $plugin_domain, $locale );
}
}
}
Admin/Settings.php 0000644 00000026610 15233366071 0010120 0 ustar 00 <?php
/**
* WooCommerce Settings.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Plugins;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\PluginsHelper;
use WC_Marketplace_Suggestions;
/**
* Contains logic in regards to WooCommerce Admin Settings.
*/
class Settings {
/**
* Class instance.
*
* @var Settings instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
// Old settings injection.
add_filter( 'woocommerce_components_settings', array( $this, 'add_component_settings' ) );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'add_component_settings' ) );
add_filter( 'woocommerce_settings_groups', array( $this, 'add_settings_group' ) );
add_filter( 'woocommerce_settings-wc_admin', array( $this, 'add_settings' ) );
}
/**
* Format order statuses by removing a leading 'wc-' if present.
*
* @param array $statuses Order statuses.
* @return array formatted statuses.
*/
public static function get_order_statuses( $statuses ) {
$formatted_statuses = array();
foreach ( $statuses as $key => $value ) {
$formatted_key = preg_replace( '/^wc-/', '', $key );
$formatted_statuses[ $formatted_key ] = $value;
}
return $formatted_statuses;
}
/**
* Get all order statuses present in analytics tables that aren't registered.
*
* @return array Unregistered order statuses.
*/
private function get_unregistered_order_statuses() {
$registered_statuses = wc_get_order_statuses();
$all_synced_statuses = OrdersDataStore::get_all_statuses();
$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
$formatted_status_keys = self::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
$formatted_statuses = array_keys( $formatted_status_keys );
return array_combine( $formatted_statuses, $formatted_statuses );
}
/**
* Return an object defining the currecy options for the site's current currency
*
* @return array Settings for the current currency {
* Array of settings.
*
* @type string $code Currency code.
* @type string $precision Number of decimals.
* @type string $symbol Symbol for currency.
* }
*/
public static function get_currency_settings() {
$code = get_woocommerce_currency();
//phpcs:ignore
return apply_filters(
'wc_currency_settings',
array(
'code' => $code,
'precision' => wc_get_price_decimals(),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
'symbolPosition' => get_option( 'woocommerce_currency_pos' ),
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'priceFormat' => html_entity_decode( get_woocommerce_price_format() ),
)
);
}
/**
* Hooks extra neccessary data into the component settings array already set in WooCommerce core.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
if ( ! function_exists( 'wc_blocks_container' ) ) {
global $wp_locale;
// inject data not available via older versions of wc_blocks/woo.
$settings['orderStatuses'] = self::get_order_statuses( wc_get_order_statuses() );
$settings['stockStatuses'] = self::get_order_statuses( wc_get_product_stock_status_options() );
$settings['currency'] = self::get_currency_settings();
$settings['locale'] = array(
'siteLocale' => isset( $settings['siteLocale'] )
? $settings['siteLocale']
: get_locale(),
'userLocale' => isset( $settings['l10n']['userLocale'] )
? $settings['l10n']['userLocale']
: get_user_locale(),
'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
? $settings['l10n']['weekdaysShort']
: array_values( $wp_locale->weekday_abbrev ),
);
}
//phpcs:ignore
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
'rest_preload_api_request'
);
}
//phpcs:ignore
$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
if ( ! empty( $preload_options ) ) {
foreach ( $preload_options as $option ) {
$settings['preloadOptions'][ $option ] = get_option( $option );
}
}
//phpcs:ignore
$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
if ( ! empty( $preload_settings ) ) {
$setting_options = new \WC_REST_Setting_Options_V2_Controller();
foreach ( $preload_settings as $group ) {
$group_settings = $setting_options->get_group_settings( $group );
$preload_settings = array();
foreach ( $group_settings as $option ) {
if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
$preload_settings[ $option['id'] ] = $option['value'];
}
}
$settings['preloadSettings'][ $group ] = $preload_settings;
}
}
$user_controller = new \WP_REST_Users_Controller();
$request = new \WP_REST_Request();
$request->set_query_params( array( 'context' => 'edit' ) );
$user_response = $user_controller->get_current_item( $request );
$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
$settings['currentUserData'] = $current_user_data;
$settings['reviewsEnabled'] = get_option( 'woocommerce_enable_reviews' );
$settings['manageStock'] = get_option( 'woocommerce_manage_stock' );
$settings['commentModeration'] = get_option( 'comment_moderation' );
$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
/**
* Deprecate wcAdminAssetUrl as we no longer need it after The Merge.
* Use wcAssetUrl instead.
*
* @deprecated 6.7.0
* @var string
*/
$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
$settings['wcVersion'] = WC_VERSION;
$settings['siteUrl'] = site_url();
$settings['shopUrl'] = get_permalink( wc_get_page_id( 'shop' ) );
$settings['homeUrl'] = home_url();
$settings['dateFormat'] = get_option( 'date_format' );
$settings['timeZone'] = wc_timezone_string();
$settings['plugins'] = array(
'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
'activePlugins' => Plugins::get_active_plugins(),
);
// Plugins that depend on changing the translation work on the server but not the client -
// WooCommerce Branding is an example of this - so pass through the translation of
// 'WooCommerce' to wcSettings.
$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = $this->get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
//phpcs:ignore
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
? $settings['dataEndpoints']
: array();
foreach ( $preload_data_endpoints as $key => $endpoint ) {
// Handle error case: rest_do_request() doesn't guarantee success.
if ( empty( $preload_data[ $endpoint ] ) ) {
$settings['dataEndpoints'][ $key ] = array();
} else {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
}
}
$settings = $this->get_custom_settings( $settings );
if ( PageController::is_embed_page() ) {
$settings['embedBreadcrumbs'] = wc_admin_get_breadcrumbs();
}
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
return $settings;
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*/
public function add_settings_group( $groups ) {
$groups[] = array(
'id' => 'wc_admin',
'label' => __( 'WooCommerce Admin', 'woocommerce' ),
'description' => __( 'Settings for WooCommerce admin reporting.', 'woocommerce' ),
);
return $groups;
}
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*/
public function add_settings( $settings ) {
$unregistered_statuses = $this->get_unregistered_order_statuses();
$registered_statuses = self::get_order_statuses( wc_get_order_statuses() );
$all_statuses = array_merge( $unregistered_statuses, $registered_statuses );
$settings[] = array(
'id' => 'woocommerce_excluded_report_order_statuses',
'option_key' => 'woocommerce_excluded_report_order_statuses',
'label' => __( 'Excluded report order statuses', 'woocommerce' ),
'description' => __( 'Statuses that should not be included when calculating report totals.', 'woocommerce' ),
'default' => array( 'pending', 'cancelled', 'failed' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_actionable_order_statuses',
'option_key' => 'woocommerce_actionable_order_statuses',
'label' => __( 'Actionable order statuses', 'woocommerce' ),
'description' => __( 'Statuses that require extra action on behalf of the store admin.', 'woocommerce' ),
'default' => array( 'processing', 'on-hold' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_default_date_range',
'option_key' => 'woocommerce_default_date_range',
'label' => __( 'Default Date Range', 'woocommerce' ),
'description' => __( 'Default Date Range', 'woocommerce' ),
'default' => 'period=month&compare=previous_year',
'type' => 'text',
);
return $settings;
}
/**
* Gets custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*/
private function get_custom_settings( $settings ) {
$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
if ( ! empty( $setting['id'] ) ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
}
return $settings;
}
}
Admin/Orders/PostsRedirectionController.php 0000644 00000010552 15233366071 0015120 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
/**
* When {@see OrdersTableDataStore} is in use, this class takes care of redirecting admins from CPT-based URLs
* to the new ones.
*/
class PostsRedirectionController {
/**
* Instance of the PageController class.
*
* @var PageController
*/
private $page_controller;
/**
* Constructor.
*
* @param PageController $page_controller Page controller instance. Used to generate links/URLs.
*/
public function __construct( PageController $page_controller ) {
$this->page_controller = $page_controller;
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return;
}
add_action(
'load-edit.php',
function() {
$this->maybe_redirect_to_orders_page();
}
);
add_action(
'load-post-new.php',
function() {
$this->maybe_redirect_to_new_order_page();
}
);
add_action(
'load-post.php',
function() {
$this->maybe_redirect_to_edit_order_page();
}
);
}
/**
* If needed, performs a redirection to the main orders page.
*
* @return void
*/
private function maybe_redirect_to_orders_page(): void {
if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'] ?? '';
$posts = $query_args['post'] ?? array();
unset( $query_args['post_type'], $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
// Remap 'post_status' arg.
if ( isset( $query_args['post_status'] ) ) {
$query_args['status'] = $query_args['post_status'];
unset( $query_args['post_status'] );
}
$new_url = $this->page_controller->get_orders_url();
$new_url = add_query_arg( $query_args, $new_url );
// Handle bulk actions.
if ( $action && in_array( $action, array( 'trash', 'untrash', 'delete', 'mark_processing', 'mark_on-hold', 'mark_completed', 'mark_cancelled' ), true ) ) {
check_admin_referer( 'bulk-posts' );
$new_url = add_query_arg(
array(
'action' => $action,
'order' => $posts,
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$new_url
);
}
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the new order page.
*
* @return void
*/
private function maybe_redirect_to_new_order_page(): void {
if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
unset( $query_args['post_type'] );
$new_url = $this->page_controller->get_new_page_url();
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the edit order page.
*
* @return void
*/
private function maybe_redirect_to_edit_order_page(): void {
$post_id = absint( $_GET['post'] ?? 0 );
if ( ! $post_id || ! in_array( get_post_type( $post_id ), array( 'shop_order_placehold', 'shop_order' ), true ) || ! isset( $_GET['action'] ) ) {
return;
}
// Respect query args, except for 'post'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'];
unset( $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
$new_url = '';
switch ( $action ) {
case 'edit':
$new_url = $this->page_controller->get_edit_url( $post_id );
break;
case 'trash':
case 'untrash':
case 'delete':
// Re-generate nonce if validation passes.
check_admin_referer( $action . '-post_' . $post_id );
$new_url = add_query_arg(
array(
'action' => $action,
'order' => array( $post_id ),
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$this->page_controller->get_orders_url()
);
break;
default:
break;
}
if ( ! $new_url ) {
return;
}
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
}
Admin/Orders/MetaBoxes/CustomMetaBox.php 0000644 00000032647 15233366071 0014206 0 ustar 00 <?php
/**
* Meta box to edit and add custom meta values for an order.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use WC_Data_Store;
use WC_Meta_Data;
use WC_Order;
use WP_Ajax_Response;
/**
* Class CustomMetaBox.
*/
class CustomMetaBox {
/**
* Update nonce shared among different meta rows.
*
* @var string
*/
private $update_nonce;
/**
* Helper method to get formatted meta data array with proper keys. This can be directly fed to `list_meta()` method.
*
* @param \WC_Order $order Order object.
*
* @return array Meta data.
*/
private function get_formatted_order_meta_data( \WC_Order $order ) {
$metadata = $order->get_meta_data();
$metadata_to_list = array();
foreach ( $metadata as $meta ) {
$data = $meta->get_data();
if ( is_protected_meta( $data['key'], 'order' ) ) {
continue;
}
$metadata_to_list[] = array(
'meta_id' => $data['id'],
'meta_key' => $data['key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive, not a meta query.
'meta_value' => maybe_serialize( $data['value'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- False positive, not a meta query.
);
}
return $metadata_to_list;
}
/**
* Renders the meta box to manage custom meta.
*
* @param \WP_Post|\WC_Order $order_or_post Post or order object that we are rendering for.
*/
public function output( $order_or_post ) {
if ( is_a( $order_or_post, \WP_Post::class ) ) {
$order = wc_get_order( $order_or_post );
} else {
$order = $order_or_post;
}
$this->render_custom_meta_form( $this->get_formatted_order_meta_data( $order ), $order );
}
/**
* Helper method to render layout and actual HTML
*
* @param array $metadata_to_list List of metadata to render.
* @param \WC_Order $order Order object.
*/
private function render_custom_meta_form( array $metadata_to_list, \WC_Order $order ) {
?>
<div id="postcustomstuff">
<div id="ajax-response"></div>
<?php
list_meta( $metadata_to_list );
$this->render_meta_form( $order );
?>
</div>
<p>
<?php
printf(
/* translators: 1: opening documentation tag 2: closing documentation tag. */
esc_html( __( 'Custom fields can be used to add extra metadata to an order that you can %1$suse in your theme%2$s.', 'woocommerce' ) ),
'<a href="' . esc_attr__( 'https://wordpress.org/support/article/custom-fields/', 'woocommerce' ) . '">',
'</a>'
);
?>
</p>
<?php
}
/**
* Compute keys to display in autofill when adding new meta key entry in custom meta box.
* Currently, returns empty keys, will be implemented after caching is merged.
*
* @param array|null $keys Keys to display in autofill.
* @param \WP_Post|\WC_Order $order Order object.
*
* @return array|mixed Array of keys to display in autofill.
*/
public function order_meta_keys_autofill( $keys, $order ) {
if ( is_a( $order, \WC_Order::class ) ) {
return array();
}
return $keys;
}
/**
* Reimplementation of WP core's `meta_form` function. Renders meta form box.
*
* @param \WC_Order $order WC_Order object.
*
* @return void
*/
public function render_meta_form( \WC_Order $order ) : void {
$meta_key_input_id = 'metakeyselect';
$keys = $this->order_meta_keys_autofill( null, $order );
/**
* Filters values for the meta key dropdown in the Custom Fields meta box.
*
* Compatibility filter for `postmeta_form_keys` filter.
*
* @since 6.9.0
*
* @param array|null $keys Pre-defined meta keys to be used in place of a postmeta query. Default null.
* @param \WC_Order $order The current post object.
*/
$keys = apply_filters( 'postmeta_form_keys', $keys, $order );
?>
<p><strong><?php esc_html_e( 'Add New Custom Field:', 'woocommerce' ); ?></strong></p>
<table id="newmeta">
<thead>
<tr>
<th class="left"><label for="<?php echo esc_attr( $meta_key_input_id ); ?>"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label></th>
<th><label for="metavalue"><?php esc_html_e( 'Value', 'woocommerce' ); ?></label></th>
</tr>
</thead>
<tbody>
<tr>
<td id="newmetaleft" class="left">
<?php if ( $keys ) { ?>
<select id="metakeyselect" name="metakeyselect">
<option value="#NONE#"><?php esc_html_e( '— Select —', 'woocommerce' ); ?></option>
<?php
foreach ( $keys as $key ) {
if ( is_protected_meta( $key, 'post' ) || ! current_user_can( 'edit_others_shop_order', $order->get_id() ) ) {
continue;
}
echo "\n<option value='" . esc_attr( $key ) . "'>" . esc_html( $key ) . '</option>';
}
?>
</select>
<input class="hide-if-js" type="text" id="metakeyinput" name="metakeyinput" value="" />
<a href="#postcustomstuff" class="hide-if-no-js" onclick="jQuery('#metakeyinput, #metakeyselect, #enternew, #cancelnew').toggle();return false;">
<span id="enternew"><?php esc_html_e( 'Enter new', 'woocommerce' ); ?></span>
<span id="cancelnew" class="hidden"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></span></a>
<?php } else { ?>
<input type="text" id="metakeyinput" name="metakeyinput" value="" />
<?php } ?>
</td>
<td><textarea id="metavalue" name="metavalue" rows="2" cols="25"></textarea></td>
</tr>
<tr><td colspan="2">
<div class="submit">
<?php
submit_button(
__( 'Add Custom Field', 'woocommerce' ),
'',
'addmeta',
false,
array(
'id' => 'newmeta-submit',
'data-wp-lists' => 'add:the-list:newmeta',
)
);
?>
</div>
<?php wp_nonce_field( 'add-meta', '_ajax_nonce-add-meta', false ); ?>
</td></tr>
</tbody>
</table>
<?php
}
/**
* Helper method to verify order edit permissions.
*
* @param int $order_id Order ID.
*
* @return ?WC_Order WC_Order object if the user can edit the order, die otherwise.
*/
private function verify_order_edit_permission_for_ajax( int $order_id ): ?WC_Order {
if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_others_shop_orders' ) ) {
wp_send_json_error( 'missing_capabilities' );
wp_die();
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_send_json_error( 'invalid_order_id' );
wp_die();
}
return $order;
}
/**
* Reimplementation of WP core's `wp_ajax_add_meta` method to support order custom meta updates with custom tables.
*/
public function add_meta_ajax() {
if ( ! check_ajax_referer( 'add-meta', '_ajax_nonce-add-meta' ) ) {
wp_send_json_error( 'invalid_nonce' );
wp_die();
}
$order_id = (int) $_POST['order_id'] ?? 0;
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
if ( isset( $_POST['metakeyselect'] ) && '#NONE#' === $_POST['metakeyselect'] && empty( $_POST['metakeyinput'] ) ) {
wp_die( 1 );
}
if ( isset( $_POST['metakeyinput'] ) ) { // add meta.
$meta_key = sanitize_text_field( wp_unslash( $_POST['metakeyinput'] ) );
$meta_value = sanitize_text_field( wp_unslash( $_POST['metavalue'] ?? '' ) );
$this->handle_add_meta( $order, $meta_key, $meta_value );
} else { // update.
$meta = wp_unslash( $_POST['meta'] ?? array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitization done below in array_walk.
$this->handle_update_meta( $order, $meta );
}
}
/**
* Part of WP Core's `wp_ajax_add_meta`. This is re-implemented to support updating meta for custom tables.
*
* @param WC_Order $order Order object.
* @param string $meta_key Meta key.
* @param string $meta_value Meta value.
*
* @return void
*/
private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
$order_data_store = WC_Data_Store::load( 'order' );
$count = 0;
if ( is_protected_meta( $meta_key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
$meta_id = $order_data_store->add_meta(
$order,
new WC_Meta_Data(
array(
'key' => $meta_key,
'value' => $meta_value,
)
)
);
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $meta_id,
'data' => $this->list_meta_row(
array(
'meta_id' => $meta_id,
'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
),
$count
),
'position' => 1,
)
);
$response->send();
}
/**
* Handles updating metadata.
*
* @param WC_Order $order Order object.
* @param array $meta Meta object to update.
*
* @return void
*/
private function handle_update_meta( WC_Order $order, array $meta ) {
if ( ! is_array( $meta ) ) {
wp_send_json_error( 'invalid_meta' );
wp_die();
}
array_walk( $meta, 'sanitize_text_field' );
$mid = (int) key( $meta );
if ( ! $mid ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$key = $meta[ $mid ]['key'];
$value = $meta[ $mid ]['value'];
if ( is_protected_meta( $key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
if ( '' === trim( $key ) ) {
wp_send_json_error( 'invalid_meta_key' );
wp_die();
}
$order_data_store = WC_Data_Store::load( 'order' );
$count = 0;
$meta_object = new WC_Meta_Data(
array(
'id' => $mid,
'key' => $key,
'value' => $value,
)
);
$order_data_store->update_meta( $order, $meta_object );
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $mid,
'old_id' => $mid,
'data' => $this->list_meta_row(
array(
'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
'meta_id' => $mid,
),
$count
),
'position' => 0,
)
);
$response->send();
}
/**
* Outputs a single row of public meta data in the Custom Fields meta box.
*
* @since 2.5.0
*
* @param array $entry Meta entry.
* @param int $count Sequence number of meta entries.
* @return string
*/
private function list_meta_row( array $entry, int &$count ) : string {
if ( is_protected_meta( $entry['meta_key'], 'post' ) ) {
return '';
}
if ( ! $this->update_nonce ) {
$this->update_nonce = wp_create_nonce( 'add-meta' );
}
$r = '';
++ $count;
if ( is_serialized( $entry['meta_value'] ) ) {
if ( is_serialized_string( $entry['meta_value'] ) ) {
// This is a serialized string, so we should display it.
$entry['meta_value'] = maybe_unserialize( $entry['meta_value'] ); // // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
} else {
// This is a serialized array/object so we should NOT display it.
--$count;
return '';
}
}
$entry['meta_key'] = esc_attr( $entry['meta_key'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
$entry['meta_value'] = esc_textarea( $entry['meta_value'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
$entry['meta_id'] = (int) $entry['meta_id'];
$delete_nonce = wp_create_nonce( 'delete-meta_' . $entry['meta_id'] );
$r .= "\n\t<tr id='meta-{$entry['meta_id']}'>";
$r .= "\n\t\t<td class='left'><label class='screen-reader-text' for='meta-{$entry['meta_id']}-key'>" . __( 'Key', 'woocommerce' ) . "</label><input name='meta[{$entry['meta_id']}][key]' id='meta-{$entry['meta_id']}-key' type='text' size='20' value='{$entry['meta_key']}' />";
$r .= "\n\t\t<div class='submit'>";
$r .= get_submit_button( __( 'Delete', 'woocommerce' ), 'deletemeta small', "deletemeta[{$entry['meta_id']}]", false, array( 'data-wp-lists' => "delete:the-list:meta-{$entry['meta_id']}::_ajax_nonce:$delete_nonce" ) );
$r .= "\n\t\t";
$r .= get_submit_button( __( 'Update', 'woocommerce' ), 'updatemeta small', "meta-{$entry['meta_id']}-submit", false, array( 'data-wp-lists' => "add:the-list:meta-{$entry['meta_id']}::_ajax_nonce-add-meta={$this->update_nonce}" ) );
$r .= '</div>';
$r .= wp_nonce_field( 'change-meta', '_ajax_nonce', false, false );
$r .= '</td>';
$r .= "\n\t\t<td><label class='screen-reader-text' for='meta-{$entry['meta_id']}-value'>" . __( 'Value', 'woocommerce' ) . "</label><textarea name='meta[{$entry['meta_id']}][value]' id='meta-{$entry['meta_id']}-value' rows='2' cols='30'>{$entry['meta_value']}</textarea></td>\n\t</tr>";
return $r;
}
/**
* Reimplementation of WP core's `wp_ajax_delete_meta` method to support order custom meta updates with custom tables.
*
* @return void
*/
public function delete_meta_ajax() {
$meta_id = (int) $_POST['id'] ?? 0;
$order_id = (int) $_POST['order_id'] ?? 0;
if ( ! $meta_id || ! $order_id ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
check_ajax_referer( "delete-meta_$meta_id" );
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
$meta_to_delete = wp_list_filter( $order->get_meta_data(), array( 'id' => $meta_id ) );
if ( empty( $meta_to_delete ) ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$order->delete_meta_data_by_mid( $meta_id );
if ( $order->save() ) {
wp_die( 1 );
}
wp_die( 0 );
}
}
Admin/Orders/PageController.php 0000644 00000016037 15233366071 0012500 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* Controls the different pages/screens associated to the "Orders" menu page.
*/
class PageController {
use AccessiblePrivateMethods;
/**
* Instance of the posts redirection controller.
*
* @var PostsRedirectionController
*/
private $redirection_controller;
/**
* Instance of the orders list table.
*
* @var ListTable
*/
private $orders_table;
/**
* Instance of orders edit form.
*
* @var Edit
*/
private $order_edit_form;
/**
* Current action.
*
* @var string
*/
private $current_action = '';
/**
* Order object to be used in edit/new form.
*
* @var \WC_Order
*/
private $order;
/**
* Verify that user has permission to edit orders.
*
* @return void
*/
private function verify_edit_permission() {
if ( 'edit_order' === $this->current_action && ( ! isset( $this->order ) || ! $this->order ) ) {
wp_die( esc_html__( 'You attempted to edit an order that does not exist. Perhaps it was deleted?', 'woocommerce' ) );
}
if ( ! current_user_can( 'edit_others_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to edit this order', 'woocommerce' ) );
}
if ( 'trash' === $this->order->get_status() ) {
wp_die( esc_html__( 'You cannot edit this item because it is in the Trash. Please restore it and try again.', 'woocommerce' ) );
}
}
/**
* Verify that user has permission to create order.
*
* @return void
*/
private function verify_create_permission() {
if ( ! current_user_can( 'publish_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You don\'t have permission to create a new order', 'woocommerce' ) );
}
if ( isset( $this->order ) ) {
$this->verify_edit_permission();
}
}
/**
* Sets up the page controller, including registering the menu item.
*
* @return void
*/
public function setup(): void {
$this->redirection_controller = new PostsRedirectionController( $this );
// Register menu.
if ( 'admin_menu' === current_action() ) {
$this->register_menu();
} else {
add_action( 'admin_menu', 'register_menu', 9 );
}
$this->set_action();
self::add_action( 'load-woocommerce_page_wc-orders', array( $this, 'handle_load_page_action' ) );
}
/**
* Perform initialization for the current action.
*/
private function handle_load_page_action() {
if ( method_exists( $this, 'setup_action_' . $this->current_action ) ) {
$this->{"setup_action_{$this->current_action}"}();
}
}
/**
* Sets the current action based on querystring arguments. Defaults to 'list_orders'.
*
* @return void
*/
private function set_action(): void {
switch ( isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '' ) {
case 'edit':
$this->current_action = 'edit_order';
break;
case 'new':
$this->current_action = 'new_order';
break;
default:
$this->current_action = 'list_orders';
break;
}
}
/**
* Registers the "Orders" menu.
*
* @return void
*/
public function register_menu(): void {
add_submenu_page(
'woocommerce',
__( 'Orders', 'woocommerce' ),
__( 'Orders', 'woocommerce' ),
'edit_others_shop_orders',
'wc-orders',
array( $this, 'output' )
);
// In some cases (such as if the authoritative order store was changed earlier in the current request) we
// need an extra step to remove the menu entry for the menu post type.
add_action(
'admin_init',
function() {
remove_submenu_page( 'woocommerce', 'edit.php?post_type=shop_order' );
}
);
}
/**
* Outputs content for the current orders screen.
*
* @return void
*/
public function output(): void {
switch ( $this->current_action ) {
case 'edit_order':
case 'new_order':
if ( ! isset( $this->order_edit_form ) ) {
$this->order_edit_form = new Edit();
$this->order_edit_form->setup( $this->order );
}
$this->order_edit_form->display();
break;
case 'list_orders':
default:
$this->orders_table->prepare_items();
$this->orders_table->display();
break;
}
}
/**
* Handles initialization of the orders list table.
*
* @return void
*/
private function setup_action_list_orders(): void {
$this->orders_table = wc_get_container()->get( ListTable::class );
$this->orders_table->setup();
if ( $this->orders_table->current_action() ) {
$this->orders_table->handle_bulk_actions();
}
$this->strip_http_referer();
}
/**
* Perform a redirect to remove the `_wp_http_referer` and `_wpnonce` strings if present in the URL (see also
* wp-admin/edit.php where a similar process takes place), otherwise the size of this field builds to an
* unmanageable length over time.
*/
private function strip_http_referer(): void {
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
$stripped_url = remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), $current_url );
if ( $stripped_url !== $current_url ) {
wp_safe_redirect( $stripped_url );
exit;
}
}
/**
* Handles initialization of the orders edit form.
*
* @return void
*/
private function setup_action_edit_order(): void {
global $theorder;
$this->order = wc_get_order( absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ) );
$this->verify_edit_permission();
$theorder = $this->order;
}
/**
* Handles initialization of the orders edit form with a new order.
*
* @return void
*/
private function setup_action_new_order(): void {
global $theorder;
$this->verify_create_permission();
$this->order = new \WC_Order();
$this->order->set_object_read( false );
$this->order->set_status( 'auto-draft' );
$this->order->save();
$theorder = $this->order;
}
/**
* Helper method to generate a link to the main orders screen.
*
* @return string Orders screen URL.
*/
public function get_orders_url(): string {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
admin_url( 'admin.php?page=wc-orders' ) :
admin_url( 'edit.php?post_type=shop_order' );
}
/**
* Helper method to generate edit link for an order.
*
* @param int $order_id Order ID.
*
* @return string Edit link.
*/
public function get_edit_url( int $order_id ) : string {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
admin_url( 'admin.php?page=wc-orders&id=' . absint( $order_id ) ) . '&action=edit' :
admin_url( 'post.php?post=' . absint( $order_id ) ) . '&action=edit';
}
/**
* Helper method to generate a link for creating order.
*
* @return string
*/
public function get_new_page_url() : string {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
admin_url( 'admin.php?page=wc-orders&action=new' ) :
admin_url( 'post-new.php?post_type=shop_order' );
}
}
Admin/Orders/ListTable.php 0000644 00000116025 15233366071 0011441 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use WC_Order;
use WP_List_Table;
use WP_Screen;
/**
* Admin list table for orders as managed by the OrdersTableDataStore.
*/
class ListTable extends WP_List_Table {
/**
* Contains the arguments to be used in the order query.
*
* @var array
*/
private $order_query_args = array();
/**
* Tracks if a filter (ie, date or customer filter) has been applied.
*
* @var bool
*/
private $has_filter = false;
/**
* Page controller instance for this request.
*
* @var PageController
*/
private $page_controller;
/**
* Tracks whether we're currently inside the trash.
*
* @var boolean
*/
private $is_trash = false;
/**
* Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
*
* @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'order',
'plural' => 'orders',
'ajax' => false,
)
);
}
/**
* Init method, invoked by DI container.
*
* @internal This method is not intended to be used directly (except for testing).
* @param PageController $page_controller Page controller instance for this request.
*/
final public function init( PageController $page_controller ) {
$this->page_controller = $page_controller;
}
/**
* Performs setup work required before rendering the table.
*
* @return void
*/
public function setup(): void {
add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
add_filter( 'manage_woocommerce_page_wc-orders_columns', array( $this, 'get_columns' ), 0 );
add_filter( 'set_screen_option_edit_orders_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );
$this->items_per_page();
set_screen_options();
add_action( 'manage_' . wc_get_page_screen_id( 'shop-order' ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
}
/**
* Render individual column.
*
* @param string $column_id Column ID to render.
* @param WC_Order $order Order object.
*/
public function render_column( $column_id, $order ) {
if ( ! $order ) {
return;
}
if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
}
}
/**
* Handles output for the default column.
*
* @param \WC_Order $order Current WooCommerce order object.
* @param string $column_name Identifier for the custom column.
*/
public function column_default( $order, $column_name ) {
/**
* Fires for each custom column in the Custom Order Table in the administrative screen.
*
* @param string $column_name Identifier for the custom column.
* @param \WC_Order $order Current WooCommerce order object.
*
* @since 7.0.0
*/
do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
}
/**
* Sets up an items-per-page control.
*/
private function items_per_page(): void {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'edit_orders_per_page',
)
);
}
/**
* Saves the items-per-page setting.
*
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
*
* @return mixed
*/
public function set_items_per_page( $default, string $option, int $value ) {
return 'edit_orders_per_page' === $option ? absint( $value ) : $default;
}
/**
* Render the table.
*
* @return void
*/
public function display() {
$title = esc_html__( 'Orders', 'woocommerce' );
$add_new = esc_html__( 'Add Order', 'woocommerce' );
$new_page_link = $this->page_controller->get_new_page_url();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wp_kses_post(
"
<div class='wrap'>
<h1 class='wp-heading-inline'>{$title}</h1>
<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
<hr class='wp-header-end'>"
);
if ( $this->has_items() || $this->has_filter ) {
$this->views();
echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
$this->print_hidden_form_fields();
$this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );
parent::display();
echo '</form> </div>';
} else {
$this->render_blank_state();
}
}
/**
* Renders advice in the event that no orders exist yet.
*
* @return void
*/
public function render_blank_state(): void {
?>
<div class="woocommerce-BlankState">
<h2 class="woocommerce-BlankState-message">
<?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
</h2>
<div class="woocommerce-BlankState-buttons">
<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://docs.woocommerce.com/document/managing-orders/?utm_source=blankslate&utm_medium=product&utm_content=ordersdoc&utm_campaign=woocommerceplugin"><?php esc_html_e( 'Learn more about orders', 'woocommerce' ); ?></a>
</div>
<?php
/**
* Renders after the 'blank state' message for the order list table has rendered.
*
* @since 6.6.1
*/
do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
?>
</div>
<?php
}
/**
* Retrieves the list of bulk actions available for this table.
*
* @return array
*/
protected function get_bulk_actions() {
$selected_status = $this->order_query_args['status'] ?? false;
if ( array( 'trash' ) === $selected_status ) {
$actions = array(
'untrash' => __( 'Restore', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
);
} else {
$actions = array(
'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
'mark_on-hold' => __( 'Change status to on-hold', 'woocommerce' ),
'mark_completed' => __( 'Change status to completed', 'woocommerce' ),
'mark_cancelled' => __( 'Change status to cancelled', 'woocommerce' ),
'trash' => __( 'Move to Trash', 'woocommerce' ),
);
}
if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
$actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
}
return $actions;
}
/**
* Prepares the list of items for displaying.
*/
public function prepare_items() {
$limit = $this->get_items_per_page( 'edit_orders_per_page' );
$this->order_query_args = array(
'limit' => $limit,
'page' => $this->get_pagenum(),
'paginate' => true,
'type' => 'shop_order',
);
$this->set_status_args();
$this->set_order_args();
$this->set_date_args();
$this->set_customer_args();
$this->set_search_args();
/**
* Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
* table.
*
* @since 6.9.0
*
* @param array $query_args Arguments to be passed to `wc_get_orders()`.
*/
$order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );
// We must ensure the 'paginate' argument is set.
$order_query_args['paginate'] = true;
$orders = wc_get_orders( $order_query_args );
$this->items = $orders->orders;
$max_num_pages = $orders->max_num_pages;
// Check in case the user has attempted to page beyond the available range of orders.
if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
$count_query_args = $order_query_args;
$count_query_args['page'] = 1;
$count_query_args['limit'] = 1;
$order_count = wc_get_orders( $count_query_args );
$max_num_pages = (int) ceil( $order_count->total / $order_query_args['limit'] );
}
$this->set_pagination_args(
array(
'total_items' => $orders->total ?? 0,
'per_page' => $limit,
'total_pages' => $max_num_pages,
)
);
// Are we inside the trash?
$this->is_trash = isset( $_REQUEST['status'] ) && 'trash' === $_REQUEST['status'];
}
/**
* Updates the WC Order Query arguments as needed to support orderable columns.
*/
private function set_order_args() {
$sortable = $this->get_sortable_columns();
$field = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );
if ( ! in_array( $field, $sortable, true ) ) {
return;
}
$this->order_query_args['orderby'] = $field;
$this->order_query_args['order'] = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
}
/**
* Implements date (month-based) filtering.
*/
private function set_date_args() {
$year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );
if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
return;
}
$year = (int) substr( $year_month, 0, 4 );
$month = (int) substr( $year_month, 4, 2 );
if ( $month < 0 || $month > 12 ) {
return;
}
$last_day_of_month = date_create( "$year-$month" )->format( 'Y-m-t' );
$this->order_query_args['date_created'] = "$year-$month-01..." . $last_day_of_month;
$this->has_filter = true;
}
/**
* Implements filtering of orders by customer.
*/
private function set_customer_args() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$customer = (int) wp_unslash( $_GET['_customer_user'] ?? '' );
if ( $customer < 1 ) {
return;
}
$this->order_query_args['customer'] = $customer;
$this->has_filter = true;
}
/**
* Implements filtering of orders by status.
*/
private function set_status_args() {
$status = trim( sanitize_text_field( wp_unslash( $_REQUEST['status'] ?? '' ) ) );
$query_statuses = array();
if ( empty( $status ) || 'all' === $status ) {
$query_statuses = array_intersect(
array_keys( wc_get_order_statuses() ),
get_post_stati( array( 'show_in_admin_all_list' => true ), 'names' )
);
} else {
$query_statuses[] = $status;
$this->has_filter = true;
}
$this->order_query_args['status'] = $query_statuses;
}
/**
* Implements order search.
*/
private function set_search_args(): void {
$search_term = trim( sanitize_text_field( wp_unslash( $_REQUEST['s'] ?? '' ) ) );
if ( ! empty( $search_term ) ) {
$this->order_query_args['s'] = $search_term;
$this->has_filter = true;
}
}
/**
* Get the list of views for this table (all orders, completed orders, etc, each with a count of the number of
* corresponding orders).
*
* @return array
*/
public function get_views() {
$view_counts = array();
$view_links = array();
$statuses = wc_get_order_statuses();
$current = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ?? '' ) ) : 'all';
// Add 'draft' and 'trash' to list.
foreach ( array( 'draft', 'trash' ) as $wp_status ) {
$statuses[ $wp_status ] = ( get_post_status_object( $wp_status ) )->label;
}
$statuses_in_list = array_intersect( array_keys( $statuses ), get_post_stati( array( 'show_in_admin_status_list' => true ) ) );
foreach ( $statuses_in_list as $slug ) {
$total_in_status = $this->count_orders_by_status( $slug );
if ( $total_in_status > 0 ) {
$view_counts[ $slug ] = $total_in_status;
}
}
$all_count = array_sum( $view_counts );
$view_links['all'] = $this->get_view_link( 'all', __( 'All', 'woocommerce' ), $all_count, '' === $current || 'all' === $current );
foreach ( $view_counts as $slug => $count ) {
$view_links[ $slug ] = $this->get_view_link( $slug, $statuses[ $slug ], $count, $slug === $current );
}
return $view_links;
}
/**
* Count orders by status.
*
* @param string $status The order status we are interested in.
*
* @return int
*/
private function count_orders_by_status( string $status ): int {
$orders = wc_get_orders(
array(
'limit' => -1,
'return' => 'ids',
'status' => $status,
)
);
return count( $orders );
}
/**
* Form a link to use in the list of table views.
*
* @param string $slug Slug used to identify the view (usually the order status slug).
* @param string $name Human-readable name of the view (usually the order status label).
* @param int $count Number of items in this view.
* @param bool $current If this is the current view.
*
* @return string
*/
private function get_view_link( string $slug, string $name, int $count, bool $current ): string {
$url = esc_url( add_query_arg( 'status', $slug, get_admin_url( null, 'admin.php?page=wc-orders' ) ) );
$name = esc_html( $name );
$count = absint( $count );
$class = $current ? 'class="current"' : '';
return "<a href='$url' $class>$name <span class='count'>($count)</span></a>";
}
/**
* Extra controls to be displayed between bulk actions and pagination.
*
* @param string $which Either 'top' or 'bottom'.
*/
protected function extra_tablenav( $which ) {
echo '<div class="alignleft actions">';
if ( 'top' === $which ) {
$this->months_filter();
$this->customers_filter();
submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'order-query-submit' ) );
}
if ( $this->is_trash && $this->has_items() && current_user_can( 'edit_others_shop_orders' ) ) {
submit_button( __( 'Empty Trash', 'woocommerce' ), 'apply', 'delete_all', false );
}
echo '</div>';
}
/**
* Render the months filter dropdown.
*
* @return void
*/
private function months_filter() {
// XXX: [review] we may prefer to move this logic outside of the ListTable class.
global $wp_locale;
global $wpdb;
$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_dates = $wpdb->get_results(
"
SELECT DISTINCT YEAR( date_created_gmt ) AS year,
MONTH( date_created_gmt ) AS month
FROM $orders_table
WHERE status NOT IN (
'trash'
)
ORDER BY year DESC, month DESC;
"
);
$m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0;
echo '<select name="m" id="filter-by-date">';
echo '<option ' . selected( $m, 0, false ) . ' value="0">' . esc_html__( 'All dates', 'woocommerce' ) . '</option>';
foreach ( $order_dates as $date ) {
$month = zeroise( $date->month, 2 );
$month_year_text = sprintf(
/* translators: 1: Month name, 2: 4-digit year. */
esc_html_x( '%1$s %2$d', 'order dates dropdown', 'woocommerce' ),
$wp_locale->get_month( $month ),
$date->year
);
printf(
'<option %1$s value="%2$s">%3$s</option>\n',
selected( $m, $date->year . $month, false ),
esc_attr( $date->year . $month ),
esc_html( $month_year_text )
);
}
echo '</select>';
}
/**
* Render the customer filter dropdown.
*
* @return void
*/
public function customers_filter() {
$user_string = '';
$user_id = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['_customer_user'] ) ) {
$user_id = absint( $_GET['_customer_user'] );
$user = get_user_by( 'id', $user_id );
$user_string = sprintf(
/* translators: 1: user display name 2: user ID 3: user email */
esc_html__( '%1$s (#%2$s – %3$s)', 'woocommerce' ),
$user->display_name,
absint( $user->ID ),
$user->user_email
);
}
// Note: use of htmlspecialchars (below) is to prevent XSS when rendered by selectWoo.
?>
<select class="wc-customer-search" name="_customer_user" data-placeholder="<?php esc_attr_e( 'Filter by registered customer', 'woocommerce' ); ?>" data-allow_clear="true">
<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo htmlspecialchars( wp_kses_post( $user_string ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></option>
</select>
<?php
}
/**
* Get list columns.
*
* @return array
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'order_number' => esc_html__( 'Order', 'woocommerce' ),
'order_date' => esc_html__( 'Date', 'woocommerce' ),
'order_status' => esc_html__( 'Status', 'woocommerce' ),
'billing_address' => esc_html__( 'Billing', 'woocommerce' ),
'shipping_address' => esc_html__( 'Ship to', 'woocommerce' ),
'order_total' => esc_html__( 'Total', 'woocommerce' ),
'wc_actions' => esc_html__( 'Actions', 'woocommerce' ),
);
}
/**
* Defines the default sortable columns.
*
* @return string[]
*/
public function get_sortable_columns() {
return array(
'order_number' => 'ID',
'order_date' => 'date',
'order_total' => 'order_total',
);
}
/**
* Specify the columns we wish to hide by default.
*
* @param array $hidden Columns set to be hidden.
* @param WP_Screen $screen Screen object.
*
* @return array
*/
public function default_hidden_columns( array $hidden, WP_Screen $screen ) {
if ( isset( $screen->id ) && wc_get_page_screen_id( 'shop-order' ) === $screen->id ) {
$hidden = array_merge(
$hidden,
array(
'billing_address',
'shipping_address',
'wc_actions',
)
);
}
return $hidden;
}
/**
* Checklist column, used for selecting items for processing by a bulk action.
*
* @param WC_Order $item The order object for the current row.
*
* @return string
*/
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="%1$s[]" value="%2$s" />', esc_attr( $this->_args['singular'] ), esc_attr( $item->get_id() ) );
}
/**
* Renders the order number, customer name and provides a preview link.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_number_column( WC_Order $order ): void {
$buyer = '';
if ( $order->get_billing_first_name() || $order->get_billing_last_name() ) {
/* translators: 1: first name 2: last name */
$buyer = trim( sprintf( _x( '%1$s %2$s', 'full name', 'woocommerce' ), $order->get_billing_first_name(), $order->get_billing_last_name() ) );
} elseif ( $order->get_billing_company() ) {
$buyer = trim( $order->get_billing_company() );
} elseif ( $order->get_customer_id() ) {
$user = get_user_by( 'id', $order->get_customer_id() );
$buyer = ucwords( $user->display_name );
}
/**
* Filter buyer name in list table orders.
*
* @since 3.7.0
*
* @param string $buyer Buyer name.
* @param WC_Order $order Order data.
*/
$buyer = apply_filters( 'woocommerce_admin_order_buyer_name', $buyer, $order );
if ( $order->get_status() === 'trash' ) {
echo '<strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong>';
} else {
echo '<a href="#" class="order-preview" data-order-id="' . absint( $order->get_id() ) . '" title="' . esc_attr( __( 'Preview', 'woocommerce' ) ) . '">' . esc_html( __( 'Preview', 'woocommerce' ) ) . '</a>';
echo '<a href="' . esc_url( $this->get_order_edit_link( $order ) ) . '" class="order-view"><strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong></a>';
}
}
/**
* Get the edit link for an order.
*
* @param WC_Order $order Order object.
*
* @return string Edit link for the order.
*/
private function get_order_edit_link( WC_Order $order ) : string {
return $this->page_controller->get_edit_url( $order->get_id() );
}
/**
* Renders the order date.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_date_column( WC_Order $order ): void {
$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';
if ( ! $order_timestamp ) {
echo '–';
return;
}
// Check if the order was created within the last 24 hours, and not in the future.
if ( $order_timestamp > strtotime( '-1 day', time() ) && $order_timestamp <= time() ) {
$show_date = sprintf(
/* translators: %s: human-readable time difference */
_x( '%s ago', '%s = human-readable time difference', 'woocommerce' ),
human_time_diff( $order->get_date_created()->getTimestamp(), time() )
);
} else {
$show_date = $order->get_date_created()->date_i18n( apply_filters( 'woocommerce_admin_order_date_format', __( 'M j, Y', 'woocommerce' ) ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
printf(
'<time datetime="%1$s" title="%2$s">%3$s</time>',
esc_attr( $order->get_date_created()->date( 'c' ) ),
esc_html( $order->get_date_created()->date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ),
esc_html( $show_date )
);
}
/**
* Renders the order status.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_status_column( WC_Order $order ): void {
$tooltip = '';
$comment_count = get_comment_count( $order->get_id() );
$approved_comments_count = absint( $comment_count['approved'] );
if ( $approved_comments_count ) {
$latest_notes = wc_get_order_notes(
array(
'order_id' => $order->get_id(),
'limit' => 1,
'orderby' => 'date_created_gmt',
)
);
$latest_note = current( $latest_notes );
if ( isset( $latest_note->content ) && 1 === $approved_comments_count ) {
$tooltip = wc_sanitize_tooltip( $latest_note->content );
} elseif ( isset( $latest_note->content ) ) {
/* translators: %d: notes count */
$tooltip = wc_sanitize_tooltip( $latest_note->content . '<br/><small style="display:block">' . sprintf( _n( 'Plus %d other note', 'Plus %d other notes', ( $approved_comments_count - 1 ), 'woocommerce' ), $approved_comments_count - 1 ) . '</small>' );
} else {
/* translators: %d: notes count */
$tooltip = wc_sanitize_tooltip( sprintf( _n( '%d note', '%d notes', $approved_comments_count, 'woocommerce' ), $approved_comments_count ) );
}
}
// Gracefully handle legacy statuses.
if ( in_array( $order->get_status(), array( 'trash', 'draft' ), true ) ) {
$status_name = ( get_post_status_object( $order->get_status() ) )->label;
} else {
$status_name = wc_get_order_status_name( $order->get_status() );
}
if ( $tooltip ) {
printf( '<mark class="order-status %s tips" data-tip="%s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), wp_kses_post( $tooltip ), esc_html( $status_name ) );
} else {
printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), esc_html( $status_name ) );
}
}
/**
* Renders order billing information.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_billing_address_column( WC_Order $order ): void {
$address = $order->get_formatted_billing_address();
if ( $address ) {
echo esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) );
if ( $order->get_payment_method() ) {
/* translators: %s: payment method */
echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_payment_method_title() ) ) . '</span>';
}
} else {
echo '–';
}
}
/**
* Renders order shipping information.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_shipping_address_column( WC_Order $order ): void {
$address = $order->get_formatted_shipping_address();
if ( $address ) {
echo '<a target="_blank" href="' . esc_url( $order->get_shipping_address_map_url() ) . '">' . esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) ) . '</a>';
if ( $order->get_shipping_method() ) {
/* translators: %s: shipping method */
echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_shipping_method() ) ) . '</span>';
}
} else {
echo '–';
}
}
/**
* Renders the order total.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_total_column( WC_Order $order ): void {
if ( $order->get_payment_method_title() ) {
/* translators: %s: method */
echo '<span class="tips" data-tip="' . esc_attr( sprintf( __( 'via %s', 'woocommerce' ), $order->get_payment_method_title() ) ) . '">' . wp_kses_post( $order->get_formatted_order_total() ) . '</span>';
} else {
echo wp_kses_post( $order->get_formatted_order_total() );
}
}
/**
* Renders order actions.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_wc_actions_column( WC_Order $order ): void {
echo '<p>';
/**
* Fires before the order action buttons (within the actions column for the order list table)
* are registered.
*
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
do_action( 'woocommerce_admin_order_actions_start', $order );
$actions = array();
if ( $order->has_status( array( 'pending', 'on-hold' ) ) ) {
$actions['processing'] = array(
'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=processing&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
'name' => __( 'Processing', 'woocommerce' ),
'action' => 'processing',
);
}
if ( $order->has_status( array( 'pending', 'on-hold', 'processing' ) ) ) {
$actions['complete'] = array(
'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=completed&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
'name' => __( 'Complete', 'woocommerce' ),
'action' => 'complete',
);
}
/**
* Provides an opportunity to modify the action buttons within the order list table.
*
* @param array $action Order actions.
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
$actions = apply_filters( 'woocommerce_admin_order_actions', $actions, $order );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wc_render_action_buttons( $actions );
/**
* Fires after the order action buttons (within the actions column for the order list table)
* are rendered.
*
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
do_action( 'woocommerce_admin_order_actions_end', $order );
echo '</p>';
}
/**
* Outputs hidden fields used to retain state when filtering.
*
* @return void
*/
private function print_hidden_form_fields(): void {
echo '<input type="hidden" name="page" value="wc-orders" >';
$state_params = array(
'paged',
'status',
);
foreach ( $state_params as $param ) {
if ( ! isset( $_GET[ $param ] ) ) {
continue;
}
echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ) . '" >';
}
}
/**
* Gets the current action selected from the bulk actions dropdown.
*
* @return string|false The action name. False if no action was selected.
*/
public function current_action() {
if ( ! empty( $_REQUEST['delete_all'] ) ) {
return 'delete_all';
}
return parent::current_action();
}
/**
* Handle bulk actions.
*/
public function handle_bulk_actions() {
$action = $this->current_action();
if ( ! $action ) {
return;
}
check_admin_referer( 'bulk-orders' );
$redirect_to = remove_query_arg( array( 'deleted', 'ids' ), wp_get_referer() );
$redirect_to = add_query_arg( 'paged', $this->get_pagenum(), $redirect_to );
if ( 'delete_all' === $action ) {
// Get all trashed orders.
$ids = wc_get_orders(
array(
'type' => 'shop_order',
'status' => 'trash',
'limit' => -1,
'return' => 'ids',
)
);
$action = 'delete';
} else {
$ids = isset( $_REQUEST['order'] ) ? array_reverse( array_map( 'absint', $_REQUEST['order'] ) ) : array();
}
/**
* Allows 3rd parties to modify order IDs about to be affected by a bulk action.
*
* @param array Array of order IDs.
*/
$ids = apply_filters( // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
'woocommerce_bulk_action_ids',
$ids,
$action,
'order'
);
if ( ! $ids ) {
wp_safe_redirect( $redirect_to );
exit;
}
$report_action = '';
$changed = 0;
if ( 'remove_personal_data' === $action ) {
$report_action = 'removed_personal_data';
$changed = $this->do_bulk_action_remove_personal_data( $ids );
} elseif ( 'trash' === $action ) {
$changed = $this->do_delete( $ids );
$report_action = 'trashed';
} elseif ( 'delete' === $action ) {
$changed = $this->do_delete( $ids, true );
$report_action = 'deleted';
} elseif ( 'untrash' === $action ) {
$changed = $this->do_untrash( $ids );
$report_action = 'untrashed';
} elseif ( false !== strpos( $action, 'mark_' ) ) {
$order_statuses = wc_get_order_statuses();
$new_status = substr( $action, 5 );
$report_action = 'marked_' . $new_status;
if ( isset( $order_statuses[ 'wc-' . $new_status ] ) ) {
$changed = $this->do_bulk_action_mark_orders( $ids, $new_status );
}
} else {
$screen = get_current_screen()->id;
/**
* This action is documented in /wp-admin/edit.php (it is a core WordPress hook).
*
* @since 7.2.0
*
* @param string $redirect_to The URL to redirect to after processing the bulk actions.
* @param string $action The current bulk action.
* @param int[] $ids IDs for the orders to be processed.
*/
$custom_sendback = apply_filters( "handle_bulk_actions-{$screen}", $redirect_to, $action, $ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
}
if ( ! empty( $custom_sendback ) ) {
$redirect_to = $custom_sendback;
} elseif ( $changed ) {
$redirect_to = add_query_arg(
array(
'bulk_action' => $report_action,
'changed' => $changed,
'ids' => implode( ',', $ids ),
),
$redirect_to
);
}
wp_safe_redirect( $redirect_to );
exit;
}
/**
* Implements the "remove personal data" bulk action.
*
* @param array $order_ids The Order IDs.
* @return int Number of orders modified.
*/
private function do_bulk_action_remove_personal_data( $order_ids ): int {
$changed = 0;
foreach ( $order_ids as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
continue;
}
do_action( 'woocommerce_remove_order_personal_data', $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$changed++;
}
return $changed;
}
/**
* Implements the "mark <status>" bulk action.
*
* @param array $order_ids The order IDs to change.
* @param string $new_status The new order status.
* @return int Number of orders modified.
*/
private function do_bulk_action_mark_orders( $order_ids, $new_status ): int {
$changed = 0;
// Initialize payment gateways in case order has hooked status transition actions.
WC()->payment_gateways();
foreach ( $order_ids as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
continue;
}
$order->update_status( $new_status, __( 'Order status changed by bulk edit.', 'woocommerce' ), true );
do_action( 'woocommerce_order_edit_status', $id, $new_status ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$changed++;
}
return $changed;
}
/**
* Handles bulk trashing of orders.
*
* @param int[] $ids Order IDs to be trashed.
* @param bool $force_delete When set, the order will be completed deleted. Otherwise, it will be trashed.
*
* @return int Number of orders that were trashed.
*/
private function do_delete( array $ids, bool $force_delete = false ): int {
$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
$delete_args = $force_delete ? array( 'force_delete' => true ) : array();
$changed = 0;
foreach ( $ids as $id ) {
$order = wc_get_order( $id );
$orders_store->delete( $order, $delete_args );
$updated_order = wc_get_order( $id );
if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {
$changed++;
}
}
return $changed;
}
/**
* Handles bulk restoration of trashed orders.
*
* @param array $ids Order IDs to be restored to their previous status.
*
* @return int Number of orders that were restored from the trash.
*/
private function do_untrash( array $ids ): int {
$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
$changed = 0;
foreach ( $ids as $id ) {
if ( $orders_store->untrash_order( wc_get_order( $id ) ) ) {
$changed++;
}
}
return $changed;
}
/**
* Show confirmation message that order status changed for number of orders.
*/
public function bulk_action_notices() {
if ( empty( $_REQUEST['bulk_action'] ) ) {
return;
}
$order_statuses = wc_get_order_statuses();
$number = absint( $_REQUEST['changed'] ?? 0 );
$bulk_action = wc_clean( wp_unslash( $_REQUEST['bulk_action'] ) );
$message = '';
// Check if any status changes happened.
foreach ( $order_statuses as $slug => $name ) {
if ( 'marked_' . str_replace( 'wc-', '', $slug ) === $bulk_action ) { // WPCS: input var ok, CSRF ok.
/* translators: %s: orders count */
$message = sprintf( _n( '%s order status changed.', '%s order statuses changed.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
}
}
switch ( $bulk_action ) {
case 'removed_personal_data':
/* translators: %s: orders count */
$message = sprintf( _n( 'Removed personal data from %s order.', 'Removed personal data from %s orders.', $number, 'woocommerce' ), number_format_i18n( $number ) );
echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
break;
case 'trashed':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order moved to the Trash.', '%s orders moved to the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
case 'untrashed':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order restored from the Trash.', '%s orders restored from the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
case 'deleted':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order permanently deleted.', '%s orders permanently deleted.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
}
if ( ! empty( $message ) ) {
echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
}
}
/**
* Enqueue list table scripts.
*
* @return void
*/
public function enqueue_scripts(): void {
echo $this->get_order_preview_template(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_enqueue_script( 'wc-orders' );
}
/**
* Returns the HTML for the order preview template.
*
* @return string HTML template.
*/
public function get_order_preview_template(): string {
$order_edit_url_placeholder =
wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
? esc_url( admin_url( 'admin.php?page=wc-orders&action=edit' ) ) . '&id={{ data.data.id }}'
: esc_url( admin_url( 'post.php?action=edit' ) ) . '&post={{ data.data.id }}';
ob_start();
?>
<script type="text/template" id="tmpl-wc-modal-view-order">
<div class="wc-backbone-modal wc-order-preview">
<div class="wc-backbone-modal-content">
<section class="wc-backbone-modal-main" role="main">
<header class="wc-backbone-modal-header">
<mark class="order-status status-{{ data.status }}"><span>{{ data.status_name }}</span></mark>
<?php /* translators: %s: order ID */ ?>
<h1><?php echo esc_html( sprintf( __( 'Order #%s', 'woocommerce' ), '{{ data.order_number }}' ) ); ?></h1>
<button class="modal-close modal-close-link dashicons dashicons-no-alt">
<span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
</button>
</header>
<article>
<?php do_action( 'woocommerce_admin_order_preview_start' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
<div class="wc-order-preview-addresses">
<div class="wc-order-preview-address">
<h2><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>
{{{ data.formatted_billing_address }}}
<# if ( data.data.billing.email ) { #>
<strong><?php esc_html_e( 'Email', 'woocommerce' ); ?></strong>
<a href="mailto:{{ data.data.billing.email }}">{{ data.data.billing.email }}</a>
<# } #>
<# if ( data.data.billing.phone ) { #>
<strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
<a href="tel:{{ data.data.billing.phone }}">{{ data.data.billing.phone }}</a>
<# } #>
<# if ( data.payment_via ) { #>
<strong><?php esc_html_e( 'Payment via', 'woocommerce' ); ?></strong>
{{{ data.payment_via }}}
<# } #>
</div>
<# if ( data.needs_shipping ) { #>
<div class="wc-order-preview-address">
<h2><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>
<# if ( data.ship_to_billing ) { #>
{{{ data.formatted_billing_address }}}
<# } else { #>
<a href="{{ data.shipping_address_map_url }}" target="_blank">{{{ data.formatted_shipping_address }}}</a>
<# } #>
<# if ( data.shipping_via ) { #>
<strong><?php esc_html_e( 'Shipping method', 'woocommerce' ); ?></strong>
{{ data.shipping_via }}
<# } #>
</div>
<# } #>
<# if ( data.data.customer_note ) { #>
<div class="wc-order-preview-note">
<strong><?php esc_html_e( 'Note', 'woocommerce' ); ?></strong>
{{ data.data.customer_note }}
</div>
<# } #>
</div>
{{{ data.item_html }}}
<?php do_action( 'woocommerce_admin_order_preview_end' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
</article>
<footer>
<div class="inner">
{{{ data.actions_html }}}
<a class="button button-primary button-large" aria-label="<?php esc_attr_e( 'Edit this order', 'woocommerce' ); ?>" href="<?php echo $order_edit_url_placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a>
</div>
</footer>
</section>
</div>
</div>
<div class="wc-backbone-modal-backdrop modal-close"></div>
</script>
<?php
$html = ob_get_clean();
return $html;
}
}
Admin/Orders/COTRedirectionController.php 0000644 00000005424 15233366071 0014437 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* When Custom Order Tables are not the default order store (ie, posts are authoritative), we should take care of
* redirecting requests for the order editor and order admin list table to the equivalent posts-table screens.
*
* If the redirect logic is problematic, it can be unhooked using code like the following example:
*
* remove_action(
* 'admin_page_access_denied',
* array( wc_get_container()->get( COTRedirectionController::class ), 'handle_hpos_admin_requests' )
* );
*/
class COTRedirectionController {
use AccessiblePrivateMethods;
/**
* Add hooks needed to perform our magic.
*/
public function setup(): void {
// Only take action in cases where access to the admin screen would otherwise be denied.
self::add_action( 'admin_page_access_denied', array( $this, 'handle_hpos_admin_requests' ) );
}
/**
* Listen for denied admin requests and, if they appear to relate to HPOS admin screens, potentially
* redirect the user to the equivalent CPT-driven screens.
*
* @param array|null $query_params The query parameters to use when determining the redirect. If not provided, the $_GET superglobal will be used.
*/
private function handle_hpos_admin_requests( $query_params = null ) {
$query_params = is_array( $query_params ) ? $query_params : $_GET;
if ( ! isset( $query_params['page'] ) || 'wc-orders' !== $query_params['page'] ) {
return;
}
$params = wp_unslash( $query_params );
$action = $params['action'] ?? '';
unset( $params['page'] );
if ( 'edit' === $action && isset( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
$new_url = add_query_arg( $params, get_admin_url( null, 'post.php' ) );
} elseif ( 'new' === $action ) {
unset( $params['action'] );
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'post-new.php' ) );
} else {
// If nonce parameters are present and valid, rebuild them for the CPT admin list table.
if ( isset( $params['_wpnonce'] ) && check_admin_referer( 'bulk-orders' ) ) {
$params['_wp_http_referer'] = get_admin_url( null, 'edit.php?post_type=shop_order' );
$params['_wpnonce'] = wp_create_nonce( 'bulk-posts' );
}
// If an `order` array parameter is present, rename as `post`.
if ( isset( $params['order'] ) && is_array( $params['order'] ) ) {
$params['post'] = $params['order'];
unset( $params['order'] );
}
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'edit.php' ) );
}
if ( ! empty( $new_url ) && wp_safe_redirect( $new_url, 301 ) ) {
exit;
}
}
}
Admin/Orders/Edit.php 0000644 00000020150 15233366071 0010434 0 ustar 00 <?php
/**
* Renders order edit page, works with both post and order object.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
/**
* Class Edit.
*/
class Edit {
/**
* Screen ID for the edit order screen.
*
* @var string
*/
private $screen_id;
/**
* Instance of the CustomMetaBox class. Used to render meta box for custom meta.
*
* @var CustomMetaBox
*/
private $custom_meta_box;
/**
* Instance of WC_Order to be used in metaboxes.
*
* @var \WC_Order
*/
private $order;
/**
* Hooks all meta-boxes for order edit page. This is static since this may be called by post edit form rendering.
*
* @param string $screen_id Screen ID.
* @param string $title Title of the page.
*/
public static function add_order_meta_boxes( string $screen_id, string $title ) {
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-data', sprintf( __( '%s data', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Data::output', $screen_id, 'normal', 'high' );
add_meta_box( 'woocommerce-order-items', __( 'Items', 'woocommerce' ), 'WC_Meta_Box_Order_Items::output', $screen_id, 'normal', 'high' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-notes', sprintf( __( '%s notes', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Notes::output', $screen_id, 'side', 'default' );
add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
}
/**
* Hooks metabox save functions for order edit page.
*
* @return void
*/
public static function add_save_meta_boxes() {
/**
* Save Order Meta Boxes.
*
* In order:
* Save the order items.
* Save the order totals.
* Save the order downloads.
* Save order data - also updates status and sends out admin emails if needed. Last to show latest data.
* Save actions - sends out other emails. Last to show latest data.
*/
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Items::save', 10 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Downloads::save', 30, 2 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Actions::save', 50, 2 );
}
/**
* Enqueue necessary scripts for order edit page.
*/
private function enqueue_scripts() {
if ( wp_is_mobile() ) {
wp_enqueue_script( 'jquery-touch-punch' );
}
wp_enqueue_script( 'post' ); // Ensure existing JS libraries are still available for backward compat.
}
/**
* Setup hooks, actions and variables needed to render order edit page.
*
* @param \WC_Order $order Order object.
*/
public function setup( \WC_Order $order ) {
$this->order = $order;
$current_screen = get_current_screen();
$current_screen->is_block_editor( false );
$this->screen_id = $current_screen->id;
if ( ! isset( $this->custom_meta_box ) ) {
$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
}
$this->add_save_meta_boxes();
$this->handle_order_update();
$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
$this->add_order_specific_meta_box();
/**
* From wp-admin/includes/meta-boxes.php.
*
* Fires after all built-in meta boxes have been added. Custom metaboxes may be enqueued here.
*
* @since 3.8.0.
*/
do_action( 'add_meta_boxes', wc_get_page_screen_id( 'shop-order' ), $this->order );
$this->enqueue_scripts();
}
/**
* Hooks meta box for order specific meta.
*/
private function add_order_specific_meta_box() {
add_meta_box(
'order_custom',
__( 'Custom Fields', 'woocommerce' ),
array( $this, 'render_custom_meta_box' ),
$this->screen_id,
'normal'
);
}
/**
* Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
*
* @return void
*/
public function handle_order_update() {
global $theorder;
if ( ! isset( $this->order ) ) {
return;
}
if ( 'edit_order' !== sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ) ) {
return;
}
check_admin_referer( $this->get_order_edit_nonce_action() );
/**
* Save meta for shop order.
*
* @param int Order ID.
* @param \WC_Order Post object.
*
* @since 2.1.0
*/
do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );
// Refresh the order from DB.
$this->order = wc_get_order( $this->order->get_id() );
$theorder = $this->order;
}
/**
* Helper method to get the name of order edit nonce.
*
* @return string Nonce action name.
*/
private function get_order_edit_nonce_action() {
return 'update-order_' . $this->order->get_id();
}
/**
* Render meta box for order specific meta.
*/
public function render_custom_meta_box() {
$this->custom_meta_box->output( $this->order );
}
/**
* Render order edit page.
*/
public function display() {
$this->render_wrapper_start();
$this->render_meta_boxes();
$this->render_wrapper_end();
}
/**
* Helper function to render wrapper start.
*
* @param string $notice Notice to display, if any.
* @param string $message Message to display, if any.
*/
private function render_wrapper_start( $notice = '', $message = '' ) {
$edit_page_url = admin_url( 'admin.php?page=wc-orders&action=edit&id=' . $this->order->get_id() );
$form_action = 'edit_order';
$referer = wp_get_referer();
$new_page_url = wc_get_container()->get( PageController::class )->get_new_page_url();
?>
<div class="wrap">
<h1 class="wp-heading-inline">
<?php echo esc_html( 'Edit order' ); ?>
</h1>
<?php
echo ' <a href="' . esc_url( $new_page_url ) . '" class="page-title-action"> Add order </a>';
?>
<hr class="wp-header-end">
<?php
if ( $notice ) :
?>
<div id="notice" class="notice notice-warning"><p
id="has-newer-autosave"><?php echo wp_kses_post( $notice ); ?></p></div>
<?php endif; ?>
<?php if ( $message ) : ?>
<div id="message" class="updated notice notice-success is-dismissible">
<p><?php echo wp_kses_post( $message ); ?></p></div>
<?php
endif;
?>
<form name="order" action="<?php echo esc_url( $edit_page_url ); ?>" method="post" id="order"
<?php
/**
* Fires inside the order edit form tag.
*
* @param \WC_Order $order Order object.
*
* @since 6.9.0
*/
do_action( 'order_edit_form_tag', $this->order );
?>
>
<?php wp_nonce_field( $this->get_order_edit_nonce_action() ); ?>
<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>
<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $this->order->get_status() ); ?>"/>
<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
<div id="poststuff">
<div id="post-body"
class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">
<?php
}
/**
* Helper function to render meta boxes.
*/
private function render_meta_boxes() {
?>
<div id="postbox-container-1" class="postbox-container">
<?php do_meta_boxes( $this->screen_id, 'side', $this->order ); ?>
</div>
<div id="postbox-container-2" class="postbox-container">
<?php
do_meta_boxes( $this->screen_id, 'normal', $this->order );
do_meta_boxes( $this->screen_id, 'advanced', $this->order );
?>
</div>
<?php
}
/**
* Helper function to render wrapper end.
*/
private function render_wrapper_end() {
?>
</div> <!-- /post-body -->
</div> <!-- /poststuff -->
</form>
</div> <!-- /wrap -->
<?php
}
}
Admin/CouponsMovedTrait.php 0000644 00000004233 15233366071 0011742 0 ustar 00 <?php
/**
* A Trait to help with managing the legacy coupon menu.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* CouponsMovedTrait trait.
*/
trait CouponsMovedTrait {
/**
* The GET query key for the legacy menu.
*
* @var string
*/
protected static $query_key = 'legacy_coupon_menu';
/**
* The key for storing an option in the DB.
*
* @var string
*/
protected static $option_key = 'wc_admin_show_legacy_coupon_menu';
/**
* Get the URL for the legacy coupon management.
*
* @return string The unescaped URL for the legacy coupon management page.
*/
protected static function get_legacy_coupon_url() {
return self::get_coupon_url( [ self::$query_key => true ] );
}
/**
* Get the URL for the coupon management page.
*
* @param array $args Additional URL query arguments.
*
* @return string
*/
protected static function get_coupon_url( $args = [] ) {
$args = array_merge(
[
'post_type' => 'shop_coupon',
],
$args
);
return add_query_arg( $args, admin_url( 'edit.php' ) );
}
/**
* Get the new URL for managing coupons.
*
* @param string $page The management page.
*
* @return string
*/
protected static function get_management_url( $page ) {
$path = '';
switch ( $page ) {
case 'coupon':
case 'coupons':
return self::get_coupon_url();
case 'marketing':
$path = self::get_marketing_path();
break;
}
return "wc-admin&path={$path}";
}
/**
* Get the WC Admin path for the marking page.
*
* @return string
*/
protected static function get_marketing_path() {
return '/marketing/overview';
}
/**
* Whether we should display the legacy coupon menu item.
*
* @return bool
*/
protected static function should_display_legacy_menu() {
return ( get_option( self::$option_key, 1 ) && ! Features::is_enabled( 'navigation' ) );
}
/**
* Set whether we should display the legacy coupon menu item.
*
* @param bool $display Whether the menu should be displayed or not.
*/
protected static function display_legacy_menu( $display = false ) {
update_option( self::$option_key, $display ? 1 : 0 );
}
}
Admin/Marketing.php 0000644 00000017044 15233366071 0010242 0 ustar 00 <?php
/**
* WooCommerce Marketing.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Internal\Admin\Loader;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Marketing feature.
*/
class Marketing {
use CouponsMovedTrait;
/**
* Name of recommended plugins transient.
*
* @var string
*/
const RECOMMENDED_PLUGINS_TRANSIENT = 'wc_marketing_recommended_plugins';
/**
* Name of knowledge base post transient.
*
* @var string
*/
const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base';
/**
* Class instance.
*
* @var Marketing instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_action( 'admin_menu', array( $this, 'register_pages' ), 5 );
add_action( 'admin_menu', array( $this, 'add_parent_menu_item' ), 6 );
add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 30 );
}
/**
* Add main marketing menu item.
*
* Uses priority of 9 so other items can easily be added at the default priority (10).
*/
public function add_parent_menu_item() {
if ( ! Features::is_enabled( 'navigation' ) ) {
add_menu_page(
__( 'Marketing', 'woocommerce' ),
__( 'Marketing', 'woocommerce' ),
'manage_woocommerce',
'woocommerce-marketing',
null,
'dashicons-megaphone',
58
);
}
PageController::get_instance()->connect_page(
[
'id' => 'woocommerce-marketing',
'title' => 'Marketing',
'capability' => 'manage_woocommerce',
'path' => 'wc-admin&path=/marketing',
]
);
}
/**
* Registers report pages.
*/
public function register_pages() {
$this->register_overview_page();
$controller = PageController::get_instance();
$defaults = [
'parent' => 'woocommerce-marketing',
'existing_page' => false,
];
$marketing_pages = apply_filters( 'woocommerce_marketing_menu_items', [] );
foreach ( $marketing_pages as $marketing_page ) {
if ( ! is_array( $marketing_page ) ) {
continue;
}
$marketing_page = array_merge( $defaults, $marketing_page );
if ( $marketing_page['existing_page'] ) {
$controller->connect_page( $marketing_page );
} else {
$controller->register_page( $marketing_page );
}
}
}
/**
* Register the main Marketing page, which is Marketing > Overview.
*
* This is done separately because we need to ensure the page is registered properly and
* that the link is done properly. For some reason the normal page registration process
* gives us the wrong menu link.
*/
protected function register_overview_page() {
global $submenu;
// First register the page.
PageController::get_instance()->register_page(
[
'id' => 'woocommerce-marketing-overview',
'title' => __( 'Overview', 'woocommerce' ),
'path' => 'wc-admin&path=/marketing',
'parent' => 'woocommerce-marketing',
'nav_args' => array(
'parent' => 'woocommerce-marketing',
'order' => 10,
),
]
);
// Now fix the path, since register_page() gets it wrong.
if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
return;
}
foreach ( $submenu['woocommerce-marketing'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-admin' ) ) {
$item[2] = 'admin.php?page=' . $item[2];
}
}
}
/**
* Preload options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_options( $options ) {
$options[] = 'woocommerce_marketing_overview_welcome_hidden';
return $options;
}
/**
* Add settings for marketing feature.
*
* @param array $settings Component settings.
* @return array
*/
public function component_settings( $settings ) {
// Bail early if not on a wc-admin powered page.
if ( ! PageController::is_admin_page() ) {
return $settings;
}
$settings['marketing']['installedExtensions'] = InstalledExtensions::get_data();
return $settings;
}
/**
* Load recommended plugins from WooCommerce.com
*
* @return array
*/
public function get_recommended_plugins() {
$plugins = get_transient( self::RECOMMENDED_PLUGINS_TRANSIENT );
if ( false === $plugins ) {
$request = wp_remote_get(
'https://woocommerce.com/wp-json/wccom/marketing-tab/1.2/recommendations.json',
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$plugins = [];
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$plugins = json_decode( $request['body'], true );
}
set_transient(
self::RECOMMENDED_PLUGINS_TRANSIENT,
$plugins,
// Expire transient in 15 minutes if remote get failed.
// Cache an empty result to avoid repeated failed requests.
empty( $plugins ) ? 900 : 3 * DAY_IN_SECONDS
);
}
return array_values( $plugins );
}
/**
* Load knowledge base posts from WooCommerce.com
*
* @param string $category Category of posts to retrieve.
* @return array
*/
public function get_knowledge_base_posts( $category ) {
$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT;
$categories = array(
'marketing' => 1744,
'coupons' => 25202,
);
// Default to marketing category (if no category set on the kb component).
if ( ! empty( $category ) && array_key_exists( $category, $categories ) ) {
$category_id = $categories[ $category ];
$kb_transient = $kb_transient . '_' . strtolower( $category );
} else {
$category_id = $categories['marketing'];
}
$posts = get_transient( $kb_transient );
if ( false === $posts ) {
$request_url = add_query_arg(
array(
'categories' => $category_id,
'page' => 1,
'per_page' => 8,
'_embed' => 1,
),
'https://woocommerce.com/wp-json/wp/v2/posts?utm_medium=product'
);
$request = wp_remote_get(
$request_url,
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$posts = [];
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$raw_posts = json_decode( $request['body'], true );
foreach ( $raw_posts as $raw_post ) {
$post = [
'title' => html_entity_decode( $raw_post['title']['rendered'] ),
'date' => $raw_post['date_gmt'],
'link' => $raw_post['link'],
'author_name' => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '',
'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '',
];
$featured_media = $raw_post['_embedded']['wp:featuredmedia'] ?? [];
if ( count( $featured_media ) > 0 ) {
$image = current( $featured_media );
$post['image'] = add_query_arg(
array(
'resize' => '650,340',
'crop' => 1,
),
$image['source_url']
);
}
$posts[] = $post;
}
}
set_transient(
$kb_transient,
$posts,
// Expire transient in 15 minutes if remote get failed.
empty( $posts ) ? 900 : DAY_IN_SECONDS
);
}
return $posts;
}
}
Admin/Homescreen.php 0000644 00000020361 15233366071 0010405 0 ustar 00 <?php
/**
* WooCommerce Homescreen.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Shipping;
/**
* Contains backend logic for the homescreen feature.
*/
class Homescreen {
/**
* Menu slug.
*/
const MENU_SLUG = 'wc-admin';
/**
* Class instance.
*
* @var Homescreen instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_page' ) );
// In WC Core 5.1 $submenu manipulation occurs in admin_menu, not admin_head. See https://github.com/woocommerce/woocommerce/pull/29088.
if ( version_compare( WC_VERSION, '5.1', '>=' ) ) {
// priority is 20 to run after admin_menu hook for woocommerce runs, so that submenu is populated.
add_action( 'admin_menu', array( $this, 'possibly_remove_woocommerce_menu' ) );
add_action( 'admin_menu', array( $this, 'update_link_structure' ), 20 );
} else {
// priority is 20 to run after https://github.com/woocommerce/woocommerce/blob/a55ae325306fc2179149ba9b97e66f32f84fdd9c/includes/admin/class-wc-admin-menus.php#L165.
add_action( 'admin_head', array( $this, 'update_link_structure' ), 20 );
}
add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );
if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
add_filter(
'woocommerce_admin_shared_settings',
array( $this, 'maybe_set_default_shipping_options_on_home' ),
9999
);
}
}
/**
* Set free shipping in the same country as the store default
* Flag rate in all other countries when any of the following conditions are ture
*
* - The store sells physical products, has JP and WCS installed and connected, and is located in the US.
* - The store sells physical products, and is not located in US/Canada/Australia/UK (irrelevant if JP is installed or not).
* - The store sells physical products and is located in US, but JP and WCS are not installed.
*
* @param array $settings shared admin settings.
* @return array
*/
public function maybe_set_default_shipping_options_on_home( $settings ) {
if ( ! function_exists( 'get_current_screen' ) ) {
return $settings;
}
$current_screen = get_current_screen();
// Abort if it's not the homescreen.
if ( ! isset( $current_screen->id ) || 'woocommerce_page_wc-admin' !== $current_screen->id ) {
return $settings;
}
// Abort if we already created the shipping options.
$already_created = get_option( 'woocommerce_admin_created_default_shipping_zones' );
if ( $already_created === 'yes' ) {
return $settings;
}
$zone_count = count( \WC_Data_Store::load( 'shipping-zone' )->get_zones() );
if ( $zone_count ) {
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
update_option( 'woocommerce_admin_reviewed_default_shipping_zones', 'yes' );
return $settings;
}
$user_skipped_obw = $settings['onboarding']['profile']['skipped'] ?? false;
$store_address = $settings['preloadSettings']['general']['woocommerce_store_address'] ?? '';
$product_types = $settings['onboarding']['profile']['product_types'] ?? array();
$user_has_set_store_country = $settings['onboarding']['profile']['is_store_country_set'] ?? false;
// Do not proceed if user has not filled out their country in the onboarding profiler.
if ( ! $user_has_set_store_country ) {
return $settings;
}
// If user skipped the obw or has not completed the store_details
// then we assume the user is going to sell physical products.
if ( $user_skipped_obw || '' === $store_address ) {
$product_types[] = 'physical';
}
if ( false === in_array( 'physical', $product_types, true ) ) {
return $settings;
}
$country_code = wc_format_country_state_string( $settings['preloadSettings']['general']['woocommerce_default_country'] )['country'];
$country_name = WC()->countries->get_countries()[ $country_code ] ?? null;
$is_jetpack_installed = in_array( 'jetpack', $settings['plugins']['installedPlugins'] ?? array(), true );
$is_wcs_installed = in_array( 'woocommerce-services', $settings['plugins']['installedPlugins'] ?? array(), true );
if (
( 'US' === $country_code && $is_jetpack_installed )
||
( ! in_array( $country_code, array( 'US', 'CA', 'AU', 'GB' ), true ) )
||
( 'US' === $country_code && false === $is_jetpack_installed && false === $is_wcs_installed )
) {
$zone = new \WC_Shipping_Zone();
$zone->set_zone_name( $country_name );
$zone->add_location( $country_code, 'country' );
$zone->add_shipping_method( 'free_shipping' );
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
Shipping::delete_zone_count_transient();
}
return $settings;
}
/**
* Adds fields so that we can store performance indicators, row settings, and chart type settings for users.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'homepage_layout',
'homepage_stats',
'task_list_tracked_started_tasks',
'help_panel_highlight_shown',
)
);
}
/**
* Registers home page.
*/
public function register_page() {
// Register a top-level item for users who cannot view the core WooCommerce menu.
if ( ! self::is_admin_user() ) {
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'WooCommerce', 'woocommerce' ),
'path' => self::MENU_SLUG,
'capability' => 'read',
)
);
return;
}
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'Home', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => self::MENU_SLUG,
'order' => 0,
'capability' => 'read',
)
);
}
/**
* Check if the user can access the top-level WooCommerce item.
*
* @return bool
*/
public static function is_admin_user() {
if ( ! class_exists( 'WC_Admin_Menus', false ) ) {
include_once WC_ABSPATH . 'includes/admin/class-wc-admin-menus.php';
}
if ( method_exists( 'WC_Admin_Menus', 'can_view_woocommerce_menu_item' ) ) {
return \WC_Admin_Menus::can_view_woocommerce_menu_item() || current_user_can( 'manage_woocommerce' );
} else {
// We leave this line for WC versions <= 6.2.
return current_user_can( 'edit_others_shop_orders' ) || current_user_can( 'manage_woocommerce' );
}
}
/**
* Possibly remove the WooCommerce menu item if it was purely used to access wc-admin pages.
*/
public function possibly_remove_woocommerce_menu() {
global $menu;
if ( self::is_admin_user() ) {
return;
}
foreach ( $menu as $key => $menu_item ) {
if ( self::MENU_SLUG !== $menu_item[2] || 'read' !== $menu_item[1] ) {
continue;
}
unset( $menu[ $key ] );
}
}
/**
* Update the WooCommerce menu structure to make our main dashboard/handler
* the top level link for 'WooCommerce'.
*/
public function update_link_structure() {
global $submenu;
// User does not have capabilites to see the submenu.
if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
return;
}
$wc_admin_key = null;
foreach ( $submenu['woocommerce'] as $submenu_key => $submenu_item ) {
if ( self::MENU_SLUG === $submenu_item[2] ) {
$wc_admin_key = $submenu_key;
break;
}
}
if ( ! $wc_admin_key ) {
return;
}
$menu = $submenu['woocommerce'][ $wc_admin_key ];
// Move menu item to top of array.
unset( $submenu['woocommerce'][ $wc_admin_key ] );
array_unshift( $submenu['woocommerce'], $menu );
}
/**
* Preload options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_options( $options ) {
$options[] = 'woocommerce_default_homepage_layout';
$options[] = 'woocommerce_admin_install_timestamp';
return $options;
}
}
Admin/SettingsNavigationFeature.php 0000644 00000010521 15233366071 0013446 0 ustar 00 <?php
/**
* WooCommerce Settings.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Settings feature.
*/
class SettingsNavigationFeature {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_settings_enabled';
/**
* Class instance.
*
* @var Settings instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) );
if ( 'yes' !== get_option( 'woocommerce_settings_enabled', 'no' ) ) {
return;
}
add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_component_settings' ) );
// Run this after the original WooCommerce settings have been added.
add_action( 'admin_menu', array( $this, 'register_pages' ), 60 );
add_action( 'init', array( $this, 'redirect_core_settings_pages' ) );
}
/**
* Add the necessary data to initially load the WooCommerce Settings pages.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public static function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$pages = array();
foreach ( $setting_pages as $setting_page ) {
$pages = $setting_page->add_settings_page( $pages );
}
$settings['settingsPages'] = $pages;
return $settings;
}
/**
* Add the feature toggle to the features settings.
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
$features[] = array(
'title' => __( 'Settings', 'woocommerce' ),
'desc' => __(
'Adds the new WooCommerce settings UI.',
'woocommerce'
),
'id' => 'woocommerce_settings_enabled',
'type' => 'checkbox',
);
return $features;
}
/**
* Registers settings pages.
*/
public function register_pages() {
$controller = PageController::get_instance();
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$settings = array();
foreach ( $setting_pages as $setting_page ) {
$settings = $setting_page->add_settings_page( $settings );
}
$order = 0;
foreach ( $settings as $key => $setting ) {
$order += 10;
$settings_page = array(
'parent' => 'woocommerce-settings',
'title' => $setting,
'id' => 'settings-' . $key,
'path' => "/settings/$key",
'nav_args' => array(
'capability' => 'manage_woocommerce',
'order' => $order,
'parent' => 'woocommerce-settings',
),
);
// Replace the old menu with the first settings item.
if ( 10 === $order ) {
$this->replace_settings_page( $settings_page );
}
$controller->register_page( $settings_page );
}
}
/**
* Replace the Settings page in the original WooCommerce menu.
*
* @param array $page Page used to replace the original.
*/
protected function replace_settings_page( $page ) {
global $submenu;
// Check if WooCommerce parent menu has been registered.
if ( ! isset( $submenu['woocommerce'] ) ) {
return;
}
foreach ( $submenu['woocommerce'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-settings' ) ) {
$item[2] = wc_admin_url( "&path={$page['path']}" );
}
}
}
/**
* Redirect the old settings page URLs to the new ones.
*/
public function redirect_core_settings_pages() {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$default_setting = isset( $setting_pages[0] ) ? $setting_pages[0]->get_id() : '';
$setting = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : $default_setting;
/* phpcs:enable */
wp_safe_redirect( wc_admin_url( "&path=/settings/$setting" ) );
exit;
}
}
Admin/WCPayPromotion/Init.php 0000644 00000011643 15233366071 0012115 0 ustar 00 <?php
/**
* Handles wcpay promotion
*/
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DataSourcePoller;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaySuggestionsDataSourcePoller as PaymentGatewaySuggestionsDataSourcePoller;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* WC Pay Promotion engine.
*/
class Init {
const EXPLAT_VARIATION_PREFIX = 'woocommerce_wc_pay_promotion_payment_methods_table_';
/**
* Constructor.
*/
public function __construct() {
include_once __DIR__ . '/WCPaymentGatewayPreInstallWCPayPromotion.php';
$is_payments_page = isset( $_GET['page'] ) && $_GET['page'] === 'wc-settings' && isset( $_GET['tab'] ) && $_GET['tab'] === 'checkout'; // phpcs:ignore WordPress.Security.NonceVerification
if ( ! wp_is_json_request() && ! $is_payments_page ) {
return;
}
add_filter( 'woocommerce_payment_gateways', array( __CLASS__, 'possibly_register_pre_install_wc_pay_promotion_gateway' ) );
add_filter( 'option_woocommerce_gateway_order', [ __CLASS__, 'set_gateway_top_of_list' ] );
add_filter( 'default_option_woocommerce_gateway_order', [ __CLASS__, 'set_gateway_top_of_list' ] );
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'wc-admin-payment-method-promotions',
WCAdminAssets::get_url( "payment-method-promotions/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'payment-method-promotions', true );
}
/**
* Possibly registers the pre install wc pay promoted gateway.
*
* @param array $gateways list of gateway classes.
* @return array list of gateway classes.
*/
public static function possibly_register_pre_install_wc_pay_promotion_gateway( $gateways ) {
if ( self::can_show_promotion() && ! WCPaymentGatewayPreInstallWCPayPromotion::is_dismissed() ) {
$gateways[] = 'Automattic\WooCommerce\Internal\Admin\WCPayPromotion\WCPaymentGatewayPreInstallWCPayPromotion';
}
return $gateways;
}
/**
* Checks if promoted gateway can be registered.
*
* @return boolean if promoted gateway should be registered.
*/
public static function can_show_promotion() {
// Check if WC Pay is enabled.
if ( class_exists( '\WC_Payments' ) ) {
return false;
}
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return false;
}
if ( ! apply_filters( 'woocommerce_allow_marketplace_suggestions', true ) ) {
return false;
}
$wc_pay_spec = self::get_wc_pay_promotion_spec();
if ( ! $wc_pay_spec ) {
return false;
}
return true;
}
/**
* By default, new payment gateways are put at the bottom of the list on the admin "Payments" settings screen.
* For visibility, we want WooCommerce Payments to be at the top of the list.
*
* @param array $ordering Existing ordering of the payment gateways.
*
* @return array Modified ordering.
*/
public static function set_gateway_top_of_list( $ordering ) {
$ordering = (array) $ordering;
$id = WCPaymentGatewayPreInstallWCPayPromotion::GATEWAY_ID;
// Only tweak the ordering if the list hasn't been reordered with WooCommerce Payments in it already.
if ( ! isset( $ordering[ $id ] ) || ! is_numeric( $ordering[ $id ] ) ) {
$is_empty = empty( $ordering ) || ( count( $ordering ) === 1 && $ordering[0] === false );
$ordering[ $id ] = $is_empty ? 0 : ( min( $ordering ) - 1 );
}
return $ordering;
}
/**
* Get WC Pay promotion spec.
*/
public static function get_wc_pay_promotion_spec() {
$promotions = self::get_promotions();
$wc_pay_promotion_spec = array_values(
array_filter(
$promotions,
function( $promotion ) {
return isset( $promotion->plugins ) && in_array( 'woocommerce-payments', $promotion->plugins, true );
}
)
);
return current( $wc_pay_promotion_spec );
}
/**
* Go through the specs and run them.
*/
public static function get_promotions() {
$suggestions = array();
$specs = self::get_specs();
foreach ( $specs as $spec ) {
$suggestion = EvaluateSuggestion::evaluate( $spec );
$suggestions[] = $suggestion;
}
return array_values(
array_filter(
$suggestions,
function( $suggestion ) {
return ! property_exists( $suggestion, 'is_visible' ) || $suggestion->is_visible;
}
)
);
}
/**
* Delete the specs transient.
*/
public static function delete_specs_transient() {
WCPayPromotionDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs() {
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return array();
}
return WCPayPromotionDataSourcePoller::get_instance()->get_specs_from_data_sources();
}
}
Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php 0000644 00000003224 15233366071 0021210 0 ustar 00 <?php
/**
* Class WCPaymentGatewayPreInstallWCPayPromotion
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* A Psuedo WCPay gateway class.
*
* @extends WC_Payment_Gateway
*/
class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway {
const GATEWAY_ID = 'pre_install_woocommerce_payments_promotion';
/**
* Constructor
*/
public function __construct() {
$wc_pay_spec = Init::get_wc_pay_promotion_spec();
if ( ! $wc_pay_spec ) {
return;
}
$this->id = static::GATEWAY_ID;
$this->method_title = $wc_pay_spec->title;
if ( property_exists( $wc_pay_spec, 'sub_title' ) ) {
$this->title = sprintf( '<span class="gateway-subtitle" >%s</span>', $wc_pay_spec->sub_title );
}
$this->method_description = $wc_pay_spec->content;
$this->has_fields = false;
// Get setting values.
$this->enabled = false;
// Load the settings.
$this->init_form_fields();
$this->init_settings();
}
/**
* Initialise Gateway Settings Form Fields.
*/
public function init_form_fields() {
$this->form_fields = array(
'is_dismissed' => array(
'title' => __( 'Dismiss', 'woocommerce' ),
'type' => 'checkbox',
'label' => __( 'Dismiss the gateway', 'woocommerce' ),
'default' => 'no',
),
);
}
/**
* Check if the promotional gateaway has been dismissed.
*
* @return bool
*/
public static function is_dismissed() {
$settings = get_option( 'woocommerce_' . self::GATEWAY_ID . '_settings', array() );
return isset( $settings['is_dismissed'] ) && 'yes' === $settings['is_dismissed'];
}
}
Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php 0000644 00000001425 15233366071 0017232 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
use Automattic\WooCommerce\Admin\DataSourcePoller;
/**
* Specs data source poller class for WooCommerce Payment Promotion.
*/
class WCPayPromotionDataSourcePoller extends DataSourcePoller {
const ID = 'payment_method_promotion';
/**
* Default data sources array.
*/
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/payment-gateway-suggestions/1.0/payment-method/promotions.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self( self::ID, self::DATA_SOURCES );
}
return self::$instance;
}
}
Admin/Survey.php 0000644 00000001400 15233366071 0007603 0 ustar 00 <?php
/**
* Survey helper methods.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Survey Class.
*/
class Survey {
/**
* Survey URL.
*/
const SURVEY_URL = 'https://automattic.survey.fm';
/**
* Get a survey's URL from a path.
*
* @param string $path Path of the survey.
* @param array $query Query arguments as key value pairs.
* @return string Full URL to survey.
*/
public static function get_url( $path, $query = array() ) {
$url = self::SURVEY_URL . $path;
$query_args = apply_filters( 'woocommerce_admin_survey_query', $query );
if ( ! empty( $query_args ) ) {
$query_string = http_build_query( $query_args );
$url = $url . '?' . $query_string;
}
return $url;
}
}
RestockRefundedItemsAdjuster.php 0000644 00000004121 15233366071 0013054 0 ustar 00 <?php
/**
* RestockRefundedItemsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust or initialize the restock refunded items.
*/
class RestockRefundedItemsAdjuster {
/**
* The order factory to use.
*
* @var WC_Order_Factory
*/
private $order_factory;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->order_factory = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Order_Factory::class );
add_action( 'woocommerce_before_save_order_items', array( $this, 'initialize_restock_refunded_items' ), 10, 2 );
}
/**
* Initializes the restock refunded items meta for order version less than 5.5.
*
* @see https://github.com/woocommerce/woocommerce/issues/29502
*
* @param int $order_id Order ID.
* @param array $items Order items to save.
*/
public function initialize_restock_refunded_items( $order_id, $items ) {
$order = wc_get_order( $order_id );
$order_version = $order->get_version();
if ( version_compare( $order_version, '5.5', '>=' ) ) {
return;
}
// If there are no refund lines, then this migration isn't necessary because restock related meta's wouldn't be set.
if ( 0 === count( $order->get_refunds() ) ) {
return;
}
if ( isset( $items['order_item_id'] ) ) {
foreach ( $items['order_item_id'] as $item_id ) {
$item = $this->order_factory::get_order_item( absint( $item_id ) );
if ( ! $item ) {
continue;
}
if ( 'line_item' !== $item->get_type() ) {
continue;
}
// There could be code paths in custom code which don't update version number but still update the items.
if ( '' !== $item->get_meta( '_restock_refunded_items', true ) ) {
continue;
}
$refunded_item_quantity = abs( $order->get_qty_refunded_for_item( $item->get_id() ) );
$item->add_meta_data( '_restock_refunded_items', $refunded_item_quantity, false );
$item->save();
}
}
}
}
DependencyManagement/ExtendedContainer.php 0000644 00000013714 15233366071 0014747 0 ustar 00 <?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Container;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Vendor\League\Container\Container as BaseContainer;
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
/**
* This class extends the original League's Container object by adding some functionality
* that we need for WooCommerce.
*/
class ExtendedContainer extends BaseContainer {
/**
* The root namespace of all WooCommerce classes in the `src` directory.
*
* @var string
*/
private $woocommerce_namespace = 'Automattic\\WooCommerce\\';
/**
* Whitelist of classes that we can register using the container
* despite not belonging to the WooCommerce root namespace.
*
* In general we allow only the registration of classes in the
* WooCommerce root namespace to prevent registering 3rd party code
* (which doesn't really belong to this container) or old classes
* (which may be eventually deprecated, also the LegacyProxy
* should be used for those).
*
* @var string[]
*/
private $registration_whitelist = array(
Container::class,
);
/**
* Register a class in the container.
*
* @param string $class_name Class name.
* @param mixed $concrete How to resolve the class with `get`: a factory callback, a concrete instance, another class name, or null to just create an instance of the class.
* @param bool|null $shared Whether the resolution should be performed only once and cached.
*
* @return DefinitionInterface The generated definition for the container.
* @throws ContainerException Invalid parameters.
*/
public function add( string $class_name, $concrete = null, bool $shared = null ) : DefinitionInterface {
if ( ! $this->is_class_allowed( $class_name ) ) {
throw new ContainerException( "You cannot add '$class_name', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) ) {
throw new ContainerException( "You cannot add concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
// We want to use a definition class that does not support constructor injection to avoid accidental usage.
if ( ! $concrete instanceof DefinitionInterface ) {
$concrete = new Definition( $class_name, $concrete );
}
return parent::add( $class_name, $concrete, $shared );
}
/**
* Replace an existing registration with a different concrete.
*
* @param string $class_name The class name whose definition will be replaced.
* @param mixed $concrete The new concrete (same as "add").
*
* @return DefinitionInterface The modified definition.
* @throws ContainerException Invalid parameters.
*/
public function replace( string $class_name, $concrete ) : DefinitionInterface {
if ( ! $this->has( $class_name ) ) {
throw new ContainerException( "The container doesn't have '$class_name' registered, please use 'add' instead of 'replace'." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) && ! $this->is_anonymous_class( $concrete_class ) ) {
throw new ContainerException( "You cannot use concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
return $this->extend( $class_name )->setConcrete( $concrete );
}
/**
* Reset all the cached resolutions, so any further "get" for shared definitions will generate the instance again.
*/
public function reset_all_resolved() {
foreach ( $this->definitions->getIterator() as $definition ) {
// setConcrete causes the cached resolved value to be forgotten.
$concrete = $definition->getConcrete();
$definition->setConcrete( $concrete );
}
}
/**
* Get an instance of a registered class.
*
* @param string $id The class name.
* @param bool $new True to generate a new instance even if the class was registered as shared.
*
* @return object An instance of the requested class.
* @throws ContainerException Attempt to get an instance of a non-namespaced class.
*/
public function get( $id, bool $new = false ) {
if ( false === strpos( $id, '\\' ) ) {
throw new ContainerException( "Attempt to get an instance of the non-namespaced class '$id' from the container, did you forget to add a namespace import?" );
}
return parent::get( $id, $new );
}
/**
* Gets the class from the concrete regardless of type.
*
* @param mixed $concrete The concrete that we want the class from..
*
* @return string|null The class from the concrete if one is available, null otherwise.
*/
protected function get_class_from_concrete( $concrete ) {
if ( is_object( $concrete ) && ! is_callable( $concrete ) ) {
if ( $concrete instanceof DefinitionInterface ) {
return $this->get_class_from_concrete( $concrete->getConcrete() );
}
return get_class( $concrete );
}
if ( is_string( $concrete ) && class_exists( $concrete ) ) {
return $concrete;
}
return null;
}
/**
* Checks to see whether or not a class is allowed to be registered.
*
* @param string $class_name The class to check.
*
* @return bool True if the class is allowed to be registered, false otherwise.
*/
protected function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_with( $class_name, $this->woocommerce_namespace, false ) || in_array( $class_name, $this->registration_whitelist, true );
}
/**
* Check if a class name corresponds to an anonymous class.
*
* @param string $class_name The class name to check.
* @return bool True if the name corresponds to an anonymous class.
*/
protected function is_anonymous_class( string $class_name ): bool {
return StringUtil::starts_with( $class_name, 'class@anonymous' );
}
}
DependencyManagement/ServiceProviders/ProductDownloadsServiceProvider.php 0000644 00000002204 15233366071 0023161 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
/**
* Service provider for the Product Downloads-related services.
*/
class ProductDownloadsServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Register::class,
Sync::class,
SyncUI::class,
UI::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Register::class );
$this->share( Synchronize::class )->addArgument( Register::class );
$this->share( SyncUI::class )->addArgument( Register::class );
$this->share( UI::class )->addArgument( Register::class );
}
}
DependencyManagement/ServiceProviders/UtilsClassesServiceProvider.php 0000644 00000002770 15233366071 0022314 0 ustar 00 <?php
/**
* UtilsClassesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Utilities\COTMigrationUtil;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\Utilities\HtmlSanitizer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Service provider for the non-static utils classes in the Automattic\WooCommerce\src namespace.
*/
class UtilsClassesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DatabaseUtil::class,
HtmlSanitizer::class,
OrderUtil::class,
PluginUtil::class,
COTMigrationUtil::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DatabaseUtil::class );
$this->share( HtmlSanitizer::class );
$this->share( OrderUtil::class );
$this->share( PluginUtil::class )
->addArgument( LegacyProxy::class );
$this->share( COTMigrationUtil::class )
->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class ) );
}
}
DependencyManagement/ServiceProviders/ProxiesServiceProvider.php 0000644 00000001464 15233366071 0021326 0 ustar 00 <?php
/**
* ProxiesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Proxies\ActionsProxy;
/**
* Service provider for the classes in the Automattic\WooCommerce\Proxies namespace.
*/
class ProxiesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
LegacyProxy::class,
ActionsProxy::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( ActionsProxy::class );
$this->share_with_auto_arguments( LegacyProxy::class );
}
}
DependencyManagement/ServiceProviders/FeaturesServiceProvider.php 0000644 00000001501 15233366071 0021443 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
/**
* Service provider for the features enabling/disabling/compatibility engine.
*/
class FeaturesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
FeaturesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( FeaturesController::class )
->addArguments( array( LegacyProxy::class, PluginUtil::class ) );
}
}
DependencyManagement/ServiceProviders/DownloadPermissionsAdjusterServiceProvider.php 0000644 00000001364 15233366071 0025401 0 ustar 00 <?php
/**
* DownloadPermissionsAdjusterServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
/**
* Service provider for the DownloadPermissionsAdjuster class.
*/
class DownloadPermissionsAdjusterServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DownloadPermissionsAdjuster::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DownloadPermissionsAdjuster::class );
}
}
DependencyManagement/ServiceProviders/OptionSanitizerServiceProvider.php 0000644 00000001266 15233366071 0023036 0 ustar 00 <?php
/**
* OptionSanitizerServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
/**
* Service provider for the OptionSanitizer class.
*/
class OptionSanitizerServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
OptionSanitizer::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( OptionSanitizer::class );
}
}
DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php 0000644 00000005124 15233366071 0022737 0 ustar 00 <?php
/**
* OrdersDataStoreServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable\CLIRunner;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
* Service provider for the classes in the Internal\DataStores\Orders namespace.
*/
class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DataSynchronizer::class,
CustomOrdersTableController::class,
OrdersTableDataStore::class,
CLIRunner::class,
OrdersTableDataStoreMeta::class,
OrdersTableRefundDataStore::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( OrdersTableDataStoreMeta::class );
$this->share( OrdersTableDataStore::class )->addArguments( array( OrdersTableDataStoreMeta::class, DatabaseUtil::class, LegacyProxy::class ) );
$this->share( DataSynchronizer::class )->addArguments( array( OrdersTableDataStore::class, DatabaseUtil::class, PostsToOrdersMigrationController::class, LegacyProxy::class ) );
$this->share( OrdersTableRefundDataStore::class )->addArguments( array( OrdersTableDataStoreMeta::class, DatabaseUtil::class, LegacyProxy::class ) );
$this->share( CustomOrdersTableController::class )->addArguments(
array(
OrdersTableDataStore::class,
DataSynchronizer::class,
OrdersTableRefundDataStore::class,
BatchProcessingController::class,
FeaturesController::class,
)
);
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {
$this->share( CLIRunner::class )->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class, PostsToOrdersMigrationController::class ) );
}
}
}
DependencyManagement/ServiceProviders/BatchProcessingServiceProvider.php 0000644 00000001750 15233366071 0022751 0 ustar 00 <?php
/**
* Service provider for ActionUpdateController class.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
/**
* Class BatchProcessingServiceProvider
*
* @package Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders
*/
class BatchProcessingServiceProvider extends AbstractServiceProvider {
/**
* Services provided by this provider.
*
* @var string[]
*/
protected $provides = array(
BatchProcessingController::class,
);
/**
* Use the register method to register items with the container via the
* protected $this->leagueContainer property or the `getLeagueContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register() {
$this->share( BatchProcessingController::class, new BatchProcessingController() );
}
}
DependencyManagement/ServiceProviders/COTMigrationServiceProvider.php 0000644 00000002224 15233366071 0022167 0 ustar 00 <?php
/**
* Service provider for COTMigration.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable\CLIRunner;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Class COTMigrationServiceProvider
*
* @package Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders
*/
class COTMigrationServiceProvider extends AbstractServiceProvider {
/**
* Services provided by this provider.
*
* @var string[]
*/
protected $provides = array(
PostsToOrdersMigrationController::class,
CLIRunner::class,
DataSynchronizer::class,
);
/**
* Use the register method to register items with the container via the
* protected $this->leagueContainer property or the `getLeagueContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register() {
$this->share( PostsToOrdersMigrationController::class );
}
}
DependencyManagement/ServiceProviders/ProductReviewsServiceProvider.php 0000644 00000001562 15233366071 0022661 0 ustar 00 <?php
/**
* OrdersDataStoreServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Service provider for the classes in the Internal\Admin\ProductReviews namespace.
*/
class ProductReviewsServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Reviews::class,
ReviewsCommentsOverrides::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Reviews::class );
$this->share( ReviewsCommentsOverrides::class );
}
}
DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php 0000644 00000002112 15233366071 0024545 0 ustar 00 <?php
/**
* ProductAttributesLookupServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
/**
* Service provider for the ProductAttributesLookupServiceProvider namespace.
*/
class ProductAttributesLookupServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DataRegenerator::class,
Filterer::class,
LookupDataStore::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DataRegenerator::class )->addArgument( LookupDataStore::class );
$this->share( Filterer::class )->addArgument( LookupDataStore::class );
$this->share( LookupDataStore::class );
}
}
DependencyManagement/ServiceProviders/AssignDefaultCategoryServiceProvider.php 0000644 00000001320 15233366071 0024113 0 ustar 00 <?php
/**
* AssignDefaultCategoryServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
/**
* Service provider for the AssignDefaultCategory class.
*/
class AssignDefaultCategoryServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
AssignDefaultCategory::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( AssignDefaultCategory::class );
}
}
DependencyManagement/ServiceProviders/OrderAdminServiceProvider.php 0000644 00000002177 15233366071 0021723 0 ustar 00 <?php
/**
* Service provider for various order admin classes.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\Edit;
use Automattic\WooCommerce\Internal\Admin\Orders\ListTable;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* OrderAdminServiceProvider class.
*/
class OrderAdminServiceProvider extends AbstractServiceProvider {
/**
* List services provided by this class.
*
* @var string[]
*/
protected $provides = array(
COTRedirectionController::class,
PageController::class,
Edit::class,
ListTable::class,
);
/**
* Registers services provided by this class.
*
* @return void
*/
public function register() {
$this->share( COTRedirectionController::class );
$this->share( PageController::class );
$this->share( Edit::class )->addArgument( PageController::class );
$this->share( ListTable::class )->addArgument( PageController::class );
}
}
DependencyManagement/ServiceProviders/RestockRefundedItemsAdjusterServiceProvider.php 0000644 00000001372 15233366071 0025466 0 ustar 00 <?php
/**
* RestockRefundedItemsAdjusterServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
/**
* Service provider for the RestockRefundedItemsAdjuster class.
*/
class RestockRefundedItemsAdjusterServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
RestockRefundedItemsAdjuster::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( RestockRefundedItemsAdjuster::class );
}
}
DependencyManagement/ServiceProviders/OrderMetaBoxServiceProvider.php 0000644 00000001252 15233366071 0022223 0 ustar 00 <?php
/**
* Service provider for order meta boxes.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* OrderMetaBoxServiceProvider class.
*/
class OrderMetaBoxServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
CustomMetaBox::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( CustomMetaBox::class );
}
}
DependencyManagement/ServiceProviders/OrdersControllersServiceProvider.php 0000644 00000001571 15233366071 0023361 0 ustar 00 <?php
/**
* OrdersControllersServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
/**
* Service provider for the orders controller classes in the Automattic\WooCommerce\Internal\Orders namespace.
*/
class OrdersControllersServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
CouponsController::class,
TaxesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( CouponsController::class );
$this->share( TaxesController::class );
}
}
DependencyManagement/ServiceProviders/ObjectCacheServiceProvider.php 0000644 00000001154 15233366071 0022023 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Caching\WpCacheEngine;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Service provider for the object cache mechanism.
*/
class ObjectCacheServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
WpCacheEngine::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( WpCacheEngine::class );
}
}
DependencyManagement/AbstractServiceProvider.php 0000644 00000015611 15233366071 0016141 0 ustar 00 <?php
/**
* AbstractServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Vendor\League\Container\Argument\RawArgument;
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
use Automattic\WooCommerce\Vendor\League\Container\ServiceProvider\AbstractServiceProvider as BaseServiceProvider;
/**
* Base class for the service providers used to register classes in the container.
*
* See the documentation of the original class this one is based on (https://container.thephpleague.com/3.x/service-providers)
* for basic usage details. What this class adds is:
*
* - The `add_with_auto_arguments` method that allows to register classes without having to specify the injection method arguments.
* - The `share_with_auto_arguments` method, sibling of the above.
* - Convenience `add` and `share` methods that are just proxies for the same methods in `$this->getContainer()`.
*/
abstract class AbstractServiceProvider extends BaseServiceProvider {
/**
* Register a class in the container and use reflection to guess the injection method arguments.
*
* WARNING: this method uses reflection, so please have performance in mind when using it.
*
* @param string $class_name Class name to register.
* @param mixed $concrete The concrete to register. Can be a shared instance, a factory callback, or a class name.
* @param bool $shared Whether to register the class as shared (`get` always returns the same instance) or not.
*
* @return DefinitionInterface The generated container definition.
*
* @throws ContainerException Error when reflecting the class, or class injection method is not public, or an argument has no valid type hint.
*/
protected function add_with_auto_arguments( string $class_name, $concrete = null, bool $shared = false ) : DefinitionInterface {
$definition = new Definition( $class_name, $concrete );
$function = $this->reflect_class_or_callable( $class_name, $concrete );
if ( ! is_null( $function ) ) {
$arguments = $function->getParameters();
foreach ( $arguments as $argument ) {
if ( $argument->isDefaultValueAvailable() ) {
$default_value = $argument->getDefaultValue();
$definition->addArgument( new RawArgument( $default_value ) );
} else {
$argument_class = $this->get_class( $argument );
if ( is_null( $argument_class ) ) {
throw new ContainerException( "Argument '{$argument->getName()}' of class '$class_name' doesn't have a type hint or has one that doesn't specify a class." );
}
$definition->addArgument( $argument_class->name );
}
}
}
// Register the definition only after being sure that no exception will be thrown.
$this->getContainer()->add( $definition->getAlias(), $definition, $shared );
return $definition;
}
/**
* Gets the class of a parameter.
*
* This method is a replacement for ReflectionParameter::getClass,
* which is deprecated as of PHP 8.
*
* @param \ReflectionParameter $parameter The parameter to get the class for.
*
* @return \ReflectionClass|null The class of the parameter, or null if it hasn't any.
*/
private function get_class( \ReflectionParameter $parameter ) {
return $parameter->getType() && ! $parameter->getType()->isBuiltin()
? new \ReflectionClass( $parameter->getType()->getName() )
: null;
}
/**
* Check if a combination of class name and concrete is valid for registration.
* Also return the class injection method if the concrete is either a class name or null (then use the supplied class name).
*
* @param string $class_name The class name to check.
* @param mixed $concrete The concrete to check.
*
* @return \ReflectionFunctionAbstract|null A reflection instance for the $class_name injection method or $concrete injection method or callable; null otherwise.
* @throws ContainerException Class has a private injection method, can't reflect class, or the concrete is invalid.
*/
private function reflect_class_or_callable( string $class_name, $concrete ) {
if ( ! isset( $concrete ) || is_string( $concrete ) && class_exists( $concrete ) ) {
$class = $concrete ?? $class_name;
if ( ! method_exists( $class, Definition::INJECTION_METHOD ) ) {
return null;
}
$method = new \ReflectionMethod( $class, Definition::INJECTION_METHOD );
$missing_modifiers = array();
if ( ! $method->isFinal() ) {
$missing_modifiers[] = 'final';
}
if ( ! $method->isPublic() ) {
$missing_modifiers[] = 'public';
}
if ( ! empty( $missing_modifiers ) ) {
throw new ContainerException( "Method '" . Definition::INJECTION_METHOD . "' of class '$class' isn't '" . implode( ' ', $missing_modifiers ) . "', instances can't be created." );
}
return $method;
} elseif ( is_callable( $concrete ) ) {
try {
return new \ReflectionFunction( $concrete );
} catch ( \ReflectionException $ex ) {
throw new ContainerException( "Error when reflecting callable: {$ex->getMessage()}" );
}
}
return null;
}
/**
* Register a class in the container and use reflection to guess the injection method arguments.
* The class is registered as shared, so `get` on the container always returns the same instance.
*
* WARNING: this method uses reflection, so please have performance in mind when using it.
*
* @param string $class_name Class name to register.
* @param mixed $concrete The concrete to register. Can be a shared instance, a factory callback, or a class name.
*
* @return DefinitionInterface The generated container definition.
*
* @throws ContainerException Error when reflecting the class, or class injection method is not public, or an argument has no valid type hint.
*/
protected function share_with_auto_arguments( string $class_name, $concrete = null ) : DefinitionInterface {
return $this->add_with_auto_arguments( $class_name, $concrete, true );
}
/**
* Register an entry in the container.
*
* @param string $id Entry id (typically a class or interface name).
* @param mixed|null $concrete Concrete entity to register under that id, null for automatic creation.
* @param bool|null $shared Whether to register the class as shared (`get` always returns the same instance) or not.
*
* @return DefinitionInterface The generated container definition.
*/
protected function add( string $id, $concrete = null, bool $shared = null ) : DefinitionInterface {
return $this->getContainer()->add( $id, $concrete, $shared );
}
/**
* Register a shared entry in the container (`get` always returns the same instance).
*
* @param string $id Entry id (typically a class or interface name).
* @param mixed|null $concrete Concrete entity to register under that id, null for automatic creation.
*
* @return DefinitionInterface The generated container definition.
*/
protected function share( string $id, $concrete = null ) : DefinitionInterface {
return $this->add( $id, $concrete, true );
}
}
DependencyManagement/Definition.php 0000644 00000002206 15233366071 0013426 0 ustar 00 <?php
/**
* An extension to the Definition class to prevent constructor injection from being possible.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Vendor\League\Container\Definition\Definition as BaseDefinition;
/**
* An extension of the definition class that replaces constructor injection with method injection.
*/
class Definition extends BaseDefinition {
/**
* The standard method that we use for dependency injection.
*/
public const INJECTION_METHOD = 'init';
/**
* Resolve a class using method injection instead of constructor injection.
*
* @param string $concrete The concrete to instantiate.
*
* @return object
*/
protected function resolveClass( string $concrete ) {
$resolved = $this->resolveArguments( $this->arguments );
$concrete = new $concrete();
// Constructor injection causes backwards compatibility problems
// so we will rely on method injection via an internal method.
if ( method_exists( $concrete, static::INJECTION_METHOD ) ) {
call_user_func_array( array( $concrete, static::INJECTION_METHOD ), $resolved );
}
return $concrete;
}
}
DependencyManagement/ContainerException.php 0000644 00000001256 15233366071 0015143 0 ustar 00 <?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
/**
* Class ContainerException.
* Used to signal error conditions related to the dependency injection container.
*/
class ContainerException extends \Exception {
/**
* Create a new instance of the class.
*
* @param null $message The exception message to throw.
* @param int $code The error code.
* @param \Exception|null $previous The previous throwable used for exception chaining.
*/
public function __construct( $message = null, $code = 0, \Exception $previous = null ) {
parent::__construct( $message, $code, $previous );
}
}
Utilities/Users.php 0000644 00000001460 15233366071 0010340 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with users.
*/
class Users {
/**
* Indicates if the user qualifies as site administrator.
*
* In the context of multisite networks, this means that they must have the `manage_sites`
* capability. In all other cases, they must have the `manage_options` capability.
*
* @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
*
* @return bool
*/
public static function is_site_administrator( int $user_id = 0 ): bool {
$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );
if ( false === $user ) {
return false;
}
return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
}
}
Utilities/URL.php 0000644 00000032143 15233366071 0007703 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Provides an easy method of assessing URLs, including filepaths (which will be silently
* converted to a file:// URL if provided).
*/
class URL {
/**
* Components of the URL being assessed.
*
* The keys match those potentially returned by the parse_url() function, except
* that they are always defined and 'drive' (Windows drive letter) has been added.
*
* @var string|null[]
*/
private $components = array(
'drive' => null,
'fragment' => null,
'host' => null,
'pass' => null,
'path' => null,
'port' => null,
'query' => null,
'scheme' => null,
'user' => null,
);
/**
* If the URL (or filepath) is absolute.
*
* @var bool
*/
private $is_absolute;
/**
* If the URL (or filepath) represents a directory other than the root directory.
*
* This is useful at different points in the process, when deciding whether to re-apply
* a trailing slash at the end of processing or when we need to calculate how many
* directory traversals are needed to form a (grand-)parent URL.
*
* @var bool
*/
private $is_non_root_directory;
/**
* The components of the URL's path.
*
* For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
* no host component) this would contain:
*
* [ "srv", "www", "wp.site" ]
*
* In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
* host is not part of the path) it would contain:
*
* [ "foo", "bar", "baz" ]
*
* @var array
*/
private $path_parts = array();
/**
* The URL.
*
* @var string
*/
private $url;
/**
* Creates and processes the provided URL (or filepath).
*
* @throws URLException If the URL (or filepath) is seriously malformed.
*
* @param string $url The URL (or filepath).
*/
public function __construct( string $url ) {
$this->url = $url;
$this->preprocess();
$this->process_path();
}
/**
* Makes all slashes forward slashes, converts filepaths to file:// URLs, and
* other processing to help with comprehension of filepaths.
*
* @throws URLException If the URL is seriously malformed.
*/
private function preprocess() {
// For consistency, all slashes should be forward slashes.
$this->url = str_replace( '\\', '/', $this->url );
// Windows: capture the drive letter if provided.
if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
$this->components['drive'] = $matches[2];
}
/*
* If there is no scheme, assume and prepend "file://". An exception is made for cases where the URL simply
* starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
* there is freedom to switch between 'http' and 'https').
*/
if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $this->url ) ) {
$this->url = 'file://' . $this->url;
}
$parsed_components = wp_parse_url( $this->url );
// If we received a really badly formed URL, let's go no further.
if ( false === $parsed_components ) {
throw new URLException(
sprintf(
/* translators: %s is the URL. */
__( '%s is not a valid URL.', 'woocommerce' ),
$this->url
)
);
}
$this->components = array_merge( $this->components, $parsed_components );
// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
// (if present) may be incorrectly be interpreted as the host name.
if ( 'file' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
// If we do not have a drive letter, then simply merge the host and the path together.
if ( null === $this->components['drive'] ) {
$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
}
// Restore the host to null in this situation.
$this->components['host'] = null;
}
}
/**
* Simplifies the path if possible, by resolving directory traversals to the extent possible
* without touching the filesystem.
*/
private function process_path() {
$segments = explode( '/', $this->components['path'] );
$this->is_absolute = substr( $this->components['path'], 0, 1 ) === '/' || ! empty( $this->components['host'] );
$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
$resolve_traversals = 'file' !== $this->components['scheme'] || $this->is_absolute;
$retain_traversals = false;
// Clean the path.
foreach ( $segments as $part ) {
// Drop empty segments.
if ( strlen( $part ) === 0 || '.' === $part ) {
continue;
}
// Directory traversals created with percent-encoding syntax should also be detected.
$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';
// Resolve directory traversals (if allowed: see further comment relating to this).
if ( $resolve_traversals && $is_traversal ) {
if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
continue;
} elseif ( $this->is_absolute ) {
continue;
}
}
/*
* Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
* 'foo/baz').
*
* 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
* $resolve_traversals will already be true).
* 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
* never wish to do this more than once.
* 3. We only flip the switch after we have examined all leading '..' traversal segments.
*/
if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
$resolve_traversals = true;
}
/*
* Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
* discard traversals at the start of the path.
*/
$retain_traversals = $resolve_traversals && '..' === $part;
// Retain this part of the path.
$this->path_parts[] = $part;
}
// Protect against empty relative paths.
if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
$this->path_parts = array( '.' );
$this->is_non_root_directory = true;
}
// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
// the Windows drive letter if we have one.
$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts ) . ( $this->is_non_root_directory ? '/' : '' );
}
/**
* Returns the processed URL as a string.
*
* @return string
*/
public function __toString(): string {
return $this->get_url();
}
/**
* Returns all possible parent URLs for the current URL.
*
* @return string[]
*/
public function get_all_parent_urls(): array {
$max_parent = count( $this->path_parts );
$parents = array();
/*
* If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
* then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
* number of parent URLs since we can't know how far the tree extends).
*/
if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
$max_parent = 1;
}
for ( $level = 1; $level <= $max_parent; $level++ ) {
$parents[] = $this->get_parent_url( $level );
}
return $parents;
}
/**
* Outputs the parent URL.
*
* For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
* this method will return "https://example.com/foo/bar/".
*
* When a grand-parent is needed, the optional $level parameter can be used. By default
* this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
* grand-parent, etc.
*
* If a level is specified that exceeds the number of path segments, this method will
* return false.
*
* @param int $level Used to indicate the level of parent.
*
* @return string|false
*/
public function get_parent_url( int $level = 1 ) {
if ( $level < 1 ) {
$level = 1;
}
$parts_count = count( $this->path_parts );
$parent_path_parts_to_keep = $parts_count - $level;
/*
* With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
* us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
* not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
*/
if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
return false;
}
// In the specific case of an absolute filepath describing the root directory, there can be no parent.
if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
return false;
}
// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
// processed, we can be confident that any such segments are at the start of the path.
if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
$single_dots = array_keys( $this->path_parts, '.', true );
$double_dots = array_keys( $this->path_parts, '..', true );
$max_dot_index = max( array_merge( $single_dots, $double_dots ) );
// Prepend the required number of traversals and discard unnessary trailing segments.
$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
$parent_path = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
} elseif ( $parent_path_parts_to_keep < 0 ) {
// For relative filepaths only, we use traversals to describe the requested parent.
$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
} else {
// Otherwise, in a very simple case, we just remove existing parts.
$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
}
if ( $this->is_relative() && '' === $parent_path ) {
$parent_path = '.';
}
// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
$parent_path .= '/';
// For absolute paths, apply a leading slash (does not apply if we have a root path).
if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
$parent_path = '/' . $parent_path;
}
// Form the parent URL (ditching the query and fragment, if set).
$parent_url = $this->get_url(
array(
'path' => $parent_path,
'query' => null,
'fragment' => null,
)
);
// We process the parent URL through a fresh instance of this class, for consistency.
return ( new self( $parent_url ) )->get_url();
}
/**
* Outputs the processed URL.
*
* Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
*
* @param array $component_overrides If provided, these will override values set in $this->components.
*
* @return string
*/
public function get_url( array $component_overrides = array() ): string {
$components = array_merge( $this->components, $component_overrides );
$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
$host = null !== $components['host'] ? $components['host'] : '';
$port = null !== $components['port'] ? ':' . $components['port'] : '';
$path = $this->get_path( $components['path'] );
// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
if ( '' === $host && ( '' === $path || '.' === $path ) ) {
$path = './';
}
$user = null !== $components['user'] ? $components['user'] : '';
$pass = null !== $components['pass'] ? ':' . $components['pass'] : '';
$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';
$query = null !== $components['query'] ? '?' . $components['query'] : '';
$fragment = null !== $components['fragment'] ? '#' . $components['fragment'] : '';
return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
}
/**
* Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
*
* @param string $path_override If provided this will be used as the URL path. Does not impact drive letter.
*
* @return string
*/
public function get_path( string $path_override = null ): string {
return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
}
/**
* Indicates if the URL or filepath was absolute.
*
* @return bool True if absolute, else false.
*/
public function is_absolute(): bool {
return $this->is_absolute;
}
/**
* Indicates if the URL or filepath was relative.
*
* @return bool True if relative, else false.
*/
public function is_relative(): bool {
return ! $this->is_absolute;
}
}
Utilities/DatabaseUtil.php 0000644 00000020536 15233366071 0011606 0 ustar 00 <?php
/**
* DatabaseUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use DateTime;
use DateTimeZone;
/**
* A class of utilities for dealing with the database.
*/
class DatabaseUtil {
/**
* Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
*
* @param string $queries The SQL queries to execute.
* @param bool $execute Ture to actually execute the queries, false to only simulate the execution.
* @return array The result of the execution (or simulation) from dbDelta.
*/
public function dbdelta( string $queries = '', bool $execute = true ): array {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
return dbDelta( $queries, $execute );
}
/**
* Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
*
* @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
* @return array An array containing the names of the tables that currently don't exist in the database.
*/
public function get_missing_tables( string $creation_queries ): array {
$dbdelta_output = $this->dbdelta( $creation_queries, false );
$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
return $parsed_output['created_tables'];
}
/**
* Parses the output given by dbdelta and returns information about it.
*
* @param array $dbdelta_output The output from the execution of dbdelta.
* @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
*/
public function parse_dbdelta_output( array $dbdelta_output ): array {
$created_tables = array();
foreach ( $dbdelta_output as $table_name => $result ) {
if ( "Created table $table_name" === $result ) {
$created_tables[] = $table_name;
}
}
return array( 'created_tables' => $created_tables );
}
/**
* Drops a database table.
*
* @param string $table_name The name of the table to drop.
* @param bool $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
* @return bool True on success, false on error.
*/
public function drop_database_table( string $table_name, bool $add_prefix = false ) {
global $wpdb;
if ( $add_prefix ) {
$table_name = $wpdb->prefix . $table_name;
}
//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
}
/**
* Drops a table index, if both the table and the index exist.
*
* @param string $table_name The name of the table that contains the index.
* @param string $index_name The name of the index to be dropped.
* @return bool True if the index has been dropped, false if either the table or the index don't exist.
*/
public function drop_table_index( string $table_name, string $index_name ): bool {
global $wpdb;
if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
return true;
}
/**
* Create a primary key for a table, only if the table doesn't have a primary key already.
*
* @param string $table_name Table name.
* @param array $columns An array with the index column names.
* @return bool True if the key has been created, false if the table already had a primary key.
*/
public function create_primary_key( string $table_name, array $columns ) {
global $wpdb;
if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
return true;
}
/**
* Get the columns of a given table index, or of the primary key.
*
* @param string $table_name Table name.
* @param string $index_name Index name, empty string for the primary key.
* @return array The index columns. Empty array if the table or the index don't exist.
*/
public function get_index_columns( string $table_name, string $index_name = '' ): array {
global $wpdb;
if ( empty( $index_name ) ) {
$index_name = 'PRIMARY';
}
// phpcs:disable WordPress.DB.PreparedSQL
return $wpdb->get_col(
"
SELECT column_name FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name='$table_name'
AND table_schema='" . DB_NAME . "'
AND index_name='$index_name'"
);
// phpcs:enable WordPress.DB.PreparedSQL
}
/**
* Formats an object value of type `$type` for inclusion in the database.
*
* @param mixed $value Raw value.
* @param string $type Data type.
* @return mixed
* @throws \Exception When an invalid type is passed.
*/
public function format_object_value_for_db( $value, string $type ) {
switch ( $type ) {
case 'decimal':
$value = wc_format_decimal( $value, false, true );
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'string':
$value = strval( $value );
break;
case 'date':
// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
// for our own tables we persist dates in GMT.
$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
break;
case 'date_epoch':
$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
break;
default:
throw new \Exception( 'Invalid type received: ' . $type );
}
return $value;
}
/**
* Returns the `$wpdb` placeholder to use for data type `$type`.
*
* @param string $type Data type.
* @return string
* @throws \Exception When an invalid type is passed.
*/
public function get_wpdb_format_for_type( string $type ) {
static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
throw new \Exception( 'Invalid column type: ' . $type );
}
return $wpdb_placeholder_for_type[ $type ];
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
public function generate_on_duplicate_statement_clause( array $columns ): string {
$update_value_statements = array();
foreach ( $columns as $column ) {
$update_value_statements[] = "`$column` = VALUES( `$column` )";
}
$update_value_clause = implode( ', ', $update_value_statements );
return "ON DUPLICATE KEY UPDATE $update_value_clause";
}
/**
* Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
*
* You can use this function only when:
* 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
* 2. The complete unique constraint must be part of the $data array.
* 3. You do not need the LAST_INSERT_ID() value.
*
* @param string $table_name Table name.
* @param array $data Unescaped data to update (in column => value pairs).
* @param array $format An array of formats to be mapped to each of the values in $data.
*
* @return int Returns the value of DB's ON DUPLICATE KEY UPDATE clause.
*/
public function insert_on_duplicate_key_update( $table_name, $data, $format ) : int {
global $wpdb;
$columns = array_keys( $data );
$column_clause = '`' . implode( '`, `', $columns ) . '`';
$value_placeholders = implode( ', ', array_values( $format ) );
$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
$sql = $wpdb->prepare(
"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_placeholders )
$on_duplicate_clause
",
array_values( $data )
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
return $wpdb->query( $sql );
}
}
Utilities/COTMigrationUtil.php 0000644 00000012454 15233366071 0012401 0 ustar 00 <?php
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use WC_Order;
use WP_Post;
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
class COTMigrationUtil {
/**
* Custom order table controller.
*
* @var CustomOrdersTableController
*/
private $table_controller;
/**
* Data synchronizer.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* Initialize method, invoked by the DI container.
*
* @internal Automatically called by the container.
* @param CustomOrdersTableController $table_controller Custom order table controller.
* @param DataSynchronizer $data_synchronizer Data synchronizer.
*
* @return void
*/
final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
$this->table_controller = $table_controller;
$this->data_synchronizer = $data_synchronizer;
}
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @throws \Exception If called from outside of wp-admin.
*
* @return string
*/
public function get_order_admin_screen() : string {
if ( ! is_admin() ) {
throw new \Exception( 'This function should only be called in admin.' );
}
return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
? wc_get_page_screen_id( 'shop-order' )
: 'shop_order';
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
private function custom_orders_table_usage_is_enabled() : bool {
return $this->table_controller->custom_orders_table_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public function is_custom_order_tables_in_sync() : bool {
$sync_status = $this->data_synchronizer->get_sync_status();
return 0 === $sync_status['current_pending_count'] && $this->data_synchronizer->data_sync_is_enabled();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
if ( isset( $data ) ) {
if ( method_exists( $data, "get$key" ) ) {
return $data->{"get$key"}();
}
return $data->get_meta( $key, $single );
} else {
return get_post_meta( $post->ID, $key, $single );
}
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public function init_theorder_object( $post_or_order_object ) {
global $theorder;
if ( $theorder instanceof WC_Order ) {
return $theorder;
}
if ( $post_or_order_object instanceof WC_Order ) {
$theorder = $post_or_order_object;
} else {
$theorder = wc_get_order( $post_or_order_object->ID );
}
return $theorder;
}
/**
* Helper function to get ID from a post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public function get_post_or_order_id( $post_or_order_object ) : int {
if ( is_numeric( $post_or_order_object ) ) {
return (int) $post_or_order_object;
} elseif ( $post_or_order_object instanceof WC_Order ) {
return $post_or_order_object->get_id();
} elseif ( $post_or_order_object instanceof WP_Post ) {
return $post_or_order_object->ID;
}
return 0;
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public function get_order_type( $order_id ) {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return $order_data_store->get_order_type( $order_id );
}
}
Utilities/URLException.php 0000644 00000000277 15233366071 0011565 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Exception;
/**
* Used to represent a problem encountered when processing a URL.
*/
class URLException extends Exception {}
Utilities/BlocksUtil.php 0000644 00000004275 15233366071 0011321 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with blocks.
*/
class BlocksUtil {
/**
* Return blocks with their inner blocks flattened.
*
* @param array $blocks Array of blocks as returned by parse_blocks().
* @return array All blocks.
*/
public static function flatten_blocks( $blocks ) {
return array_reduce(
$blocks,
function( $carry, $block ) {
array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
if ( isset( $block['innerBlocks'] ) ) {
$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
return array_merge( $carry, $inner_blocks );
}
return $carry;
},
array()
);
}
/**
* Get all instances of the specified block from the widget area.
*
* @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_blocks_from_widget_area( $block_name ) {
return array_reduce(
get_option( 'widget_block' ),
function ( $acc, $block ) use ( $block_name ) {
$parsed_blocks = ! empty( $block ) && is_array( $block ) ? parse_blocks( $block['content'] ) : array();
if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
array_push( $acc, $parsed_blocks[0] );
return $acc;
}
return $acc;
},
array()
);
}
/**
* Get all instances of the specified block on a specific template part.
*
* @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @param string $template_part_slug The woo page to search, e.g. `header`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_block_from_template_part( $block_name, $template_part_slug ) {
$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );
$blocks = parse_blocks( $template->content );
$flatten_blocks = self::flatten_blocks( $blocks );
return array_values(
array_filter(
$flatten_blocks,
function ( $block ) use ( $block_name ) {
return ( $block_name === $block['blockName'] );
}
)
);
}
}
Utilities/HtmlSanitizer.php 0000644 00000005321 15233366071 0012034 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Utility for re-using WP Kses-based sanitization rules.
*/
class HtmlSanitizer {
/**
* Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
*/
public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
'pre_processors' => array(
'stripslashes',
'force_balance_tags',
),
'wp_kses_rules' => array(
'br' => true,
'img' => array(
'alt' => true,
'class' => true,
'src' => true,
'title' => true,
),
'p' => array(
'class' => true,
),
'span' => array(
'class' => true,
'title' => true,
),
),
);
/**
* Sanitizes the HTML according to the provided rules.
*
* @see wp_kses()
*
* @param string $html HTML string to be sanitized.
* @param array $sanitizer_rules {
* Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
* keys should be set.
*
* @type array $pre_processors Callbacks to run before invoking `wp_kses()`.
* @type array $wp_kses_rules Element names and attributes to allow, per `wp_kses()`.
* }
*
* @return string
*/
public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
}
// If no KSES rules are specified, assume all HTML should be stripped.
$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
? $sanitizer_rules['wp_kses_rules']
: array();
return wp_kses( $html, $kses_rules );
}
/**
* Applies callbacks used to process the string before and after wp_kses().
*
* If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
* output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
* increase the chances of this being caught during development).
*
* @param callable[] $callbacks The callbacks used to mutate the string.
* @param string $string The string being processed.
*
* @return string
*/
private function apply_string_callbacks( array $callbacks, string $string ): string {
foreach ( $callbacks as $callback ) {
if ( ! is_callable( $callback ) ) {
_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
return '';
}
$string = (string) $callback( $string );
}
return $string;
}
}
Settings/OptionSanitizer.php 0000644 00000003134 15233366071 0012225 0 ustar 00 <?php
/**
* FormatValidator class.
*/
namespace Automattic\WooCommerce\Internal\Settings;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
defined( 'ABSPATH' ) || exit;
/**
* This class handles sanitization of core options that need to conform to certain format.
*
* @since 6.6.0
*/
class OptionSanitizer {
use AccessiblePrivateMethods;
/**
* OptionSanitizer constructor.
*/
public function __construct() {
// Sanitize color options.
$color_options = array(
'woocommerce_email_base_color',
'woocommerce_email_background_color',
'woocommerce_email_body_background_color',
'woocommerce_email_text_color',
);
foreach ( $color_options as $option_name ) {
self::add_filter(
"woocommerce_admin_settings_sanitize_option_{$option_name}",
array( $this, 'sanitize_color_option' ),
10,
2
);
}
}
/**
* Sanitizes values for options of type 'color' before persisting to the database.
* Falls back to previous/default value for the option if given an invalid value.
*
* @since 6.6.0
* @param string $value Option value.
* @param array $option Option data.
* @return string Color in hex format.
*/
private function sanitize_color_option( $value, $option ) {
$value = sanitize_hex_color( $value );
// If invalid, try the current value.
if ( ! $value && ! empty( $option['id'] ) ) {
$value = sanitize_hex_color( get_option( $option['id'] ) );
}
// If still invalid, try the default.
if ( ! $value && ! empty( $option['default'] ) ) {
$value = sanitize_hex_color( $option['default'] );
}
return (string) $value;
}
}
BatchProcessing/BatchProcessorInterface.php 0000644 00000005765 15233366071 0015120 0 ustar 00 <?php
/**
* Interface for batch data processors. See the BatchProcessingController class for usage details.
*/
namespace Automattic\WooCommerce\Internal\BatchProcessing;
/**
* Interface BatchProcessorInterface
*
* @package Automattic\WooCommerce\DataBase
*/
interface BatchProcessorInterface {
/**
* Get a user-friendly name for this processor.
*
* @return string Name of the processor.
*/
public function get_name() : string;
/**
* Get a user-friendly description for this processor.
*
* @return string Description of what this processor does.
*/
public function get_description() : string;
/**
* Get the total number of pending items that require processing.
* Once an item is successfully processed by 'process_batch' it shouldn't be included in this count.
*
* Note that the once the processor is enqueued the batch processor controller will keep
* invoking `get_next_batch_to_process` and `process_batch` repeatedly until this method returns zero.
*
* @return int Number of items pending processing.
*/
public function get_total_pending_count() : int;
/**
* Returns the next batch of items that need to be processed.
*
* A batch item can be anything needed to identify the actual processing to be done,
* but whenever possible items should be numbers (e.g. database record ids)
* or at least strings, to ease troubleshooting and logging in case of problems.
*
* The size of the batch returned can be less than $size if there aren't that
* many items pending processing (and it can be zero if there isn't anything to process),
* but the size should always be consistent with what 'get_total_pending_count' returns
* (i.e. the size of the returned batch shouldn't be larger than the pending items count).
*
* @param int $size Maximum size of the batch to be returned.
*
* @return array Batch of items to process, containing $size or less items.
*/
public function get_next_batch_to_process( int $size ) : array;
/**
* Process data for the supplied batch.
*
* This method should be prepared to receive items that don't actually need processing
* (because they have been processed before) and ignore them, but if at least
* one of the batch items that actually need processing can't be processed, an exception should be thrown.
*
* Once an item has been processed it shouldn't be counted in 'get_total_pending_count'
* nor included in 'get_next_batch_to_process' anymore (unless something happens that causes it
* to actually require further processing).
*
* @throw \Exception Something went wrong while processing the batch.
*
* @param array $batch Batch to process, as returned by 'get_next_batch_to_process'.
*/
public function process_batch( array $batch ): void;
/**
* Default (preferred) batch size to pass to 'get_next_batch_to_process'.
* The controller will pass this size unless it's externally configured
* to use a different size.
*
* @return int Default batch size.
*/
public function get_default_batch_size() : int;
}
BatchProcessing/BatchProcessingController.php 0000644 00000036525 15233366071 0015476 0 ustar 00 <?php
/**
* This class is a helper intended to handle data processings that need to happen in batches in a deferred way.
* It abstracts away the nuances of (re)scheduling actions and dealing with errors.
*
* Usage:
*
* 1. Create a class that implements BatchProcessorInterface.
* The class must either be registered in the dependency injection container, or have a public parameterless constructor,
* or an instance must be provided via the 'woocommerce_get_batch_processor' filter.
* 2. Whenever there's data to be processed invoke the 'enqueue_processor' method in this class,
* passing the class name of the processor.
*
* That's it, processing will be performed in batches inside scheduled actions; enqueued processors will only
* be dequeued once they notify that no more items are left to process (or when `force_clear_all_processes` is invoked).
* Failed batches will be retried after a while.
*
* There are also a few public methods to get the list of currently enqueued processors
* and to check if a given processor is enqueued/actually scheduled.
*/
namespace Automattic\WooCommerce\Internal\BatchProcessing;
/**
* Class BatchProcessingController
*
* @package Automattic\WooCommerce\Internal\Updates.
*/
class BatchProcessingController {
/*
* Identifier of a "watchdog" action that will schedule a processing action
* for any processor that is enqueued but not yet scheduled
* (because it's been just enqueued or because it threw an error while processing a batch),
* that's one single action that reschedules itself continuously.
*/
const WATCHDOG_ACTION_NAME = 'wc_schedule_pending_batch_processes';
/*
* Identifier of the action that will do the actual batch processing.
* There's one action per enqueued processor that will keep rescheduling itself
* as long as there are still pending items to process
* (except if there's an error that caused no items to be processed at all).
*/
const PROCESS_SINGLE_BATCH_ACTION_NAME = 'wc_run_batch_process';
const ENQUEUED_PROCESSORS_OPTION_NAME = 'wc_pending_batch_processes';
const ACTION_GROUP = 'wc_batch_processes';
/**
* Instance of WC_Logger class.
*
* @var \WC_Logger_Interface
*/
private $logger;
/**
* BatchProcessingController constructor.
*
* Schedules the necessary actions to process batches.
*/
public function __construct() {
add_action(
self::WATCHDOG_ACTION_NAME,
function () {
$this->handle_watchdog_action();
}
);
add_action(
self::PROCESS_SINGLE_BATCH_ACTION_NAME,
function ( $batch_process ) {
$this->process_next_batch_for_single_processor( $batch_process );
},
10,
2
);
$this->logger = wc_get_logger();
}
/**
* Enqueue a processor so that it will get batch processing requests from within scheduled actions.
*
* @param string $processor_class_name Fully qualified class name of the processor, must implement `BatchProcessorInterface`.
*/
public function enqueue_processor( string $processor_class_name ): void {
$pending_updates = $this->get_enqueued_processors();
if ( ! in_array( $processor_class_name, array_keys( $pending_updates ), true ) ) {
$pending_updates[] = $processor_class_name;
$this->set_enqueued_processors( $pending_updates );
}
$this->schedule_watchdog_action( false, true );
}
/**
* Schedule the watchdog action.
*
* @param bool $with_delay Whether to delay the action execution. Should be true when rescheduling, false when enqueueing.
* @param bool $unique Whether to make the action unique.
*/
private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void {
$time = $with_delay ? time() + HOUR_IN_SECONDS : time();
as_schedule_single_action(
$time,
self::WATCHDOG_ACTION_NAME,
array(),
self::ACTION_GROUP,
$unique
);
}
/**
* Schedule a processing action for all the processors that are enqueued but not scheduled
* (because they have just been enqueued, or because the processing for a batch failed).
*/
private function handle_watchdog_action(): void {
$pending_processes = $this->get_enqueued_processors();
if ( empty( $pending_processes ) ) {
return;
}
foreach ( $pending_processes as $process_name ) {
if ( ! $this->is_scheduled( $process_name ) ) {
$this->schedule_batch_processing( $process_name );
}
}
$this->schedule_watchdog_action( true );
}
/**
* Process a batch for a single processor, and handle any required rescheduling or state cleanup.
*
* @param string $processor_class_name Fully qualified class name of the processor.
*
* @throws \Exception If error occurred during batch processing.
*/
private function process_next_batch_for_single_processor( string $processor_class_name ): void {
if ( ! $this->is_enqueued( $processor_class_name ) ) {
return;
}
$batch_processor = $this->get_processor_instance( $processor_class_name );
$pending_count_before = $batch_processor->get_total_pending_count();
$error = $this->process_next_batch_for_single_processor_core( $batch_processor );
$pending_count_after = $batch_processor->get_total_pending_count();
if ( ( $error instanceof \Exception ) && $pending_count_before === $pending_count_after ) {
// The batch processing failed and no items were processed:
// reschedule the processing with a delay, and also throw the error
// so Action Scheduler will ignore the rescheduling if this happens repeatedly.
$this->schedule_batch_processing( $processor_class_name, true );
throw $error;
}
if ( $pending_count_after > 0 ) {
$this->schedule_batch_processing( $processor_class_name );
} else {
$this->dequeue_processor( $processor_class_name );
}
}
/**
* Process a batch for a single processor, updating state and logging any error.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return null|\Exception Exception if error occurred, null otherwise.
*/
private function process_next_batch_for_single_processor_core( BatchProcessorInterface $batch_processor ): ?\Exception {
$details = $this->get_process_details( $batch_processor );
$time_start = microtime( true );
$batch = $batch_processor->get_next_batch_to_process( $details['current_batch_size'] );
if ( empty( $batch ) ) {
return null;
}
try {
$batch_processor->process_batch( $batch );
$time_taken = microtime( true ) - $time_start;
$this->update_processor_state( $batch_processor, $time_taken );
} catch ( \Exception $exception ) {
$time_taken = microtime( true ) - $time_start;
$this->log_error( $exception, $batch_processor, $batch );
$this->update_processor_state( $batch_processor, $time_taken, $exception );
return $exception;
}
return null;
}
/**
* Get the current state for a given enqueued processor.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return array Current state for the processor, or a "blank" state if none exists yet.
*/
private function get_process_details( BatchProcessorInterface $batch_processor ): array {
return get_option(
$this->get_processor_state_option_name( $batch_processor ),
array(
'total_time_spent' => 0,
'current_batch_size' => $batch_processor->get_default_batch_size(),
'last_error' => null,
)
);
}
/**
* Get the name of the option where we will be saving state for a given processor.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return string Option name.
*/
private function get_processor_state_option_name( BatchProcessorInterface $batch_processor ): string {
$class_name = get_class( $batch_processor );
$class_md5 = md5( $class_name );
// truncate the class name so we know that it will fit in the option name column along with md5 hash and prefix.
$class_name = substr( $class_name, 0, 140 );
return 'wc_batch_' . $class_name . '_' . $class_md5;
}
/**
* Update the state for a processor after a batch has completed processing.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
* @param float $time_taken Time take by the batch to complete processing.
* @param \Exception|null $last_error Exception object in processing the batch, if there was one.
*/
private function update_processor_state( BatchProcessorInterface $batch_processor, float $time_taken, \Exception $last_error = null ): void {
$current_status = $this->get_process_details( $batch_processor );
$current_status['total_time_spent'] += $time_taken;
$current_status['last_error'] = null !== $last_error ? $last_error->getMessage() : null;
update_option( $this->get_processor_state_option_name( $batch_processor ), $current_status, false );
}
/**
* Schedule a processing action for a single processor.
*
* @param string $processor_class_name Fully qualified class name of the processor.
* @param bool $with_delay Whether to schedule the action for immediate execution or for later.
*/
private function schedule_batch_processing( string $processor_class_name, bool $with_delay = false ) : void {
$time = $with_delay ? time() + MINUTE_IN_SECONDS : time();
as_schedule_single_action( $time, self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
/**
* Check if a batch processing action is already scheduled for a given processor.
* Differs from `as_has_scheduled_action` in that this excludes actions in progress.
*
* @param string $processor_class_name Fully qualified class name of the batch processor.
*
* @return bool True if a batch processing action is already scheduled for the processor.
*/
public function is_scheduled( string $processor_class_name ): bool {
return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
/**
* Get an instance of a processor given its class name.
*
* @param string $processor_class_name Full class name of the batch processor.
*
* @return BatchProcessorInterface Instance of batch processor for the given class.
* @throws \Exception If it's not possible to get an instance of the class.
*/
private function get_processor_instance( string $processor_class_name ) : BatchProcessorInterface {
$processor = wc_get_container()->get( $processor_class_name );
/**
* Filters the instance of a processor for a given class name.
*
* @param object|null $processor The processor instance given by the dependency injection container, or null if none was obtained.
* @param string $processor_class_name The full class name of the processor.
* @return BatchProcessorInterface|null The actual processor instance to use, or null if none could be retrieved.
*
* @since 6.8.0.
*/
$processor = apply_filters( 'woocommerce_get_batch_processor', $processor, $processor_class_name );
if ( ! isset( $processor ) && class_exists( $processor_class_name ) ) {
// This is a fallback for when the batch processor is not registered in the container.
$processor = new $processor_class_name();
}
if ( ! is_a( $processor, BatchProcessorInterface::class ) ) {
throw new \Exception( "Unable to initialize batch processor instance for $processor_class_name" );
}
return $processor;
}
/**
* Helper method to get list of all the enqueued processors.
*
* @return array List (of string) of the class names of the enqueued processors.
*/
public function get_enqueued_processors() : array {
return get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() );
}
/**
* Dequeue a processor once it has no more items pending processing.
*
* @param string $processor_class_name Full processor class name.
*/
private function dequeue_processor( string $processor_class_name ): void {
$pending_processes = $this->get_enqueued_processors();
if ( in_array( $processor_class_name, $pending_processes, true ) ) {
$pending_processes = array_diff( $pending_processes, array( $processor_class_name ) );
$this->set_enqueued_processors( $pending_processes );
}
}
/**
* Helper method to set the enqueued processor class names.
*
* @param array $processors List of full processor class names.
*/
private function set_enqueued_processors( array $processors ): void {
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false );
}
/**
* Check if a particular processor is enqueued.
*
* @param string $processor_class_name Fully qualified class name of the processor.
*
* @return bool True if the processor is enqueued.
*/
public function is_enqueued( string $processor_class_name ) : bool {
return in_array( $processor_class_name, $this->get_enqueued_processors(), true );
}
/**
* Dequeue and de-schedule a processor instance so that it won't be processed anymore.
*
* @param string $processor_class_name Fully qualified class name of the processor.
* @return bool True if the processor has been dequeued, false if the processor wasn't enqueued (so nothing has been done).
*/
public function remove_processor( string $processor_class_name ): bool {
$enqueued_processors = $this->get_enqueued_processors();
if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) {
return false;
}
$enqueued_processors = array_diff( $enqueued_processors, array( $processor_class_name ) );
if ( empty( $enqueued_processors ) ) {
$this->force_clear_all_processes();
} else {
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $enqueued_processors, false );
as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
return true;
}
/**
* Dequeues and de-schedules all the processors.
*/
public function force_clear_all_processes(): void {
as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME );
as_unschedule_all_actions( self::WATCHDOG_ACTION_NAME );
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array(), false );
}
/**
* Log an error that happened while processing a batch.
*
* @param \Exception $error Exception object to log.
* @param BatchProcessorInterface $batch_processor Batch processor instance.
* @param array $batch Batch that was being processed.
*/
protected function log_error( \Exception $error, BatchProcessorInterface $batch_processor, array $batch ) : void {
$batch_detail_string = '';
// Log only first and last, as the entire batch may be too big.
if ( count( $batch ) > 0 ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- Logging is for debugging.
$batch_detail_string = '\n' . print_r(
array(
'batch_start' => $batch[0],
'batch_end' => end( $batch ),
),
true
);
}
$error_message = "Error processing batch for {$batch_processor->get_name()}: {$error->getMessage()}" . $batch_detail_string;
/**
* Filters the error message for a batch processing.
*
* @param string $error_message The error message that will be logged.
* @param \Exception $error The exception that was thrown by the processor.
* @param BatchProcessorInterface $batch_processor The processor that threw the exception.
* @param array $batch The batch that was being processed.
* @return string The actual error message that will be logged.
*
* @since 6.8.0
*/
$error_message = apply_filters( 'wc_batch_processing_log_message', $error_message, $error, $batch_processor, $batch );
$this->logger->error( $error_message, array( 'exception' => $error ) );
}
}
AssignDefaultCategory.php 0000644 00000003631 15233366071 0011515 0 ustar 00 <?php
/**
* AssignDefaultCategory class file.
*/
namespace Automattic\WooCommerce\Internal;
defined( 'ABSPATH' ) || exit;
/**
* Class to assign default category to products.
*/
class AssignDefaultCategory {
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
add_action( 'wc_schedule_update_product_default_cat', array( $this, 'maybe_assign_default_product_cat' ) );
}
/**
* When a product category is deleted, we need to check
* if the product has no categories assigned. Then assign
* it a default category. We delay this with a scheduled
* action job to not block the response.
*
* @return void
*/
public function schedule_action() {
WC()->queue()->schedule_single(
time(),
'wc_schedule_update_product_default_cat',
array(),
'wc_update_product_default_cat'
);
}
/**
* Assigns default product category for products
* that have no categories.
*
* @return void
*/
public function maybe_assign_default_product_cat() {
global $wpdb;
$default_category = get_option( 'default_product_cat', 0 );
if ( $default_category ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id)
SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts
LEFT JOIN
(
SELECT object_id FROM {$wpdb->term_relationships} term_relationships
LEFT JOIN {$wpdb->term_taxonomy} term_taxonomy ON term_relationships.term_taxonomy_id = term_taxonomy.term_taxonomy_id
WHERE term_taxonomy.taxonomy = 'product_cat'
) AS tax_query
ON posts.ID = tax_query.object_id
WHERE posts.post_type = 'product'
AND tax_query.object_id IS NULL",
$default_category
)
);
wp_cache_flush();
delete_transient( 'wc_term_counts' );
wp_update_term_count_now( array( $default_category ), 'product_cat' );
}
}
}
Orders/IppFunctions.php 0000644 00000004243 15233366071 0011145 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use WC_Order;
/**
* Class with methods for handling order In-Person Payments.
*/
class IppFunctions {
/**
* Returns if order is eligible to accept In-Person Payments.
*
* @param WC_Order $order order that the conditions are checked for.
*
* @return bool true if order is eligible, false otherwise
*/
public static function is_order_in_person_payment_eligible( WC_Order $order ): bool {
$has_status = in_array( $order->get_status(), array( 'pending', 'on-hold', 'processing' ), true );
$has_payment_method = in_array( $order->get_payment_method(), array( 'cod', 'woocommerce_payments', 'none' ), true );
$order_is_not_paid = null === $order->get_date_paid();
$order_is_not_refunded = empty( $order->get_refunds() );
$order_has_no_subscription_products = true;
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
if ( is_object( $product ) && $product->is_type( 'subscription' ) ) {
$order_has_no_subscription_products = false;
break;
}
}
return $has_status && $has_payment_method && $order_is_not_paid && $order_is_not_refunded && $order_has_no_subscription_products;
}
/**
* Returns if store is eligible to accept In-Person Payments.
*
* @return bool true if store is eligible, false otherwise
*/
public static function is_store_in_person_payment_eligible(): bool {
$is_store_usa_based = self::has_store_specified_country_currency( 'US', 'USD' );
$is_store_canada_based = self::has_store_specified_country_currency( 'CA', 'CAD' );
return $is_store_usa_based || $is_store_canada_based;
}
/**
* Checks if the store has specified country location and currency used.
*
* @param string $country country to compare store's country with.
* @param string $currency currency to compare store's currency with.
*
* @return bool true if specified country and currency match the store's ones. false otherwise
*/
public static function has_store_specified_country_currency( string $country, string $currency ): bool {
return ( WC()->countries->get_base_country() === $country && get_woocommerce_currency() === $currency );
}
}
Orders/TaxesController.php 0000644 00000003462 15233366071 0011656 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
/**
* Class with methods for handling order taxes.
*/
class TaxesController {
/**
* Calculate line taxes via Ajax call.
*/
public function calc_line_taxes_via_ajax(): void {
check_ajax_referer( 'calc-totals', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
wp_die( -1 );
}
$order = $this->calc_line_taxes( $_POST );
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
wp_die();
}
/**
* Calculate line taxes programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
*/
public function calc_line_taxes( array $post_variables ): object {
$order_id = absint( $post_variables['order_id'] );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
// Parse the jQuery serialized items.
$items = array();
parse_str( wp_unslash( $post_variables['items'] ), $items );
// Save order items first.
wc_save_order_items( $order_id, $items );
// Grab the order and recalculate taxes.
$order = wc_get_order( $order_id );
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
return $order;
}
}
Orders/CouponsController.php 0000644 00000005751 15233366071 0012223 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use Exception;
/**
* Class with methods for handling order coupons.
*/
class CouponsController {
/**
* Add order discount via Ajax.
*
* @throws Exception If order or coupon is invalid.
*/
public function add_coupon_discount_via_ajax(): void {
check_ajax_referer( 'order-item', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( -1 );
}
$response = array();
try {
$order = $this->add_coupon_discount( $_POST );
ob_start();
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
$response['html'] = ob_get_clean();
} catch ( Exception $e ) {
wp_send_json_error( array( 'error' => $e->getMessage() ) );
}
// wp_send_json_success must be outside the try block not to break phpunit tests.
wp_send_json_success( $response );
}
/**
* Add order discount programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
* @throws \Exception Invalid order or coupon.
*/
public function add_coupon_discount( array $post_variables ): object {
$order_id = isset( $post_variables['order_id'] ) ? absint( $post_variables['order_id'] ) : 0;
$order = wc_get_order( $order_id );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
if ( ! $order ) {
throw new Exception( __( 'Invalid order', 'woocommerce' ) );
}
if ( empty( $post_variables['coupon'] ) ) {
throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
}
// Add user ID and/or email so validation for coupon limits works.
$user_id_arg = isset( $post_variables['user_id'] ) ? absint( $post_variables['user_id'] ) : 0;
$user_email_arg = isset( $post_variables['user_email'] ) ? sanitize_email( wp_unslash( $post_variables['user_email'] ) ) : '';
if ( $user_id_arg ) {
$order->set_customer_id( $user_id_arg );
}
if ( $user_email_arg ) {
$order->set_billing_email( $user_email_arg );
}
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
$result = $order->apply_coupon( wc_format_coupon_code( wp_unslash( $post_variables['coupon'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( is_wp_error( $result ) ) {
throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
}
return $order;
}
}
Orders/MobileMessagingHandler.php 0000644 00000013002 15233366071 0013060 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use DateTime;
use Exception;
use WC_Order;
use WC_Tracker;
/**
* Prepares formatted mobile deep link navigation link for order mails.
*/
class MobileMessagingHandler {
private const OPEN_ORDER_INTERVAL_DAYS = 30;
/**
* Prepares mobile messaging with a deep link.
*
* @param WC_Order $order order that mobile message is created for.
* @param ?int $blog_id of blog to make a deep link for (will be null if Jetpack is not enabled).
* @param DateTime $now current DateTime.
* @param string $domain URL of the current site.
*
* @return ?string
*/
public static function prepare_mobile_message(
WC_Order $order,
?int $blog_id,
DateTime $now,
string $domain
): ?string {
try {
$last_mobile_used = self::get_closer_mobile_usage_date();
$used_app_in_last_month = null !== $last_mobile_used && $last_mobile_used->diff( $now )->days <= self::OPEN_ORDER_INTERVAL_DAYS;
$has_jetpack = null !== $blog_id;
if ( IppFunctions::is_store_in_person_payment_eligible() && IppFunctions::is_order_in_person_payment_eligible( $order ) ) {
return self::accept_payment_message( $blog_id, $domain );
} else {
if ( $used_app_in_last_month && $has_jetpack ) {
return self::manage_order_message( $blog_id, $order->get_id(), $domain );
} else {
return self::no_app_message( $blog_id, $domain );
}
}
} catch ( Exception $e ) {
return null;
}
}
/**
* Returns the closest date of last usage of any mobile app platform.
*
* @return ?DateTime
*/
private static function get_closer_mobile_usage_date(): ?DateTime {
$mobile_usage = WC_Tracker::get_woocommerce_mobile_usage();
if ( ! $mobile_usage ) {
return null;
}
$last_ios_used = self::get_last_used_or_null( 'ios', $mobile_usage );
$last_android_used = self::get_last_used_or_null( 'android', $mobile_usage );
return max( $last_android_used, $last_ios_used );
}
/**
* Returns last used date of specified mobile app platform.
*
* @param string $platform mobile platform to check.
* @param array $mobile_usage mobile apps usage data.
*
* @return ?DateTime last used date of specified mobile app
*/
private static function get_last_used_or_null(
string $platform, array $mobile_usage
): ?DateTime {
try {
if ( array_key_exists( $platform, $mobile_usage ) ) {
return new DateTime( $mobile_usage[ $platform ]['last_used'] );
} else {
return null;
}
} catch ( Exception $e ) {
return null;
}
}
/**
* Prepares message with a deep link to mobile payment.
*
* @param ?int $blog_id blog id to deep link to.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function accept_payment_message( ?int $blog_id, $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
),
self::prepare_utm_parameters( 'deeplinks_payments', $blog_id, $domain )
),
'https://woocommerce.com/mobile/payments'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'%1$sCollect payments easily%2$s from your customers anywhere with our mobile app.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares message with a deep link to manage order details.
*
* @param int $blog_id blog id to deep link to.
* @param int $order_id order id to deep link to.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function manage_order_message( int $blog_id, int $order_id, string $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
'order_id' => absint( $order_id ),
),
self::prepare_utm_parameters( 'deeplinks_orders_details', $blog_id, $domain )
),
'https://woocommerce.com/mobile/orders/details'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'%1$sManage the order%2$s with the app.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares message with a deep link to learn more about mobile app.
*
* @param ?int $blog_id blog id used for tracking.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function no_app_message( ?int $blog_id, string $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
),
self::prepare_utm_parameters( 'deeplinks_promote_app', $blog_id, $domain )
),
'https://woocommerce.com/mobile'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'Process your orders on the go. %1$sGet the app%2$s.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares array of parameters used by WooCommerce.com for tracking.
*
* @param string $campaign name of the deep link campaign.
* @param int|null $blog_id blog id of the current site.
* @param string $domain URL of the current site.
*
* @return array
*/
private static function prepare_utm_parameters(
string $campaign,
?int $blog_id,
string $domain
): array {
return array(
'utm_campaign' => $campaign,
'utm_medium' => 'email',
'utm_source' => $domain,
'utm_term' => absint( $blog_id ),
);
}
}
ProductAttributesLookup/LookupDataStore.php 0000644 00000057670 15233366071 0015243 0 ustar 00 <?php
/**
* LookupDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
/**
* Data store class for the product attributes lookup table.
*/
class LookupDataStore {
use AccessiblePrivateMethods;
/**
* Types of updates to perform depending on the current changest
*/
public const ACTION_NONE = 0;
public const ACTION_INSERT = 1;
public const ACTION_UPDATE_STOCK = 2;
public const ACTION_DELETE = 3;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* LookupDataStore constructor. Makes the feature hidden by default.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
self::add_action( 'woocommerce_run_product_attribute_lookup_update_callback', array( $this, 'run_update_callback' ), 10, 2 );
self::add_filter( 'woocommerce_get_sections_products', array( $this, 'add_advanced_section_to_product_settings' ), 100, 1 );
self::add_action( 'woocommerce_rest_insert_product', array( $this, 'on_product_created_or_updated_via_rest_api' ), 100, 2 );
self::add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_attributes_lookup_table_settings' ), 100, 2 );
}
/**
* Check if the lookup table exists in the database.
*
* @return bool
*/
public function check_lookup_table_exists() {
global $wpdb;
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->lookup_table_name ) );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $this->lookup_table_name === $wpdb->get_var( $query );
}
/**
* Get the name of the lookup table.
*
* @return string
*/
public function get_lookup_table_name() {
return $this->lookup_table_name;
}
/**
* Insert/update the appropriate lookup table entries for a new or modified product or variation.
* This must be invoked after a product or a variation is created (including untrashing and duplication)
* or modified.
*
* @param int|\WC_Product $product Product object or product id.
* @param null|array $changeset Changes as provided by 'get_changes' method in the product object, null if it's being created.
*/
public function on_product_changed( $product, $changeset = null ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
$action = $this->get_update_action( $changeset );
if ( $action !== self::ACTION_NONE ) {
$this->maybe_schedule_update( $product->get_id(), $action );
}
}
/**
* Schedule an update of the product attributes lookup table for a given product.
* If an update for the same action is already scheduled, nothing is done.
*
* If the 'woocommerce_attribute_lookup_direct_update' option is set to 'yes',
* the update is done directly, without scheduling.
*
* @param int $product_id The product id to schedule the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function maybe_schedule_update( int $product_id, int $action ) {
if ( get_option( 'woocommerce_attribute_lookup_direct_updates' ) === 'yes' ) {
$this->run_update_callback( $product_id, $action );
return;
}
$args = array( $product_id, $action );
$queue = WC()->get_instance_of( \WC_Queue::class );
$already_scheduled = $queue->search(
array(
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'args' => $args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled ) ) {
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback',
$args,
'woocommerce-db-updates'
);
}
}
/**
* Perform an update of the lookup table for a specific product.
*
* @param int $product_id The product id to perform the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function run_update_callback( int $product_id, int $action ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
$product = WC()->call_function( 'wc_get_product', $product_id );
if ( ! $product ) {
$action = self::ACTION_DELETE;
}
switch ( $action ) {
case self::ACTION_INSERT:
$this->delete_data_for( $product_id );
$this->create_data_for( $product );
break;
case self::ACTION_UPDATE_STOCK:
$this->update_stock_status_for( $product );
break;
case self::ACTION_DELETE:
$this->delete_data_for( $product_id );
break;
}
}
/**
* Determine the type of action to perform depending on the received changeset.
*
* @param array|null $changeset The changeset received by on_product_changed.
* @return int One of the ACTION_ constants.
*/
private function get_update_action( $changeset ) {
if ( is_null( $changeset ) ) {
// No changeset at all means that the product is new.
return self::ACTION_INSERT;
}
$keys = array_keys( $changeset );
// Order matters:
// - The change with the most precedence is a change in catalog visibility
// (which will result in all data being regenerated or deleted).
// - Then a change in attributes (all data will be regenerated).
// - And finally a change in stock status (existing data will be updated).
// Thus these conditions must be checked in that same order.
if ( in_array( 'catalog_visibility', $keys, true ) ) {
$new_visibility = $changeset['catalog_visibility'];
if ( $new_visibility === 'visible' || $new_visibility === 'catalog' ) {
return self::ACTION_INSERT;
} else {
return self::ACTION_DELETE;
}
}
if ( in_array( 'attributes', $keys, true ) ) {
return self::ACTION_INSERT;
}
if ( array_intersect( $keys, array( 'stock_quantity', 'stock_status', 'manage_stock' ) ) ) {
return self::ACTION_UPDATE_STOCK;
}
return self::ACTION_NONE;
}
/**
* Update the stock status of the lookup table entries for a given product.
*
* @param \WC_Product $product The product to update the entries for.
*/
private function update_stock_status_for( \WC_Product $product ) {
global $wpdb;
$in_stock = $product->is_in_stock();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . $this->lookup_table_name . ' SET in_stock = %d WHERE product_id = %d',
$in_stock ? 1 : 0,
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Delete the lookup table contents related to a given product or variation,
* if it's a variable product it deletes the information for variations too.
* This must be invoked after a product or a variation is trashed or deleted.
*
* @param int|\WC_Product $product Product object or product id.
*/
public function on_product_deleted( $product ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( is_a( $product, \WC_Product::class ) ) {
$product_id = $product->get_id();
} else {
$product_id = $product;
}
$this->maybe_schedule_update( $product_id, self::ACTION_DELETE );
}
/**
* Create the lookup data for a given product, if a variable product is passed
* the information is created for all of its variations.
* This method is intended to be called from the data regenerator.
*
* @param int|WC_Product $product Product object or id.
* @throws \Exception A variation object is passed.
*/
public function create_data_for_product( $product ) {
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
if ( $this->is_variation( $product ) ) {
throw new \Exception( "LookupDataStore::create_data_for_product can't be called for variations." );
}
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
/**
* Create lookup table data for a given product.
*
* @param \WC_Product $product The product to create the data for.
*/
private function create_data_for( \WC_Product $product ) {
if ( $this->is_variation( $product ) ) {
$this->create_data_for_variation( $product );
} elseif ( $this->is_variable_product( $product ) ) {
$this->create_data_for_variable_product( $product );
} else {
$this->create_data_for_simple_product( $product );
}
}
/**
* Delete all the lookup table entries for a given product,
* if it's a variable product information for variations is deleted too.
*
* @param int $product_id Simple product id, or main/parent product id for variable products.
*/
private function delete_data_for( int $product_id ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_id = %d OR product_or_parent_id = %d',
$product_id,
$product_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Create lookup table entries for a simple (non variable) product.
* Assumes that no entries exist yet.
*
* @param \WC_Product $product The product to create the entries for.
*/
private function create_data_for_simple_product( \WC_Product $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$has_stock = $product->is_in_stock();
$product_id = $product->get_id();
foreach ( $product_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $product_id, $product_id, $taxonomy, $term_id, false, $has_stock );
}
}
}
/**
* Create lookup table entries for a variable product.
* Assumes that no entries exist yet.
*
* @param \WC_Product_Variable $product The product to create the entries for.
*/
private function create_data_for_variable_product( \WC_Product_Variable $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$non_variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return ! $item['used_for_variations'];
}
);
$main_product_has_stock = $product->is_in_stock();
$main_product_id = $product->get_id();
foreach ( $non_variation_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $main_product_id, $main_product_id, $taxonomy, $term_id, false, $main_product_has_stock );
}
}
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
$variations = $this->get_variations_of( $product );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
foreach ( $variations as $variation ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
}
}
}
/**
* Create all the necessary lookup data for a given variation.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
*/
private function create_data_for_variation( \WC_Product_Variation $variation ) {
$main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() );
$product_attributes_data = $this->get_attribute_taxonomies( $main_product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product->get_id(), $data['term_ids'], $term_ids_by_slug_cache );
}
}
/**
* Create lookup table entries for a given variation, corresponding to a given taxonomy and a set of term ids.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
* @param string $taxonomy The taxonomy to create the entries for.
* @param int $main_product_id The parent product id.
* @param array $term_ids The term ids to create entries for.
* @param array $term_ids_by_slug_cache A dictionary of term ids by term slug, as returned by 'get_term_ids_by_slug_cache'.
*/
private function insert_lookup_table_data_for_variation( \WC_Product_Variation $variation, string $taxonomy, int $main_product_id, array $term_ids, array $term_ids_by_slug_cache ) {
$variation_id = $variation->get_id();
$variation_has_stock = $variation->is_in_stock();
$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache );
if ( $variation_definition_term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
} else {
$term_ids_for_taxonomy = $term_ids;
foreach ( $term_ids_for_taxonomy as $term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock );
}
}
}
/**
* Get a cache of term ids by slug for a set of taxonomies, with this format:
*
* [
* 'taxonomy' => [
* 'slug_1' => id_1,
* 'slug_2' => id_2,
* ...
* ], ...
* ]
*
* @param array $taxonomies List of taxonomies to build the cache for.
* @return array A dictionary of taxonomies => dictionary of term slug => term id.
*/
private function get_term_ids_by_slug_cache( $taxonomies ) {
$result = array();
foreach ( $taxonomies as $taxonomy ) {
$terms = WC()->call_function(
'get_terms',
array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'fields' => 'id=>slug',
)
);
$result[ $taxonomy ] = array_flip( $terms );
}
return $result;
}
/**
* Get the id of the term that defines a variation for a given taxonomy,
* or null if there's no such defining id (for variations having "Any <taxonomy>" as the definition)
*
* @param \WC_Product_Variation $variation The variation to get the defining term id for.
* @param string $taxonomy The taxonomy to get the defining term id for.
* @param array $term_ids_by_slug_cache A term ids by slug as generated by get_term_ids_by_slug_cache.
* @return int|null The term id, or null if there's no defining id for that taxonomy in that variation.
*/
private function get_variation_definition_term_id( \WC_Product_Variation $variation, string $taxonomy, array $term_ids_by_slug_cache ) {
$variation_attributes = $variation->get_attributes();
$term_slug = ArrayUtil::get_value_or_default( $variation_attributes, $taxonomy );
if ( $term_slug ) {
return $term_ids_by_slug_cache[ $taxonomy ][ $term_slug ];
} else {
return null;
}
}
/**
* Get the variations of a given variable product.
*
* @param \WC_Product_Variable $product The product to get the variations for.
* @return array An array of WC_Product_Variation objects.
*/
private function get_variations_of( \WC_Product_Variable $product ) {
$variation_ids = $product->get_children();
return array_map(
function( $id ) {
return WC()->call_function( 'wc_get_product', $id );
},
$variation_ids
);
}
/**
* Check if a given product is a variable product.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variable product, false otherwise.
*/
private function is_variable_product( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variable::class );
}
/**
* Check if a given product is a variation.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variation, false otherwise.
*/
private function is_variation( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variation::class );
}
/**
* Return the list of taxonomies used for variations on a product together with
* the associated term ids, with the following format:
*
* [
* 'taxonomy_name' =>
* [
* 'term_ids' => [id, id, ...],
* 'used_for_variations' => true|false
* ], ...
* ]
*
* @param \WC_Product $product The product to get the attribute taxonomies for.
* @return array Information about the attribute taxonomies of the product.
*/
private function get_attribute_taxonomies( \WC_Product $product ) {
$product_attributes = $product->get_attributes();
$result = array();
foreach ( $product_attributes as $taxonomy_name => $attribute_data ) {
if ( ! $attribute_data->get_id() ) {
// Custom product attribute, not suitable for attribute-based filtering.
continue;
}
$result[ $taxonomy_name ] = array(
'term_ids' => $attribute_data->get_options(),
'used_for_variations' => $attribute_data->get_variation(),
);
}
return $result;
}
/**
* Insert one entry in the lookup table.
*
* @param int $product_id The product id.
* @param int $product_or_parent_id The product id for non-variable products, the main/parent product id for variations.
* @param string $taxonomy Taxonomy name.
* @param int $term_id Term id.
* @param bool $is_variation_attribute True if the taxonomy corresponds to an attribute used to define variations.
* @param bool $has_stock True if the product is in stock.
*/
private function insert_lookup_table_data( int $product_id, int $product_or_parent_id, string $taxonomy, int $term_id, bool $is_variation_attribute, bool $has_stock ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'INSERT INTO ' . $this->lookup_table_name . ' (
product_id,
product_or_parent_id,
taxonomy,
term_id,
is_variation_attribute,
in_stock)
VALUES
( %d, %d, %s, %d, %d, %d )',
$product_id,
$product_or_parent_id,
$taxonomy,
$term_id,
$is_variation_attribute ? 1 : 0,
$has_stock ? 1 : 0
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Handler for the woocommerce_rest_insert_product hook.
* Needed to update the lookup table when the REST API batch insert/update endpoints are used.
*
* @param \WP_Post $product The post representing the created or updated product.
* @param \WP_REST_Request $request The REST request that caused the hook to be fired.
* @return void
*/
private function on_product_created_or_updated_via_rest_api( \WP_Post $product, \WP_REST_Request $request ): void {
if ( StringUtil::ends_with( $request->get_route(), '/batch' ) ) {
$this->on_product_changed( $product->ID );
}
}
/**
* Tells if a lookup table regeneration is currently in progress.
*
* @return bool True if a lookup table regeneration is already in progress.
*/
public function regeneration_is_in_progress() {
return get_option( 'woocommerce_attribute_lookup_regeneration_in_progress', null ) === 'yes';
}
/**
* Set a permanent flag (via option) indicating that the lookup table regeneration is in process.
*/
public function set_regeneration_in_progress_flag() {
update_option( 'woocommerce_attribute_lookup_regeneration_in_progress', 'yes' );
}
/**
* Remove the flag indicating that the lookup table regeneration is in process.
*/
public function unset_regeneration_in_progress_flag() {
delete_option( 'woocommerce_attribute_lookup_regeneration_in_progress' );
}
/**
* Set a flag indicating that the last lookup table regeneration process started was aborted.
*/
public function set_regeneration_aborted_flag() {
update_option( 'woocommerce_attribute_lookup_regeneration_aborted', 'yes' );
}
/**
* Remove the flag indicating that the last lookup table regeneration process started was aborted.
*/
public function unset_regeneration_aborted_flag() {
delete_option( 'woocommerce_attribute_lookup_regeneration_aborted' );
}
/**
* Tells if the last lookup table regeneration process started was aborted
* (via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option).
*
* @return bool True if the last lookup table regeneration process was aborted.
*/
public function regeneration_was_aborted(): bool {
return get_option( 'woocommerce_attribute_lookup_regeneration_aborted' ) === 'yes';
}
/**
* Check if the lookup table contains any entry at all.
*
* @return bool True if the table contains entries, false if the table is empty.
*/
public function lookup_table_has_data(): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return ( (int) $wpdb->get_var( "SELECT EXISTS (SELECT 1 FROM {$this->lookup_table_name})" ) ) !== 0;
}
/**
* Handler for 'woocommerce_get_sections_products', adds the "Advanced" section to the product settings.
*
* @param array $products Original array of settings sections.
* @return array New array of settings sections.
*/
private function add_advanced_section_to_product_settings( array $products ): array {
if ( $this->check_lookup_table_exists() ) {
$products['advanced'] = __( 'Advanced', 'woocommerce' );
}
return $products;
}
/**
* Handler for 'woocommerce_get_settings_products', adds the settings related to the product attributes lookup table.
*
* @param array $settings Original settings configuration array.
* @param string $section_id Settings section identifier.
* @return array New settings configuration array.
*/
private function add_product_attributes_lookup_table_settings( array $settings, string $section_id ): array {
if ( $section_id === 'advanced' && $this->check_lookup_table_exists() ) {
$title_item = array(
'title' => __( 'Product attributes lookup table', 'woocommerce' ),
'type' => 'title',
);
$regeneration_is_in_progress = $this->regeneration_is_in_progress();
if ( $regeneration_is_in_progress ) {
$title_item['desc'] = __( 'These settings are not available while the lookup table regeneration is in progress.', 'woocommerce' );
}
$settings[] = $title_item;
if ( ! $regeneration_is_in_progress ) {
$regeneration_aborted_warning =
$this->regeneration_was_aborted() ?
sprintf(
"<p><strong style='color: #E00000'>%s</strong></p><p>%s</p>",
__( 'WARNING: The product attributes lookup table regeneration process was aborted.', 'woocommerce' ),
__( 'This means that the table is probably in an inconsistent state. It\'s recommended to run a new regeneration process or to resume the aborted process (Status - Tools - Regenerate the product attributes lookup table/Resume the product attributes lookup table regeneration) before enabling the table usage.', 'woocommerce' )
) : null;
$settings[] = array(
'title' => __( 'Enable table usage', 'woocommerce' ),
'desc' => __( 'Use the product attributes lookup table for catalog filtering.', 'woocommerce' ),
'desc_tip' => $regeneration_aborted_warning,
'id' => 'woocommerce_attribute_lookup_enabled',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
$settings[] = array(
'title' => __( 'Direct updates', 'woocommerce' ),
'desc' => __( 'Update the table directly upon product changes, instead of scheduling a deferred update.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup_direct_updates',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
}
$settings[] = array( 'type' => 'sectionend' );
}
return $settings;
}
}
ProductAttributesLookup/DataRegenerator.php 0000644 00000046437 15233366071 0015231 0 ustar 00 <?php
/**
* DataRegenerator class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the (re)generation of the product attributes lookup table.
* It schedules the regeneration in small product batches by itself, so it can be used outside the
* regular WooCommerce data regenerations mechanism.
*
* After the regeneration is completed a wp_wc_product_attributes_lookup table will exist with entries for
* all the products that existed when initiate_regeneration was invoked; entries for products created after that
* are supposed to be created/updated by the appropriate data store classes (or by the code that uses
* the data store classes) whenever a product is created/updated.
*
* Additionally, after the regeneration is completed a 'woocommerce_attribute_lookup_enabled' option
* with a value of 'yes' will have been created, thus effectively enabling the table usage
* (with an exception: if the regeneration was manually aborted via deleting the
* 'woocommerce_attribute_lookup_regeneration_in_progress' option) the option will be set to 'no'.
*
* This class also adds two entries to the Status - Tools menu: one for manually regenerating the table contents,
* and another one for enabling or disabling the actual lookup table usage.
*/
class DataRegenerator {
use AccessiblePrivateMethods;
public const PRODUCTS_PER_GENERATION_STEP = 10;
/**
* The data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* DataRegenerator constructor.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 1, 999 );
self::add_action( 'woocommerce_run_product_attribute_lookup_regeneration_callback', array( $this, 'run_regeneration_step_callback' ) );
self::add_action( 'woocommerce_installed', array( $this, 'run_woocommerce_installed_callback' ) );
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
}
/**
* Initialize the regeneration procedure:
* deletes the lookup table and related options if they exist,
* then it creates the table and runs the first step of the regeneration process.
*
* This method is intended ONLY to be used as a callback for a db update in wc-update-functions,
* regeneration triggered from the tools page will use initiate_regeneration_from_tools_page instead.
*/
public function initiate_regeneration() {
$this->data_store->unset_regeneration_aborted_flag();
$this->enable_or_disable_lookup_table_usage( false );
$this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data();
if ( $products_exist ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration( true );
}
}
/**
* Delete all the existing data related to the lookup table, including the table itself.
*/
private function delete_all_attributes_lookup_data() {
global $wpdb;
delete_option( 'woocommerce_attribute_lookup_enabled' );
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_processed_count' );
$this->data_store->unset_regeneration_in_progress_flag();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
}
/**
* Create the lookup table and initialize the options that will be temporarily used
* while the regeneration is in progress.
*
* @return bool True if there's any product at all in the database, false otherwise.
*/
private function initialize_table_and_data() {
global $wpdb;
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $this->get_table_creation_sql() );
$last_existing_product_id = $this->get_last_existing_product_id();
if ( ! $last_existing_product_id ) {
// No products exist, nothing to (re)generate.
return false;
}
$this->data_store->set_regeneration_in_progress_flag();
update_option( 'woocommerce_attribute_lookup_last_product_id_to_process', $last_existing_product_id );
update_option( 'woocommerce_attribute_lookup_processed_count', 0 );
return true;
}
/**
* Get the highest existing product id.
*
* @return int|null Highest existing product id, or null if no products exist at all.
*/
private function get_last_existing_product_id(): ?int {
$last_existing_product_id_array =
WC()->call_function(
'wc_get_products',
array(
'return' => 'ids',
'limit' => 1,
'orderby' => array(
'ID' => 'DESC',
),
)
);
return empty( $last_existing_product_id_array ) ? null : current( $last_existing_product_id_array );
}
/**
* Action scheduler callback, performs one regeneration step and then
* schedules the next step if necessary.
*/
private function run_regeneration_step_callback() {
if ( ! $this->data_store->regeneration_is_in_progress() ) {
// No regeneration in progress at this point means that the regeneration process
// was manually aborted via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option.
$this->data_store->set_regeneration_aborted_flag();
$this->finalize_regeneration( false );
return;
}
$result = $this->do_regeneration_step();
if ( $result ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration( true );
}
}
/**
* Enqueue one regeneration step in action scheduler.
*/
private function enqueue_regeneration_step_run() {
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_regeneration_callback',
array(),
'woocommerce-db-updates'
);
}
/**
* Perform one regeneration step: grabs a chunk of products and creates
* the appropriate entries for them in the lookup table.
*
* @return bool True if more steps need to be run, false otherwise.
*/
private function do_regeneration_step() {
/**
* Filter to alter the count of products that will be processed in each step of the product attributes lookup table regeneration process.
*
* @since 6.3
* @param int $count Default processing step size.
*/
$products_per_generation_step = apply_filters( 'woocommerce_attribute_lookup_regeneration_step_size', self::PRODUCTS_PER_GENERATION_STEP );
$products_already_processed = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
$product_ids = WC()->call_function(
'wc_get_products',
array(
'limit' => $products_per_generation_step,
'offset' => $products_already_processed,
'orderby' => array(
'ID' => 'ASC',
),
'return' => 'ids',
)
);
if ( ! $product_ids ) {
return false;
}
foreach ( $product_ids as $id ) {
$this->data_store->create_data_for_product( $id );
}
$products_already_processed += count( $product_ids );
update_option( 'woocommerce_attribute_lookup_processed_count', $products_already_processed );
$last_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', PHP_INT_MAX );
return end( $product_ids ) < $last_product_id_to_process;
}
/**
* Cleanup/final option setup after the regeneration has been completed.
*
* @param bool $enable_usage Whether the table usage should be enabled or not.
*/
private function finalize_regeneration( bool $enable_usage ) {
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_processed_count' );
update_option( 'woocommerce_attribute_lookup_enabled', $enable_usage ? 'yes' : 'no' );
$this->data_store->unset_regeneration_in_progress_flag();
}
/**
* Add a 'Regenerate product attributes lookup table' entry to the Status - Tools page.
*
* @param array $tools_array The tool definitions array that is passed ro the woocommerce_debug_tools filter.
* @return array The tools array with the entry added.
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ) {
if ( ! $this->data_store->check_lookup_table_exists() ) {
return $tools_array;
}
$generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
$generation_was_aborted = $this->data_store->regeneration_was_aborted();
$entry = array(
'name' => __( 'Regenerate the product attributes lookup table', 'woocommerce' ),
'desc' => __( 'This tool will regenerate the product attributes lookup table data from existing product(s) data. This process may take a while.', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function() {
$this->initiate_regeneration_from_tools_page();
return __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
},
'selector' => array(
'description' => __( 'Select a product to regenerate the data for, or leave empty for a full table regeneration:', 'woocommerce' ),
'class' => 'wc-product-search',
'search_action' => 'woocommerce_json_search_products',
'name' => 'regenerate_product_attribute_lookup_data_product_id',
'placeholder' => esc_attr__( 'Search for a product…', 'woocommerce' ),
),
);
if ( $generation_is_in_progress ) {
$entry['button'] = sprintf(
/* translators: %d: How many products have been processed so far. */
__( 'Filling in progress (%d)', 'woocommerce' ),
get_option( 'woocommerce_attribute_lookup_processed_count', 0 )
);
$entry['disabled'] = true;
} else {
$entry['button'] = __( 'Regenerate', 'woocommerce' );
}
$tools_array['regenerate_product_attributes_lookup_table'] = $entry;
if ( $generation_is_in_progress ) {
$entry = array(
'name' => __( 'Abort the product attributes lookup table regeneration', 'woocommerce' ),
'desc' => __( 'This tool will abort the regenerate product attributes lookup table regeneration. After this is done the process can be either started over, or resumed to continue where it stopped.', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function() {
$this->abort_regeneration_from_tools_page();
return __( 'Product attributes lookup table regeneration process has been aborted.', 'woocommerce' );
},
'button' => __( 'Abort', 'woocommerce' ),
);
$tools_array['abort_product_attributes_lookup_table_regeneration'] = $entry;
} elseif ( $generation_was_aborted ) {
$processed_count = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
$entry = array(
'name' => __( 'Resume the product attributes lookup table regeneration', 'woocommerce' ),
'desc' =>
sprintf(
/* translators: %1$s = count of products already processed. */
__( 'This tool will resume the product attributes lookup table regeneration at the point in which it was aborted (%1$s products were already processed).', 'woocommerce' ),
$processed_count
),
'requires_refresh' => true,
'callback' => function() {
$this->resume_regeneration_from_tools_page();
return __( 'Product attributes lookup table regeneration process has been resumed.', 'woocommerce' );
},
'button' => __( 'Resume', 'woocommerce' ),
);
$tools_array['resume_product_attributes_lookup_table_regeneration'] = $entry;
}
return $tools_array;
}
/**
* Callback to initiate the regeneration process from the Status - Tools page.
*
* @throws \Exception The regeneration is already in progress.
*/
private function initiate_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
//phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['regenerate_product_attribute_lookup_data_product_id'] ) ) {
$product_id = (int) $_REQUEST['regenerate_product_attribute_lookup_data_product_id'];
$this->check_can_do_lookup_table_regeneration( $product_id );
$this->data_store->create_data_for_product( $product_id );
} else {
$this->check_can_do_lookup_table_regeneration();
$this->initiate_regeneration();
}
//phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Enable or disable the actual lookup table usage.
*
* @param bool $enable True to enable, false to disable.
* @throws \Exception A lookup table regeneration is currently in progress.
*/
private function enable_or_disable_lookup_table_usage( $enable ) {
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." );
}
update_option( 'woocommerce_attribute_lookup_enabled', $enable ? 'yes' : 'no' );
}
/**
* Check if everything is good to go to perform a complete or per product lookup table data regeneration
* and throw an exception if not.
*
* @param mixed $product_id The product id to check the regeneration viability for, or null to check if a complete regeneration is possible.
* @throws \Exception Something prevents the regeneration from starting.
*/
private function check_can_do_lookup_table_regeneration( $product_id = null ) {
if ( $product_id && ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: regeneration is already in progress" );
}
if ( $product_id && ! wc_get_product( $product_id ) ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: product doesn't exist" );
}
}
/**
* Callback to abort the regeneration process from the Status - Tools page.
*
* @throws \Exception The lookup table doesn't exist, or there's no regeneration process in progress to abort.
*/
private function abort_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
if ( ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't abort the product attribute lookup data regeneration process: lookup table doesn't exist" );
}
if ( ! $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't abort the product attribute lookup data regeneration process since it's not currently in progress" );
}
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->cancel_all( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
$this->data_store->unset_regeneration_in_progress_flag();
$this->data_store->set_regeneration_aborted_flag();
$this->enable_or_disable_lookup_table_usage( false );
// Note that we are NOT deleting the options that track the regeneration progress (processed count, last product id to process).
// This is on purpose so that the regeneration can be resumed where it stopped.
}
/**
* Callback to resume the regeneration process from the Status - Tools page.
*
* @throws \Exception The lookup table doesn't exist, or a regeneration process is already in place.
*/
private function resume_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
if ( ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't resume the product attribute lookup data regeneration process: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't resume the product attribute lookup data regeneration process: regeneration is already in progress" );
}
$this->data_store->unset_regeneration_aborted_flag();
$this->data_store->set_regeneration_in_progress_flag();
$this->enqueue_regeneration_step_run();
}
/**
* Verify the validity of the nonce received when executing a tool from the Status - Tools page.
*
* @throws \Exception Missing or invalid nonce received.
*/
private function verify_tool_execution_nonce() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) {
throw new \Exception( 'Invalid nonce' );
}
}
/**
* Get the name of the product attributes lookup table.
*
* @return string
*/
public function get_lookup_table_name() {
return $this->lookup_table_name;
}
/**
* Get the SQL statement that creates the product attributes lookup table, including the indices.
*
* @return string
*/
public function get_table_creation_sql() {
global $wpdb;
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
return "CREATE TABLE {$this->lookup_table_name} (
product_id bigint(20) NOT NULL,
product_or_parent_id bigint(20) NOT NULL,
taxonomy varchar(32) NOT NULL,
term_id bigint(20) NOT NULL,
is_variation_attribute tinyint(1) NOT NULL,
in_stock tinyint(1) NOT NULL,
INDEX is_variation_attribute_term_id (is_variation_attribute, term_id),
PRIMARY KEY ( `product_or_parent_id`, `term_id`, `product_id`, `taxonomy` )
) $collate;";
}
/**
* Create the primary key for the table if it doesn't exist already.
* It also deletes the product_or_parent_id_term_id index if it exists, since it's now redundant.
*
* @return void
*/
public function create_table_primary_index() {
$database_util = wc_get_container()->get( DatabaseUtil::class );
$database_util->create_primary_key( $this->lookup_table_name, array( 'product_or_parent_id', 'term_id', 'product_id', 'taxonomy' ) );
$database_util->drop_table_index( $this->lookup_table_name, 'product_or_parent_id_term_id' );
if ( empty( $database_util->get_index_columns( $this->lookup_table_name ) ) ) {
wc_get_logger()->error( "The creation of the primary key for the {$this->lookup_table_name} table failed" );
}
if ( ! empty( $database_util->get_index_columns( $this->lookup_table_name, 'product_or_parent_id_term_id' ) ) ) {
wc_get_logger()->error( "Dropping the product_or_parent_id_term_id index from the {$this->lookup_table_name} table failed" );
}
}
/**
* Run additional setup needed after a WooCommerce install or update finishes.
*/
private function run_woocommerce_installed_callback() {
// The table must exist at this point (created via dbDelta), but we check just in case.
if ( ! $this->data_store->check_lookup_table_exists() ) {
return;
}
// If a table regeneration is in progress, leave it alone.
if ( $this->data_store->regeneration_is_in_progress() ) {
return;
}
// If the lookup table has data, or if it's empty because there are no products yet, we're good.
// Otherwise (lookup table is empty but products exist) we need to initiate a regeneration if one isn't already in progress.
if ( $this->data_store->lookup_table_has_data() || ! $this->get_last_existing_product_id() ) {
$must_enable = get_option( 'woocommerce_attribute_lookup_enabled' ) !== 'no';
$this->finalize_regeneration( $must_enable );
} else {
$this->initiate_regeneration();
}
}
}
ProductAttributesLookup/Filterer.php 0000644 00000027715 15233366071 0013734 0 ustar 00 <?php
/**
* Filterer class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
defined( 'ABSPATH' ) || exit;
/**
* Helper class for filtering products using the product attributes lookup table.
*/
class Filterer {
/**
* The product attributes lookup data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The name of the product attributes lookup table.
*
* @var string
*/
private $lookup_table_name;
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
$this->lookup_table_name = $data_store->get_lookup_table_name();
}
/**
* Checks if the product attribute filtering via lookup table feature is enabled.
*
* @return bool
*/
public function filtering_via_lookup_table_is_active() {
return 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );
}
/**
* Adds post clauses for filtering via lookup table.
* This method should be invoked within a 'posts_clauses' filter.
*
* @param array $args Product query clauses as supplied to the 'posts_clauses' filter.
* @param \WP_Query $wp_query Current product query as supplied to the 'posts_clauses' filter.
* @param array $attributes_to_filter_by Attribute filtering data as generated by WC_Query::get_layered_nav_chosen_attributes.
* @return array The updated product query clauses.
*/
public function filter_by_attribute_post_clauses( array $args, \WP_Query $wp_query, array $attributes_to_filter_by ) {
global $wpdb;
if ( ! $wp_query->is_main_query() || ! $this->filtering_via_lookup_table_is_active() ) {
return $args;
}
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
// (causes the filtering subquery to be executed only once).
$clause_root = " {$wpdb->posts}.ID IN ( SELECT product_or_parent_id FROM (";
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$in_stock_clause = ' AND in_stock = 1';
} else {
$in_stock_clause = '';
}
$attribute_ids_for_and_filtering = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$term_ids_to_filter_by = array_map( 'absint', $term_ids_to_filter_by );
$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
$is_and_query = 'and' === $data['query_type'];
$count = count( $term_ids_to_filter_by );
if ( 0 !== $count ) {
if ( $is_and_query && $count > 1 ) {
$attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
} else {
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE term_id in {$term_ids_to_filter_by_list}
{$in_stock_clause}
)";
}
}
}
if ( ! empty( $attribute_ids_for_and_filtering ) ) {
$count = count( $attribute_ids_for_and_filtering );
$term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
GROUP BY product_id
HAVING COUNT(product_id)={$count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
)";
}
if ( ! empty( $clauses ) ) {
// "temp" is needed because the extra derived tables require an alias.
$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
} elseif ( ! empty( $attributes_to_filter_by ) ) {
$args['where'] .= ' AND 1=0';
}
return $args;
}
/**
* Count products within certain terms, taking the main WP query into consideration,
* for the WC_Widget_Layered_Nav widget.
*
* This query allows counts to be generated based on the viewed products, not all products.
*
* @param array $term_ids Term IDs.
* @param string $taxonomy Taxonomy.
* @param string $query_type Query Type.
* @return array
*/
public function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type ) {
global $wpdb;
$use_lookup_table = $this->filtering_via_lookup_table_is_active();
$tax_query = \WC_Query::get_main_tax_query();
$meta_query = \WC_Query::get_main_meta_query();
if ( 'or' === $query_type ) {
foreach ( $tax_query as $key => $query ) {
if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
unset( $tax_query[ $key ] );
}
}
}
$meta_query = new \WP_Meta_Query( $meta_query );
$tax_query = new \WP_Tax_Query( $tax_query );
if ( $use_lookup_table ) {
$query = $this->get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids );
} else {
$query = $this->get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids );
}
$query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query );
$query_sql = implode( ' ', $query );
// We have a query - let's see if cached results of this query already exist.
$query_hash = md5( $query_sql );
// Maybe store a transient of the count values.
$cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true );
if ( true === $cache ) {
$cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
} else {
$cached_counts = array();
}
if ( ! isset( $cached_counts[ $query_hash ] ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$results = $wpdb->get_results( $query_sql, ARRAY_A );
$counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
$cached_counts[ $query_hash ] = $counts;
if ( true === $cache ) {
set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
}
}
return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
}
/**
* Get the query for counting products by terms using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $taxonomy The attribute name to get the term counts for.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $this->lookup_table_name, 'product_or_parent_id' );
$tax_query_sql = $tax_query->get_sql( $this->lookup_table_name, 'product_or_parent_id' );
$hide_out_of_stock = 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' );
$in_stock_clause = $hide_out_of_stock ? ' AND in_stock = 1' : '';
$query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id';
$query['from'] = "FROM {$this->lookup_table_name}";
$query['join'] = "
{$tax_query_sql['join']} {$meta_query_sql['join']}
INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id";
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND {$this->lookup_table_name}.taxonomy='{$taxonomy}'
AND {$this->lookup_table_name}.term_id IN $term_ids_sql
{$in_stock_clause}";
if ( ! empty( $term_ids ) ) {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) {
$and_term_ids = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
if ( 'and' !== $data['query_type'] ) {
continue;
}
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
}
if ( ! empty( $and_term_ids ) ) {
$terms_count = count( $and_term_ids );
$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
// (causes the filtering subquery to be executed only once).
$query['where'] .= "
AND product_or_parent_id IN ( SELECT product_or_parent_id FROM (
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_list}
GROUP BY product_id
HAVING COUNT(product_id)={$terms_count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_list}
) temp )";
}
} else {
$query['where'] .= $in_stock_clause;
}
} elseif ( $hide_out_of_stock ) {
$query['where'] .= " AND {$this->lookup_table_name}.in_stock=1";
}
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
$query['group_by'] = "GROUP BY {$this->lookup_table_name}.term_id";
return $query;
}
/**
* Get the query for counting products by terms NOT using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
$tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' );
// Generate query.
$query = array();
$query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) AS term_count, terms.term_id AS term_count_id";
$query['from'] = "FROM {$wpdb->posts}";
$query['join'] = "
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
INNER JOIN {$wpdb->terms} AS terms USING( term_id )
" . $tax_query_sql['join'] . $meta_query_sql['join'];
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND terms.term_id IN $term_ids_sql";
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
return $query;
}
/**
* Formats a list of term ids as "(id,id,id)".
*
* @param array $term_ids The list of terms to format.
* @return string The formatted list.
*/
private function get_term_ids_sql( $term_ids ) {
return '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
}
}
DownloadPermissionsAdjuster.php 0000644 00000013527 15233366071 0013000 0 ustar 00 <?php
/**
* DownloadPermissionsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust download permissions on product save.
*/
class DownloadPermissionsAdjuster {
/**
* The downloads data store to use.
*
* @var WC_Data_Store
*/
private $downloads_data_store;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->downloads_data_store = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Data_Store::class, 'customer-download' );
add_action( 'adjust_download_permissions', array( $this, 'adjust_download_permissions' ), 10, 1 );
}
/**
* Schedule a download permissions adjustment for a product if necessary.
* This should be executed whenever a product is saved.
*
* @param \WC_Product $product The product to schedule a download permission adjustments for.
*/
public function maybe_schedule_adjust_download_permissions( \WC_Product $product ) {
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$scheduled_action_args = array( $product->get_id() );
$already_scheduled_actions =
WC()->call_function(
'as_get_scheduled_actions',
array(
'hook' => 'adjust_download_permissions',
'args' => $scheduled_action_args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled_actions ) ) {
WC()->call_function(
'as_schedule_single_action',
WC()->call_function( 'time' ) + 1,
'adjust_download_permissions',
$scheduled_action_args
);
}
}
/**
* Create additional download permissions for variations if necessary.
*
* When a simple downloadable product is converted to a variable product,
* existing download permissions are still present in the database but they don't apply anymore.
* This method creates additional download permissions for the variations based on
* the old existing ones for the main product.
*
* The procedure is as follows. For each existing download permission for the parent product,
* check if there's any variation offering the same file for download (the file URL, not name, is checked).
* If that is found, check if an equivalent permission exists (equivalent means for the same file and with
* the same order id and customer id). If no equivalent permission exists, create it.
*
* @param int $product_id The id of the product to check permissions for.
*/
public function adjust_download_permissions( int $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return;
}
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$parent_downloads = $this->get_download_files_and_permissions( $product );
if ( ! $parent_downloads ) {
return;
}
$children_with_downloads = array();
foreach ( $children_ids as $child_id ) {
$child = wc_get_product( $child_id );
$children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child );
}
foreach ( $parent_downloads['permission_data_by_file_order_user'] as $parent_file_order_and_user => $parent_download_data ) {
foreach ( $children_with_downloads as $child_id => $child_download_data ) {
$file_url = $parent_download_data['file'];
$must_create_permission =
// The variation offers the same file as the parent for download...
in_array( $file_url, array_keys( $child_download_data['download_ids_by_file_url'] ), true ) &&
// ...but no equivalent download permission (same file URL, order id and user id) exists.
! array_key_exists( $parent_file_order_and_user, $child_download_data['permission_data_by_file_order_user'] );
if ( $must_create_permission ) {
// The new child download permission is a copy of the parent's,
// but with the product and download ids changed to match those of the variation.
$new_download_data = $parent_download_data['data'];
$new_download_data['product_id'] = $child_id;
$new_download_data['download_id'] = $child_download_data['download_ids_by_file_url'][ $file_url ];
$this->downloads_data_store->create_from_data( $new_download_data );
}
}
}
}
/**
* Get the existing downloadable files and download permissions for a given product.
* The returned value is an array with two keys:
*
* - download_ids_by_file_url: an associative array of file url => download_id.
* - permission_data_by_file_order_user: an associative array where key is "file_url:customer_id:order_id" and value is the full permission data set.
*
* @param \WC_Product $product The product to get the downloadable files and permissions for.
* @return array[] Information about the downloadable files and permissions for the product.
*/
private function get_download_files_and_permissions( \WC_Product $product ) {
$result = array(
'permission_data_by_file_order_user' => array(),
'download_ids_by_file_url' => array(),
);
$downloads = $product->get_downloads();
foreach ( $downloads as $download ) {
$result['download_ids_by_file_url'][ $download->get_file() ] = $download->get_id();
}
$permissions = $this->downloads_data_store->get_downloads( array( 'product_id' => $product->get_id() ) );
foreach ( $permissions as $permission ) {
$permission_data = (array) $permission->data;
if ( array_key_exists( $permission_data['download_id'], $downloads ) ) {
$file = $downloads[ $permission_data['download_id'] ]->get_file();
$data = array(
'file' => $file,
'data' => (array) $permission->data,
);
$result['permission_data_by_file_order_user'][ "${file}:${permission_data['user_id']}:${permission_data['order_id']}" ] = $data;
}
}
return $result;
}
}
Traits/AccessiblePrivateMethods.php 0000644 00000017745 15233366071 0013463 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Traits;
use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* This trait allows making private methods of a class accessible from outside.
* This is useful to define hook handlers with the [$this, 'method'] or [__CLASS__, 'method'] syntax
* without having to make the method public (and thus having to keep it forever for backwards compatibility).
*
* Example:
*
* class Foobar {
* use AccessiblePrivateMethods;
*
* public function __construct() {
* self::add_action('some_action', [$this, 'handle_some_action']);
* }
*
* public static function init() {
* self::add_filter('some_filter', [__CLASS__, 'handle_some_filter']);
* }
*
* private function handle_some_action() {
* }
*
* private static function handle_some_filter() {
* }
* }
*
* For this to work the callback must be an array and the first element of the array must be either '$this', '__CLASS__',
* or another instance of the same class; otherwise the method won't be marked as accessible
* (but the corresponding WordPress 'add_action' and 'add_filter' functions will still be called).
*
* No special procedure is needed to remove hooks set up with these methods, the regular 'remove_action'
* and 'remove_filter' functions provided by WordPress can be used as usual.
*/
trait AccessiblePrivateMethods {
//phpcs:disable PSR2.Classes.PropertyDeclaration.Underscore
/**
* List of instance methods marked as externally accessible.
*
* @var array
*/
private $_accessible_private_methods = array();
/**
* List of static methods marked as externally accessible.
*
* @var array
*/
private static $_accessible_static_private_methods = array();
//phpcs:enable PSR2.Classes.PropertyDeclaration.Underscore
/**
* Register a WordPress action.
* If the callback refers to a private or protected instance method in this class, the method is marked as externally accessible.
*
* $callback can be a standard callable, or a string representing the name of a method in this class.
*
* @param string $hook_name The name of the action to add the callback to.
* @param callable|string $callback The callback to be run when the action is called.
* @param int $priority Optional. Used to specify the order in which the functions
* associated with a particular action are executed.
* Lower numbers correspond with earlier execution,
* and functions with the same priority are executed
* in the order in which they were added to the action. Default 10.
* @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
*/
protected static function add_action( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
self::process_callback_before_hooking( $callback );
add_action( $hook_name, $callback, $priority, $accepted_args );
}
/**
* Register a WordPress filter.
* If the callback refers to a private or protected instance method in this class, the method is marked as externally accessible.
*
* $callback can be a standard callable, or a string representing the name of a method in this class.
*
* @param string $hook_name The name of the filter to add the callback to.
* @param callable|string $callback The callback to be run when the filter is called.
* @param int $priority Optional. Used to specify the order in which the functions
* associated with a particular filter are executed.
* Lower numbers correspond with earlier execution,
* and functions with the same priority are executed
* in the order in which they were added to the filter. Default 10.
* @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
*/
protected static function add_filter( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
self::process_callback_before_hooking( $callback );
add_filter( $hook_name, $callback, $priority, $accepted_args );
}
/**
* Do the required processing to a callback before invoking the WordPress 'add_action' or 'add_filter' function.
*
* @param callable $callback The callback to process.
* @return void
*/
protected static function process_callback_before_hooking( $callback ): void {
if ( ! is_array( $callback ) || count( $callback ) < 2 ) {
return;
}
$first_item = $callback[0];
if ( __CLASS__ === $first_item ) {
static::mark_static_method_as_accessible( $callback[1] );
} elseif ( is_object( $first_item ) && get_class( $first_item ) === __CLASS__ ) {
$first_item->mark_method_as_accessible( $callback[1] );
}
}
/**
* Register a private or protected instance method of this class as externally accessible.
*
* @param string $method_name Method name.
* @return bool True if the method has been marked as externally accessible, false if the method doesn't exist.
*/
protected function mark_method_as_accessible( string $method_name ): bool {
// Note that an "is_callable" check would be useless here:
// "is_callable" always returns true if the class implements __call.
if ( method_exists( $this, $method_name ) ) {
$this->_accessible_private_methods[ $method_name ] = $method_name;
return true;
}
return false;
}
/**
* Register a private or protected static method of this class as externally accessible.
*
* @param string $method_name Method name.
* @return bool True if the method has been marked as externally accessible, false if the method doesn't exist.
*/
protected static function mark_static_method_as_accessible( string $method_name ): bool {
if ( method_exists( __CLASS__, $method_name ) ) {
static::$_accessible_static_private_methods[ $method_name ] = $method_name;
return true;
}
return false;
}
/**
* Undefined/inaccessible instance method call handler.
*
* @param string $name Called method name.
* @param array $arguments Called method arguments.
* @return mixed
* @throws \Error The called instance method doesn't exist or is private/protected and not marked as externally accessible.
*/
public function __call( $name, $arguments ) {
if ( isset( $this->_accessible_private_methods[ $name ] ) ) {
return call_user_func_array( array( $this, $name ), $arguments );
} elseif ( is_callable( array( 'parent', '__call' ) ) ) {
return parent::__call( $name, $arguments );
} elseif ( method_exists( $this, $name ) ) {
throw new \Error( 'Call to private method ' . get_class( $this ) . '::' . $name );
} else {
throw new \Error( 'Call to undefined method ' . get_class( $this ) . '::' . $name );
}
}
/**
* Undefined/inaccessible static method call handler.
*
* @param string $name Called method name.
* @param array $arguments Called method arguments.
* @return mixed
* @throws \Error The called static method doesn't exist or is private/protected and not marked as externally accessible.
*/
public static function __callStatic( $name, $arguments ) {
if ( isset( static::$_accessible_static_private_methods[ $name ] ) ) {
return call_user_func_array( array( __CLASS__, $name ), $arguments );
} elseif ( is_callable( array( 'parent', '__callStatic' ) ) ) {
return parent::__callStatic( $name, $arguments );
} elseif ( 'add_action' === $name || 'add_filter' === $name ) {
$proper_method_name = 'add_static_' . substr( $name, 4 );
throw new \Error( __CLASS__ . '::' . $name . " can't be called statically, did you mean '$proper_method_name'?" );
} elseif ( method_exists( __CLASS__, $name ) ) {
throw new \Error( 'Call to private method ' . __CLASS__ . '::' . $name );
} else {
throw new \Error( 'Call to undefined method ' . __CLASS__ . '::' . $name );
}
}
}
WCCom/ConnectionHelper.php 0000644 00000001216 15233366071 0011472 0 ustar 00 <?php
/**
* Helpers for managing connection to WooCommerce.com.
*/
namespace Automattic\WooCommerce\Internal\WCCom;
defined( 'ABSPATH' ) || exit;
/**
* Class WCConnectionHelper.
*
* Helpers for managing connection to WooCommerce.com.
*/
final class ConnectionHelper {
/**
* Check if WooCommerce.com account is connected.
*
* @since 4.4.0
* @return bool Whether account is connected.
*/
public static function is_connected() {
$helper_options = get_option( 'woocommerce_helper_data', array() );
if ( array_key_exists( 'auth', $helper_options ) && ! empty( $helper_options['auth'] ) ) {
return true;
}
return false;
}
}
ProductDownloads/ApprovedDirectories/Admin/SyncUI.php 0000644 00000010246 15233366071 0016740 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
use Automattic\WooCommerce\Internal\Utilities\Users;
/**
* Adds tools to the Status > Tools page that can be used to (re-)initiate or stop a synchronization process
* for Approved Download Directories.
*/
class SyncUI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_debug_tools', array( $this, 'add_tools' ) );
}
/**
* Adds Approved Directory list-related entries to the tools page.
*
* @param array $tools Admin tool definitions.
*
* @return array
*/
public function add_tools( array $tools ): array {
$sync = wc_get_container()->get( Synchronize::class );
if ( ! $sync->in_progress() ) {
// Provide tools to trigger a fresh scan (migration) and to clear the Approved Directories list.
$tools['approved_directories_sync'] = array(
'name' => __( 'Synchronize approved download directories', 'woocommerce' ),
'desc' => __( 'Updates the list of Approved Product Download Directories. Note that triggering this tool does not impact whether the Approved Download Directories list is enabled or not.', 'woocommerce' ),
'button' => __( 'Update', 'woocommerce' ),
'callback' => array( $this, 'trigger_sync' ),
'requires_refresh' => true,
);
$tools['approved_directories_clear'] = array(
'name' => __( 'Empty the approved download directories list', 'woocommerce' ),
'desc' => __( 'Removes all existing entries from the Approved Product Download Directories list.', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'callback' => array( $this, 'clear_existing_entries' ),
'requires_refresh' => true,
);
} else {
// Or if a scan (migration) is already in progress, offer a means of cancelling it.
$tools['cancel_directories_scan'] = array(
'name' => __( 'Cancel synchronization of approved directories', 'woocommerce' ),
'desc' => sprintf(
/* translators: %d is an integer between 0-100 representing the percentage complete of the current scan. */
__( 'The Approved Product Download Directories list is currently being synchronized with the product catalog (%d%% complete). If you need to, you can cancel it.', 'woocommerce' ),
$sync->get_progress()
),
'button' => __( 'Cancel', 'woocommerce' ),
'callback' => array( $this, 'cancel_sync' ),
);
}
return $tools;
}
/**
* Triggers a new migration.
*/
public function trigger_sync() {
$this->security_check();
wc_get_container()->get( Synchronize::class )->start();
}
/**
* Clears all existing rules from the Approved Directories list.
*/
public function clear_existing_entries() {
$this->security_check();
$this->register->delete_all();
}
/**
* If a migration is in progress, this will attempt to cancel it.
*/
public function cancel_sync() {
$this->security_check();
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan has been cancelled.', 'woocommerce' ) );
wc_get_container()->get( Synchronize::class )->stop();
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}
ProductDownloads/ApprovedDirectories/Admin/Table.php 0000644 00000023766 15233366071 0016630 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\StoredUrl;
use WP_List_Table;
use WP_Screen;
/**
* Admin list table used to render our current list of approved directories.
*/
class Table extends WP_List_Table {
/**
* Initialize the webhook table list.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'url',
'plural' => 'urls',
'ajax' => false,
)
);
add_filter( 'manage_woocommerce_page_wc-settings_columns', array( $this, 'get_columns' ) );
$this->items_per_page();
set_screen_options();
}
/**
* Sets up an items-per-page control.
*/
private function items_per_page() {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'edit_approved_directories_per_page',
)
);
add_filter( 'set_screen_option_edit_approved_directories_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
}
/**
* Saves the items-per-page setting.
*
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
*
* @return mixed
*/
public function set_items_per_page( $default, string $option, int $value ) {
return 'edit_approved_directories_per_page' === $option ? absint( $value ) : $default;
}
/**
* No items found text.
*/
public function no_items() {
esc_html_e( 'No approved directory URLs found.', 'woocommerce' );
}
/**
* Displays the list of views available on this table.
*/
public function render_views() {
$register = wc_get_container()->get( Register::class );
$enabled_count = $register->count( true );
$disabled_count = $register->count( false );
$all_count = $enabled_count + $disabled_count;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$selected_view = isset( $_REQUEST['view'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['view'] ) ) : 'all';
$all_url = esc_url( add_query_arg( 'view', 'all', $this->get_base_url() ) );
$all_class = 'all' === $selected_view ? 'class="current"' : '';
$all_text = sprintf(
/* translators: %s is the count of approved directory list entries. */
_nx(
'All <span class="count">(%s)</span>',
'All <span class="count">(%s)</span>',
$all_count,
'Approved product download directory views',
'woocommerce'
),
$all_count
);
$enabled_url = esc_url( add_query_arg( 'view', 'enabled', $this->get_base_url() ) );
$enabled_class = 'enabled' === $selected_view ? 'class="current"' : '';
$enabled_text = sprintf(
/* translators: %s is the count of enabled approved directory list entries. */
_nx(
'Enabled <span class="count">(%s)</span>',
'Enabled <span class="count">(%s)</span>',
$enabled_count,
'Approved product download directory views',
'woocommerce'
),
$enabled_count
);
$disabled_url = esc_url( add_query_arg( 'view', 'disabled', $this->get_base_url() ) );
$disabled_class = 'disabled' === $selected_view ? 'class="current"' : '';
$disabled_text = sprintf(
/* translators: %s is the count of disabled directory list entries. */
_nx(
'Disabled <span class="count">(%s)</span>',
'Disabled <span class="count">(%s)</span>',
$disabled_count,
'Approved product download directory views',
'woocommerce'
),
$disabled_count
);
$views = array(
'all' => "<a href='{$all_url}' {$all_class}>{$all_text}</a>",
'enabled' => "<a href='{$enabled_url}' {$enabled_class}>{$enabled_text}</a>",
'disabled' => "<a href='{$disabled_url}' {$disabled_class}>{$disabled_text}</a>",
);
$this->screen->render_screen_reader_content( 'heading_views' );
echo '<ul class="subsubsub list-table-filters">';
foreach ( $views as $slug => $view ) {
$views[ $slug ] = "<li class='{$slug}'>{$view}";
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo implode( ' | </li>', $views ) . "</li>\n";
echo '</ul>';
}
/**
* Get list columns.
*
* @return array
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'title' => _x( 'URL', 'Approved product download directories', 'woocommerce' ),
'enabled' => _x( 'Enabled', 'Approved product download directories', 'woocommerce' ),
);
}
/**
* Checklist column, used for selecting items for processing by a bulk action.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="%1$s[]" value="%2$s" />', esc_attr( $this->_args['singular'] ), esc_attr( $item->get_id() ) );
}
/**
* URL column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_title( $item ) {
$id = (int) $item->get_id();
$url = esc_html( $item->get_url() );
$enabled = $item->is_enabled();
$edit_url = esc_url( $this->get_action_url( 'edit', $id ) );
$enable_disable_url = esc_url( $enabled ? $this->get_action_url( 'disable', $id ) : $this->get_action_url( 'enable', $id ) );
$enable_disable_text = esc_html( $enabled ? __( 'Disable', 'woocommerce' ) : __( 'Enable', 'woocommerce' ) );
$delete_url = esc_url( $this->get_action_url( 'delete', $id ) );
$edit_link = "<a href='{$edit_url}'>" . esc_html_x( 'Edit', 'Product downloads list', 'woocommerce' ) . '</a>';
$enable_disable_link = "<a href='{$enable_disable_url}'>{$enable_disable_text}</a>";
$delete_link = "<a href='{$delete_url}' class='submitdelete wc-confirm-delete'>" . esc_html_x( 'Delete permanently', 'Product downloads list', 'woocommerce' ) . '</a>';
$url_link = "<a href='{$edit_url}'>{$url}</a>";
return "
<strong>{$url_link}</strong>
<div class='row-actions'>
<span class='id'>ID: {$id}</span> |
<span class='edit'>{$edit_link}</span> |
<span class='enable-disable'>{$enable_disable_link}</span> |
<span class='delete'><a class='submitdelete'>{$delete_link}</a></span>
</div>
";
}
/**
* Rule-is-enabled column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_enabled( StoredUrl $item ): string {
return $item->is_enabled()
? '<mark class="yes" title="' . esc_html__( 'Enabled', 'woocommerce' ) . '"><span class="dashicons dashicons-yes"></span></mark>'
: '<mark class="no" title="' . esc_html__( 'Disabled', 'woocommerce' ) . '">–</mark>';
}
/**
* Get bulk actions.
*
* @return array
*/
protected function get_bulk_actions() {
return array(
'enable' => __( 'Enable rule', 'woocommerce' ),
'disable' => __( 'Disable rule', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
);
}
/**
* Builds an action URL (ie, to edit or delete a row).
*
* @param string $action The action to be created.
* @param int $id The ID that is the subject of the action.
* @param string $nonce_action Action used to add a nonce to the URL.
*
* @return string
*/
public function get_action_url( string $action, int $id, string $nonce_action = 'modify_approved_directories' ): string {
return add_query_arg(
array(
'check' => wp_create_nonce( $nonce_action ),
'action' => $action,
'url' => $id,
),
$this->get_base_url()
);
}
/**
* Supplies the 'base' admin URL for this admin table.
*
* @return string
*/
public function get_base_url(): string {
return add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'products',
'section' => 'download_urls',
),
admin_url( 'admin.php' )
);
}
/**
* Generate the table navigation above or below the table.
* Included to remove extra nonce input.
*
* @param string $which The location of the extra table nav markup: 'top' or 'bottom'.
*/
protected function display_tablenav( $which ) {
$directories = wc_get_container()->get( Register::class );
echo '<div class="tablenav ' . esc_attr( $which ) . '">';
if ( $this->has_items() ) {
echo '<div class="alignleft actions bulkactions">';
$this->bulk_actions( $which );
if ( $directories->count( false ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'enable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Enable All', 'Approved product download directories', 'woocommerce' ) . '</a> ';
}
if ( $directories->count( true ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'disable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Disable All', 'Approved product download directories', 'woocommerce' ) . '</a>';
}
echo '</div>';
}
$this->pagination( $which );
echo '<br class="clear" />';
echo '</div>';
}
/**
* Prepare table list items.
*/
public function prepare_items() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.NonceVerification.Missing
$current_page = $this->get_pagenum();
$per_page = $this->get_items_per_page( 'edit_approved_directories_per_page' );
$search = sanitize_text_field( wp_unslash( $_REQUEST['s'] ?? '' ) );
switch ( $_REQUEST['view'] ?? '' ) {
case 'enabled':
$enabled = true;
break;
case 'disabled':
$enabled = false;
break;
default:
$enabled = null;
break;
}
// phpcs:enable
$approved_directories = wc_get_container()->get( Register::class )->list(
array(
'page' => $current_page,
'per_page' => $per_page,
'search' => $search,
'enabled' => $enabled,
)
);
$this->items = $approved_directories['approved_directories'];
// Set the pagination.
$this->set_pagination_args(
array(
'total_items' => $approved_directories['total_urls'],
'total_pages' => $approved_directories['total_pages'],
'per_page' => $per_page,
)
);
}
}
ProductDownloads/ApprovedDirectories/Admin/UI.php 0000644 00000035040 15233366071 0016102 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Exception;
use WC_Admin_Settings;
/**
* Manages user interactions for product download URL safety.
*/
class UI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* The WP_List_Table instance used to display approved directories.
*
* @var Table
*/
private $table;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_get_sections_products', array( $this, 'add_section' ) );
add_action( 'load-woocommerce_page_wc-settings', array( $this, 'setup' ) );
add_action( 'woocommerce_settings_products', array( $this, 'render' ) );
}
/**
* Injects our new settings section (when approved directory rules are disabled, it will not show).
*
* @param array $sections Other admin settings sections.
*
* @return array
*/
public function add_section( array $sections ): array {
$sections['download_urls'] = __( 'Approved download directories', 'woocommerce' );
return $sections;
}
/**
* Sets up the table, renders any notices and processes actions as needed.
*/
public function setup() {
if ( ! $this->is_download_urls_screen() ) {
return;
}
$this->table = new Table();
$this->admin_notices();
$this->handle_search();
$this->process_actions();
}
/**
* Renders the UI.
*/
public function render() {
if ( null === $this->table || ! $this->is_download_urls_screen() ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['action'] ) && 'edit' === $_REQUEST['action'] && isset( $_REQUEST['url'] ) ) {
$this->edit_screen( (int) $_REQUEST['url'] );
return;
}
// phpcs:enable
// Show list table.
$this->table->prepare_items();
wp_nonce_field( 'modify_approved_directories', 'check' );
$this->display_title();
$this->table->render_views();
$this->table->search_box( _x( 'Search', 'Approved Directory URLs', 'woocommerce' ), 'download_url_search' );
$this->table->display();
}
/**
* Indicates if we are currently on the download URLs admin screen.
*
* @return bool
*/
private function is_download_urls_screen(): bool {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
return isset( $_GET['tab'] )
&& 'products' === $_GET['tab']
&& isset( $_GET['section'] )
&& 'download_urls' === $_GET['section'];
// phpcs:enable
}
/**
* Process bulk and single-row actions.
*/
private function process_actions() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$ids = isset( $_REQUEST['url'] ) ? array_map( 'absint', (array) $_REQUEST['url'] ) : array();
if ( empty( $ids ) || empty( $_REQUEST['action'] ) ) {
return;
}
$this->security_check();
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
switch ( $action ) {
case 'edit':
$this->process_edits( current( $ids ) );
break;
case 'delete':
case 'enable':
case 'disable':
$this->process_bulk_actions( $ids, $action );
break;
case 'enable-all':
case 'disable-all':
$this->process_all_actions( $action );
break;
case 'turn-on':
case 'turn-off':
$this->process_on_off( $action );
break;
}
// phpcs:enable
}
/**
* Support pagination across search results.
*
* In the context of the WC settings screen, form data is submitted by the post method: that poses
* a problem for the default WP_List_Table pagination logic which expects the search value to live
* as part of the URL query. This method is a simple shim to bridge the resulting gap.
*/
private function handle_search() {
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// If a search value has not been POSTed, or if it was POSTed but is already equal to the
// same value in the URL query, we need take no further action.
if ( empty( $_POST['s'] ) || sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) === $_POST['s'] ) {
return;
}
wp_safe_redirect(
add_query_arg(
array(
'paged' => absint( $_GET['paged'] ?? 1 ),
's' => sanitize_text_field( wp_unslash( $_POST['s'] ) ),
),
$this->table->get_base_url()
)
);
// phpcs:enable
exit;
}
/**
* Handles updating or adding a new URL to the list of approved directories.
*
* @param int $url_id The ID of the rule to be edited/created. Zero if we are creating a new entry.
*/
private function process_edits( int $url_id ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$url = esc_url_raw( wp_unslash( $_POST['approved_directory_url'] ?? '' ) );
$enabled = (bool) sanitize_text_field( wp_unslash( $_POST['approved_directory_enabled'] ?? '' ) );
if ( empty( $url ) ) {
return;
}
$redirect_url = add_query_arg( 'id', $url_id, $this->table->get_action_url( 'edit', $url_id ) );
try {
$upserted = 0 === $url_id
? $this->register->add_approved_directory( $url, $enabled )
: $this->register->update_approved_directory( $url_id, $url, $enabled );
if ( is_integer( $upserted ) ) {
$redirect_url = add_query_arg( 'url', $upserted, $redirect_url );
}
$redirect_url = add_query_arg( 'edit-status', 0 === $url_id ? 'added' : 'updated', $redirect_url );
} catch ( Exception $e ) {
$redirect_url = add_query_arg(
array(
'edit-status' => 'failure',
'submitted-url' => $url,
),
$redirect_url
);
}
wp_safe_redirect( $redirect_url );
exit;
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Processes actions that can be applied in bulk (requests to delete, enable
* or disable).
*
* @param int[] $ids The ID(s) to be updates.
* @param string $action The action to be applied.
*/
private function process_bulk_actions( array $ids, string $action ) {
$deletes = 0;
$enabled = 0;
$disabled = 0;
$register = wc_get_container()->get( Register::class );
foreach ( $ids as $id ) {
if ( 'delete' === $action && $register->delete_by_id( $id ) ) {
$deletes++;
} elseif ( 'enable' === $action && $register->enable_by_id( $id ) ) {
$enabled++;
} elseif ( 'disable' === $action && $register->disable_by_id( $id ) ) {
$disabled ++;
}
}
$fails = count( $ids ) - $deletes - $enabled - $disabled;
$redirect = $this->table->get_base_url();
if ( $deletes ) {
$redirect = add_query_arg( 'deleted-ids', $deletes, $redirect );
} elseif ( $enabled ) {
$redirect = add_query_arg( 'enabled-ids', $enabled, $redirect );
} elseif ( $disabled ) {
$redirect = add_query_arg( 'disabled-ids', $disabled, $redirect );
}
if ( $fails ) {
$redirect = add_query_arg( 'bulk-fails', $fails, $redirect );
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles the enable/disable-all actions.
*
* @param string $action The action to be applied.
*/
private function process_all_actions( string $action ) {
$register = wc_get_container()->get( Register::class );
$redirect = $this->table->get_base_url();
switch ( $action ) {
case 'enable-all':
$redirect = add_query_arg( 'enabled-all', (int) $register->enable_all(), $redirect );
break;
case 'disable-all':
$redirect = add_query_arg( 'disabled-all', (int) $register->disable_all(), $redirect );
break;
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles turning on/off the entire approved download directory system (vs enabling
* and disabling of individual rules).
*
* @param string $action Whether the feature should be turned on or off.
*/
private function process_on_off( string $action ) {
switch ( $action ) {
case 'turn-on':
$this->register->set_mode( Register::MODE_ENABLED );
break;
case 'turn-off':
$this->register->set_mode( Register::MODE_DISABLED );
break;
}
}
/**
* Displays the screen title, etc.
*/
private function display_title() {
$turn_on_off = $this->register->get_mode() === Register::MODE_ENABLED
? '<a href="' . esc_url( $this->table->get_action_url( 'turn-off', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Stop Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>'
: '<a href="' . esc_url( $this->table->get_action_url( 'turn-on', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Start Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>';
?>
<h2 class='wc-table-list-header'>
<?php esc_html_e( 'Approved Download Directories', 'woocommerce' ); ?>
<a href='<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>' class='page-title-action'><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php echo $turn_on_off; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</h2>
<?php
}
/**
* Renders the editor screen for approved directory URLs.
*
* @param int $url_id The ID of the rule to be edited (may be zero for new rules).
*/
private function edit_screen( int $url_id ) {
$this->security_check();
$existing = $this->register->get_by_id( $url_id );
if ( 0 !== $url_id && ! $existing ) {
WC_Admin_Settings::add_error( _x( 'The provided ID was invalid.', 'Approved product download directories', 'woocommerce' ) );
WC_Admin_Settings::show_messages();
return;
}
$title = $existing
? __( 'Edit Approved Directory', 'woocommerce' )
: __( 'Add New Approved Directory', 'woocommerce' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$submitted = sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) );
$existing_url = $existing ? $existing->get_url() : '';
$enabled = $existing ? $existing->is_enabled() : true;
// phpcs:enable
?>
<h2 class='wc-table-list-header'>
<?php echo esc_html( $title ); ?>
<?php if ( $existing ) : ?>
<a href="<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php endif; ?>
<a href="<?php echo esc_url( $this->table->get_base_url() ); ?> " class="page-title-action"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
</h2>
<table class='form-table'>
<tbody>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_url'> <?php echo esc_html_x( 'Directory URL', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_url' id='approved_directory_url' type='text' class='input-text regular-input' value='<?php echo esc_attr( empty( $submitted ) ? $existing_url : $submitted ); ?>'>
</td>
</tr>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_enabled'> <?php echo esc_html_x( 'Enabled', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_enabled' id='approved_directory_enabled' type='checkbox' value='1' <?php checked( true, $enabled ); ?>'>
</td>
</tr>
</tbody>
</table>
<input name='id' id='approved_directory_id' type='hidden' value='{$url_id}'>
<?php
}
/**
* Displays any admin notices that might be needed.
*/
private function admin_notices() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$successfully_deleted = isset( $_GET['deleted-ids'] ) ? (int) $_GET['deleted-ids'] : 0;
$successfully_enabled = isset( $_GET['enabled-ids'] ) ? (int) $_GET['enabled-ids'] : 0;
$successfully_disabled = isset( $_GET['disabled-ids'] ) ? (int) $_GET['disabled-ids'] : 0;
$failed_updates = isset( $_GET['bulk-fails'] ) ? (int) $_GET['bulk-fails'] : 0;
$edit_status = sanitize_text_field( wp_unslash( $_GET['edit-status'] ?? '' ) );
$edit_url = esc_attr( sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) ) );
// phpcs:enable
if ( $successfully_deleted ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL deleted.', '%d approved directory URLs deleted.', $successfully_deleted, 'woocommerce' ),
$successfully_deleted
)
);
} elseif ( $successfully_enabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL enabled.', '%d approved directory URLs enabled.', $successfully_enabled, 'woocommerce' ),
$successfully_enabled
)
);
} elseif ( $successfully_disabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL disabled.', '%d approved directory URLs disabled.', $successfully_disabled, 'woocommerce' ),
$successfully_disabled
)
);
}
if ( $failed_updates ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %d: count */
_n( '%d URL could not be updated.', '%d URLs could not be updated.', $failed_updates, 'woocommerce' ),
$failed_updates
)
);
}
if ( 'added' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully added.', 'woocommerce' ) );
}
if ( 'updated' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully updated.', 'woocommerce' ) );
}
if ( 'failure' === $edit_status && ! empty( $edit_url ) ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %s is the submitted URL. */
__( '"%s" could not be saved. Please review, ensure it is a valid URL and try again.', 'woocommerce' ),
$edit_url
)
);
}
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['check'] ?? '' ) ), 'modify_approved_directories' ) ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}
ProductDownloads/ApprovedDirectories/Register.php 0000644 00000033654 15233366071 0016332 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\Utilities\URL;
use Automattic\WooCommerce\Internal\Utilities\URLException;
/**
* Maintains and manages the list of approved directories, within which product downloads can
* be stored.
*/
class Register {
/**
* Used to indicate the current mode.
*/
private const MODES = array(
self::MODE_DISABLED,
self::MODE_ENABLED,
);
public const MODE_DISABLED = 'disabled';
public const MODE_ENABLED = 'enabled';
/**
* Name of the option used to store the current mode. See self::MODES for a
* list of acceptable values for the actual option.
*
* @var string
*/
private $mode_option = 'wc_downloads_approved_directories_mode';
/**
* Sets up the approved directories sub-system.
*
* @internal
*/
final public function init() {
add_action(
'admin_init',
function () {
wc_get_container()->get( SyncUI::class )->init_hooks();
wc_get_container()->get( UI::class )->init_hooks();
}
);
add_action(
'before_woocommerce_init',
function() {
if ( get_option( Synchronize::SYNC_TASK_PAGE ) > 0 ) {
wc_get_container()->get( Synchronize::class )->init_hooks();
}
}
);
}
/**
* Supplies the name of the database table used to store approved directories.
*
* @return string
*/
public function get_table(): string {
global $wpdb;
return $wpdb->prefix . 'wc_product_download_directories';
}
/**
* Returns a string indicating the current mode.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @return string
*/
public function get_mode(): string {
$current_mode = get_option( $this->mode_option, self::MODE_DISABLED );
return in_array( $current_mode, self::MODES, true ) ? $current_mode : self::MODE_DISABLED;
}
/**
* Sets the mode. This effectively controls if approved directories are enforced or not.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @param string $mode One of the values contained within self::MODES.
*
* @return bool
*/
public function set_mode( string $mode ): bool {
if ( ! in_array( $mode, self::MODES, true ) ) {
return false;
}
update_option( $this->mode_option, $mode );
return get_option( $this->mode_option ) === $mode;
}
/**
* Adds a new URL path.
*
* On success (or if the URL was already added) returns the URL ID, or else
* returns boolean false.
*
* @throws URLException If the URL was invalid.
* @throws ApprovedDirectoriesException If the operation could not be performed.
*
* @param string $url The URL of the approved directory.
* @param bool $enabled If the rule is enabled.
*
* @return int
*/
public function add_approved_directory( string $url, bool $enabled = true ): int {
$url = $this->prepare_url_for_upsert( $url );
$existing = $this->get_by_url( $url );
if ( $existing ) {
return $existing->get_id();
}
global $wpdb;
$insert_fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false !== $wpdb->insert( $this->get_table(), $insert_fields ) ) {
return $wpdb->insert_id;
}
throw new ApprovedDirectoriesException( __( 'URL could not be added (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
/**
* Updates an existing approved directory.
*
* On success or if there is an existing entry for the same URL, returns true.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param int $id The ID of the approved directory to be updated.
* @param string $url The new URL for the specified option.
* @param bool $enabled If the rule is enabled.
*
* @return bool
*/
public function update_approved_directory( int $id, string $url, bool $enabled = true ): bool {
$url = $this->prepare_url_for_upsert( $url );
$existing_path = $this->get_by_url( $url );
// No need to go any further if the URL is already listed and nothing has changed.
if ( $existing_path && $existing_path->get_url() === $url && $enabled === $existing_path->is_enabled() ) {
return true;
}
global $wpdb;
$fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false === $wpdb->update( $this->get_table(), $fields, array( 'url_id' => $id ) ) ) {
throw new ApprovedDirectoriesException( __( 'URL could not be updated (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
return true;
}
/**
* Indicates if the specified URL is already an approved directory.
*
* @param string $url The URL to check.
*
* @return bool
*/
public function approved_directory_exists( string $url ): bool {
return (bool) $this->get_by_url( $url );
}
/**
* Returns the path identified by $id, or false if it does not exist.
*
* @param int $id The ID of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_id( int $id ) {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url_id = %d", array( $id ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Returns the path identified by $url, or false if it does not exist.
*
* @param string $url The URL of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_url( string $url ) {
global $wpdb;
$table = $this->get_table();
$url = trailingslashit( $url );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url = %s", array( $url ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Indicates if the URL is within an approved directory. The approved directory must be enabled
* (it is possible for individual approved directories to be disabled).
*
* For instance, for 'https://storage.king/12345/ebook.pdf' to be valid then 'https://storage.king/12345'
* would need to be within our register.
*
* If the provided URL is a filepath it can be passed in without the 'file://' scheme.
*
* @throws URLException If the provided URL is badly formed.
*
* @param string $download_url The URL to check.
*
* @return bool
*/
public function is_valid_path( string $download_url ): bool {
global $wpdb;
$parent_directories = array();
foreach ( ( new URL( $this->normalize_url( $download_url ) ) )->get_all_parent_urls() as $parent ) {
$parent_directories[] = "'" . esc_sql( $parent ) . "'";
}
if ( empty( $parent_directories ) ) {
return false;
}
$parent_directories = join( ',', $parent_directories );
$table = $this->get_table();
// Look for a rule that matches the start of the download URL being tested. Since rules describe parent
// directories, we also ensure it ends with a trailing slash.
//
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$matches = (int) $wpdb->get_var(
"
SELECT COUNT(*)
FROM {$table}
WHERE enabled = 1
AND url IN ( {$parent_directories} )
"
);
// phpcs:enable
return $matches > 0;
}
/**
* Used when a URL string is prepared before potentially adding it to the database.
*
* It will be normalized and trailing-slashed; a length check will also be performed.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param string $url The string URL to be normalized and trailing-slashed.
*
* @return string
*/
private function prepare_url_for_upsert( string $url ): string {
$url = trailingslashit( $this->normalize_url( $url ) );
if ( mb_strlen( $url ) > 256 ) {
throw new ApprovedDirectoriesException( __( 'Approved directory URLs cannot be longer than 256 characters.', 'woocommerce' ), ApprovedDirectoriesException::INVALID_URL );
}
return $url;
}
/**
* Normalizes the provided URL, by trimming whitespace per normal PHP conventions
* and removing any trailing slashes. If it lacks a scheme, the file scheme is
* assumed and prepended.
*
* @throws URLException If the URL is badly formed.
*
* @param string $url The URL to be normalized.
*
* @return string
*/
private function normalize_url( string $url ): string {
$url = untrailingslashit( trim( $url ) );
return ( new URL( $url ) )->get_url();
}
/**
* Lists currently approved directories.
*
* Returned array will have the following structure:
*
* [
* 'total_urls' => 12345,
* 'total_pages' => 123,
* 'urls' => [], # StoredUrl[]
* ]
*
* @param array $args {
* Controls pagination and ordering.
*
* @type null|bool $enabled Controls if only enabled (true), disabled (false) or all rules (null) should be listed.
* @type string $order Ordering ('ASC' for ascending, 'DESC' for descending).
* @type string $order_by Field to order by (one of 'url_id' or 'url').
* @type int $page The page of results to retrieve.
* @type int $per_page The number of results to retrieve per page.
* @type string $search Term to search for.
* }
*
* @return array
*/
public function list( array $args ): array {
global $wpdb;
$args = array_merge(
array(
'enabled' => null,
'order' => 'ASC',
'order_by' => 'url',
'page' => 1,
'per_page' => 20,
'search' => '',
),
$args
);
$table = $this->get_table();
$paths = array();
$order = in_array( $args['order'], array( 'ASC', 'DESC' ), true ) ? $args['order'] : 'ASC';
$order_by = in_array( $args['order_by'], array( 'url_id', 'url' ), true ) ? $args['order_by'] : 'url';
$page = absint( $args['page'] );
$per_page = absint( $args['per_page'] );
$enabled = is_bool( $args['enabled'] ) ? $args['enabled'] : null;
$search = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%';
if ( $page < 1 ) {
$page = 1;
}
if ( $per_page < 1 ) {
$per_page = 1;
}
$where = array();
$where_sql = '';
if ( ! empty( $search ) ) {
$where[] = $wpdb->prepare( 'url LIKE %s', $search );
}
if ( is_bool( $enabled ) ) {
$where[] = 'enabled = ' . (int) $enabled;
}
if ( ! empty( $where ) ) {
$where_sql = 'WHERE ' . join( ' AND ', $where );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"
SELECT url_id, url, enabled
FROM {$table}
{$where_sql}
ORDER BY {$order_by} {$order}
LIMIT %d, %d
",
( $page - 1 ) * $per_page,
$per_page
)
);
$total_rows = (int) $wpdb->get_var( "SELECT COUNT( * ) FROM {$table} {$where_sql}" );
// phpcs:enable
foreach ( $results as $single_result ) {
$paths[] = new StoredUrl( $single_result->url_id, $single_result->url, $single_result->enabled );
}
return array(
'total_urls' => $total_rows,
'total_pages' => (int) ceil( $total_rows / $per_page ),
'approved_directories' => $paths,
);
}
/**
* Delete the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function delete_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->delete( $table, array( 'url_id' => $id ) );
}
/**
* Delete the entirev approved directory list.
*
* @return bool
*/
public function delete_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "DELETE FROM $table" );
}
/**
* Enable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function enable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 1 ), array( 'url_id' => $id ) );
}
/**
* Disable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function disable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 0 ), array( 'url_id' => $id ) );
}
/**
* Enables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function enable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 1" );
}
/**
* Disables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function disable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 0" );
}
/**
* Indicates the number of approved directories that are enabled (or disabled, if optional
* param $enabled is set to false).
*
* @param bool $enabled Controls whether enabled or disabled directory rules are counted.
*
* @return int
*/
public function count( bool $enabled = true ): int {
global $wpdb;
$table = $this->get_table();
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$table} WHERE enabled = %d",
$enabled ? 1 : 0
)
);
}
}
ProductDownloads/ApprovedDirectories/Synchronize.php 0000644 00000020374 15233366071 0017054 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
use Automattic\WooCommerce\Internal\Utilities\URL;
use WC_Admin_Notices;
use WC_Product;
use WC_Queue_Interface;
/**
* Ensures that any downloadable files have a corresponding entry in the Approved Product
* Download Directories list.
*/
class Synchronize {
/**
* Scheduled action hook used to facilitate scanning the product catalog for downloadable products.
*/
public const SYNC_TASK = 'woocommerce_download_dir_sync';
/**
* The group under which synchronization tasks run (our standard 'woocommerce-db-updates' group).
*/
public const SYNC_TASK_GROUP = 'woocommerce-db-updates';
/**
* Used to track progress throughout the sync process.
*/
public const SYNC_TASK_PAGE = 'wc_product_download_dir_sync_page';
/**
* Used to record an estimation of progress on the current synchronization process. 0 means 0%,
* 100 means 100%.
*
* @param int
*/
public const SYNC_TASK_PROGRESS = 'wc_product_download_dir_sync_progress';
/**
* Number of downloadable products to be processed in each atomic sync task.
*/
public const SYNC_TASK_BATCH_SIZE = 20;
/**
* WC Queue.
*
* @var WC_Queue_Interface
*/
private $queue;
/**
* Register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up our checks and controls for downloadable asset URLs, as appropriate for
* the current approved download directory mode.
*
* @internal
* @throws Exception If the WC_Queue instance cannot be obtained.
*
* @param Register $register The active approved download directories instance in use.
*/
final public function init( Register $register ) {
$this->queue = WC()->get_instance_of( WC_Queue_Interface::class );
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system.
*/
final public function init_hooks() {
add_action( self::SYNC_TASK, array( $this, 'run' ) );
}
/**
* Initializes the Approved Download Directories feature, typically following an update or
* during initial installation.
*
* @param bool $synchronize Synchronize with existing product downloads. Not needed in a fresh installation.
* @param bool $enable_feature Enable (default) or disable the feature.
*/
public function init_feature( bool $synchronize = true, bool $enable_feature = true ) {
try {
$this->add_default_directories();
if ( $synchronize ) {
$this->start();
}
} catch ( Exception $e ) {
wc_get_logger()->log( 'warning', __( 'It was not possible to synchronize download directories following the most recent update.', 'woocommerce' ) );
}
$this->register->set_mode(
$enable_feature ? Register::MODE_ENABLED : Register::MODE_DISABLED
);
}
/**
* By default we add the woocommerce_uploads directory (file path plus web URL) to the list
* of approved download directories.
*
* @throws Exception If the default directories cannot be added to the Approved List.
*/
public function add_default_directories() {
$upload_dir = wp_get_upload_dir();
$this->register->add_approved_directory( $upload_dir['basedir'] . '/woocommerce_uploads' );
$this->register->add_approved_directory( $upload_dir['baseurl'] . '/woocommerce_uploads' );
}
/**
* Starts the synchronization process.
*
* @return bool
*/
public function start(): bool {
if ( null !== $this->queue->get_next( self::SYNC_TASK ) ) {
wc_get_logger()->log( 'warning', __( 'Synchronization of approved product download directories is already in progress.', 'woocommerce' ) );
return false;
}
update_option( self::SYNC_TASK_PAGE, 1 );
$this->queue->schedule_single( time(), self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: new scan scheduled.', 'woocommerce' ) );
return true;
}
/**
* Runs the syncronization task.
*/
public function run() {
$products = $this->get_next_set_of_downloadable_products();
foreach ( $products as $product ) {
$this->process_product( $product );
}
// Detect if we have reached the end of the task.
if ( count( $products ) < self::SYNC_TASK_BATCH_SIZE ) {
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan is complete!', 'woocommerce' ) );
$this->stop();
} else {
wc_get_logger()->log(
'info',
sprintf(
/* translators: %1$d is the current batch in the synchronization task, %2$d is the percent complete. */
__( 'Approved Download Directories sync: completed batch %1$d (%2$d%% complete).', 'woocommerce' ),
(int) get_option( self::SYNC_TASK_PAGE, 2 ) - 1,
$this->get_progress()
)
);
$this->queue->schedule_single( time() + 1, self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
}
}
/**
* Stops/cancels the current synchronization task.
*/
public function stop() {
WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
delete_option( self::SYNC_TASK_PAGE );
delete_option( self::SYNC_TASK_PROGRESS );
$this->queue->cancel( self::SYNC_TASK );
}
/**
* Queries for the next batch of downloadable products, applying logic to ensure we only fetch those that actually
* have downloadable files (a downloadable product can be created that does not have downloadable files and/or
* downloadable files can be removed from existing downloadable products).
*
* @return array
*/
private function get_next_set_of_downloadable_products(): array {
$query_filter = function ( array $query ): array {
$query['meta_query'][] = array(
'key' => '_downloadable_files',
'compare' => 'EXISTS',
);
return $query;
};
$page = (int) get_option( self::SYNC_TASK_PAGE, 1 );
add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$products = wc_get_products(
array(
'limit' => self::SYNC_TASK_BATCH_SIZE,
'page' => $page,
'paginate' => true,
)
);
remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$progress = $products->max_num_pages > 0 ? (int) ( ( $page / $products->max_num_pages ) * 100 ) : 1;
update_option( self::SYNC_TASK_PAGE, $page + 1 );
update_option( self::SYNC_TASK_PROGRESS, $progress );
return $products->products;
}
/**
* Processes an individual downloadable product, adding the parent paths for any downloadable files to the
* Approved Download Directories list.
*
* Any such paths will be added with the disabled flag set, because we want a site administrator to review
* and approve first.
*
* @param WC_Product $product The product we wish to examine for downloadable file paths.
*/
private function process_product( WC_Product $product ) {
$downloads = $product->get_downloads();
foreach ( $downloads as $downloadable ) {
$parent_url = _x( 'invalid URL', 'Approved product download URLs migration', 'woocommerce' );
try {
$download_file = $downloadable->get_file();
/**
* Controls whether shortcodes should be resolved and validated using the Approved Download Directory feature.
*
* @param bool $should_validate
*/
if ( apply_filters( 'woocommerce_product_downloads_approved_directory_validation_for_shortcodes', true ) && 'shortcode' === $downloadable->get_type_of_file_path() ) {
$download_file = do_shortcode( $download_file );
}
$parent_url = ( new URL( $download_file ) )->get_parent_url();
$this->register->add_approved_directory( $parent_url, false );
} catch ( Exception $e ) {
wc_get_logger()->log(
'error',
sprintf(
/* translators: %s is a URL, %d is a product ID. */
__( 'Product download migration: %1$s (for product %1$d) could not be added to the list of approved download directories.', 'woocommerce' ),
$parent_url,
$product->get_id()
)
);
}
}
}
/**
* Indicates if a synchronization of product download directories is in progress.
*
* @return bool
*/
public function in_progress(): bool {
return (bool) get_option( self::SYNC_TASK_PAGE, false );
}
/**
* Returns a value between 0 and 100 representing the percentage complete of the current sync.
*
* @return int
*/
public function get_progress(): int {
return min( 100, max( 0, (int) get_option( self::SYNC_TASK_PROGRESS, 0 ) ) );
}
}
ProductDownloads/ApprovedDirectories/StoredUrl.php 0000644 00000002427 15233366071 0016463 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
/**
* Representation of an approved directory URL, bundling the ID and URL in a single entity.
*/
class StoredUrl {
/**
* The approved directory ID.
*
* @var int
*/
private $id;
/**
* The approved directory URL.
*
* @var string
*/
private $url;
/**
* If the individual rule is enabled or disabled.
*
* @var bool
*/
private $enabled;
/**
* Sets up the approved directory rule.
*
* @param int $id The approved directory ID.
* @param string $url The approved directory URL.
* @param bool $enabled Indicates if the approved directory rule is enabled.
*/
public function __construct( int $id, string $url, bool $enabled ) {
$this->id = $id;
$this->url = $url;
$this->enabled = $enabled;
}
/**
* Supplies the ID of the approved directory.
*
* @return int
*/
public function get_id(): int {
return $this->id;
}
/**
* Supplies the approved directory URL.
*
* @return string
*/
public function get_url(): string {
return $this->url;
}
/**
* Indicates if this rule is enabled or not (rules can be temporarily disabled).
*
* @return bool
*/
public function is_enabled(): bool {
return $this->enabled;
}
}
ProductDownloads/ApprovedDirectories/ApprovedDirectoriesException.php 0000644 00000000523 15233366071 0022367 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
/**
* Encapsulates a problem encountered while an operation relating to approved directories
* was performed.
*/
class ApprovedDirectoriesException extends Exception {
public const INVALID_URL = 1;
public const DB_ERROR = 2;
}
RestApiUtil.php 0000644 00000013304 15233366071 0007471 0 ustar 00 <?php
/**
* ApiUtil class file.
*/
namespace Automattic\WooCommerce\Internal;
/**
* Helper methos for the REST API.
*
* Class ApiUtil
*
* @package Automattic\WooCommerce\Internal
*/
class RestApiUtil {
/**
* Converts a create refund request from the public API format:
*
* [
* "reason" => "",
* "api_refund" => "x",
* "api_restock" => "x",
* "line_items" => [
* "id" => "111",
* "quantity" => 222,
* "refund_total" => 333,
* "refund_tax" => [
* [
* "id": "444",
* "refund_total": 555
* ],...
* ],...
* ]
*
* ...to the internally used format:
*
* [
* "reason" => null, (if it's missing or any empty value, set as null)
* "api_refund" => true, (if it's missing or non-bool, set as "true")
* "api_restock" => true, (if it's missing or non-bool, set as "true")
* "line_items" => [ (convert sequential array to associative based on "id")
* "111" => [
* "qty" => 222, (rename "quantity" to "qty")
* "refund_total" => 333,
* "refund_tax" => [ (convert sequential array to associative based on "id" and "refund_total)
* "444" => 555,...
* ],...
* ]
* ]
*
* It also calculates the amount if missing and whenever possible, see maybe_calculate_refund_amount_from_line_items.
*
* The conversion is done in a way that if the request is already in the internal format,
* then nothing is changed for compatibility. For example, if the line items array
* is already an associative array or any of its elements
* is missing the "id" key, then the entire array is left unchanged.
* Same for the "refund_tax" array inside each line item.
*
* @param \WP_REST_Request $request The request to adjust.
*/
public static function adjust_create_refund_request_parameters( \WP_REST_Request &$request ) {
if ( empty( $request['reason'] ) ) {
$request['reason'] = null;
}
if ( ! is_bool( $request['api_refund'] ) ) {
$request['api_refund'] = true;
}
if ( ! is_bool( $request['api_restock'] ) ) {
$request['api_restock'] = true;
}
if ( empty( $request['line_items'] ) ) {
$request['line_items'] = array();
} else {
$request['line_items'] = self::adjust_line_items_for_create_refund_request( $request['line_items'] );
}
if ( ! isset( $request['amount'] ) ) {
$amount = self::calculate_refund_amount_from_line_items( $request );
if ( null !== $amount ) {
$request['amount'] = strval( $amount );
}
}
}
/**
* Calculate the "amount" parameter for the request based on the amounts found in line items.
* This will ONLY be possible if ALL of the following is true:
*
* - "line_items" in the request is a non-empty array.
* - All line items have a "refund_total" field with a numeric value.
* - All values inside "refund_tax" in all line items are a numeric value.
*
* The request is assumed to be in internal format already.
*
* @param \WP_REST_Request $request The request to maybe calculate the total amount for.
* @return number|null The calculated amount, or null if it can't be calculated.
*/
private static function calculate_refund_amount_from_line_items( $request ) {
$line_items = $request['line_items'];
if ( ! is_array( $line_items ) || empty( $line_items ) ) {
return null;
}
$amount = 0;
foreach ( $line_items as $item ) {
if ( ! isset( $item['refund_total'] ) || ! is_numeric( $item['refund_total'] ) ) {
return null;
}
$amount += $item['refund_total'];
if ( ! isset( $item['refund_tax'] ) ) {
continue;
}
foreach ( $item['refund_tax'] as $tax ) {
if ( ! is_numeric( $tax ) ) {
return null;
}
$amount += $tax;
}
}
return $amount;
}
/**
* Convert the line items of a refund request to internal format (see adjust_create_refund_request_parameters).
*
* @param array $line_items The line items to convert.
* @return array The converted line items.
*/
private static function adjust_line_items_for_create_refund_request( $line_items ) {
if ( ! is_array( $line_items ) || empty( $line_items ) || self::is_associative( $line_items ) ) {
return $line_items;
}
$new_array = array();
foreach ( $line_items as $item ) {
if ( ! isset( $item['id'] ) ) {
return $line_items;
}
if ( isset( $item['quantity'] ) && ! isset( $item['qty'] ) ) {
$item['qty'] = $item['quantity'];
}
unset( $item['quantity'] );
if ( isset( $item['refund_tax'] ) ) {
$item['refund_tax'] = self::adjust_taxes_for_create_refund_request_line_item( $item['refund_tax'] );
}
$id = $item['id'];
$new_array[ $id ] = $item;
unset( $new_array[ $id ]['id'] );
}
return $new_array;
}
/**
* Adjust the taxes array from a line item in a refund request, see adjust_create_refund_parameters.
*
* @param array $taxes_array The array to adjust.
* @return array The adjusted array.
*/
private static function adjust_taxes_for_create_refund_request_line_item( $taxes_array ) {
if ( ! is_array( $taxes_array ) || empty( $taxes_array ) || self::is_associative( $taxes_array ) ) {
return $taxes_array;
}
$new_array = array();
foreach ( $taxes_array as $item ) {
if ( ! isset( $item['id'] ) || ! isset( $item['refund_total'] ) ) {
return $taxes_array;
}
$id = $item['id'];
$refund_total = $item['refund_total'];
$new_array[ $id ] = $refund_total;
}
return $new_array;
}
/**
* Is an array sequential or associative?
*
* @param array $array The array to check.
* @return bool True if the array is associative, false if it's sequential.
*/
private static function is_associative( array $array ) {
return array_keys( $array ) !== range( 0, count( $array ) - 1 );
}
}
DataStores/Orders/OrdersTableDataStoreMeta.php 0000644 00000001336 15233366071 0015421 0 ustar 00 <?php
/**
* OrdersTableDataStoreMeta class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
/**
* Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
*/
class OrdersTableDataStoreMeta extends CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
protected function get_table_name() {
return OrdersTableDataStore::get_meta_table_name();
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'order_id';
}
}
DataStores/Orders/CustomOrdersTableController.php 0000644 00000052624 15233366071 0016250 0 ustar 00 <?php
/**
* CustomOrdersTableController class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
defined( 'ABSPATH' ) || exit;
/**
* This is the main class that controls the custom orders tables feature. Its responsibilities are:
*
* - Allowing to enable and disable the feature while it's in development (show_feature method)
* - Displaying UI components (entries in the tools page and in settings)
* - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
*
* ...and in general, any functionality that doesn't imply database access.
*/
class CustomOrdersTableController {
use AccessiblePrivateMethods;
/**
* The name of the option for enabling the usage of the custom orders tables
*/
public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';
/**
* The name of the option that tells that the authoritative table must be flipped once sync finishes.
*/
private const AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION = 'woocommerce_auto_flip_authoritative_table_roles';
/**
* The name of the option that tells whether database transactions are to be used or not for data synchronization.
*/
public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';
/**
* The name of the option to store the transaction isolation level to use when database transactions are enabled.
*/
public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';
public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'REPEATABLE READ';
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* Refunds data store object to use.
*
* @var OrdersTableRefundDataStore
*/
private $refund_data_store;
/**
* The data synchronizer object to use.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* The batch processing controller to use.
*
* @var BatchProcessingController
*/
private $batch_processing_controller;
/**
* The features controller to use.
*
* @var FeaturesController
*/
private $features_controller;
/**
* Class constructor.
*/
public function __construct() {
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
self::add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 999, 1 );
self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'get_settings_sections' ), 999, 1 );
self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'get_settings' ), 999, 2 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
self::add_filter( DataSynchronizer::PENDING_SYNCHRONIZATION_FINISHED_ACTION, array( $this, 'process_sync_finished' ), 10, 0 );
self::add_action( 'woocommerce_update_options_advanced_custom_data_stores', array( $this, 'process_options_updated' ), 10, 0 );
self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param OrdersTableDataStore $data_store The data store to use.
* @param DataSynchronizer $data_synchronizer The data synchronizer to use.
* @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
* @param BatchProcessingController $batch_processing_controller The batch processing controller to use.
* @param FeaturesController $features_controller The features controller instance to use.
*/
final public function init(
OrdersTableDataStore $data_store,
DataSynchronizer $data_synchronizer,
OrdersTableRefundDataStore $refund_data_store,
BatchProcessingController $batch_processing_controller,
FeaturesController $features_controller ) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
$this->batch_processing_controller = $batch_processing_controller;
$this->refund_data_store = $refund_data_store;
$this->features_controller = $features_controller;
}
/**
* Checks if the feature is visible (so that dedicated entries will be added to the debug tools page).
*
* @return bool True if the feature is visible.
*/
public function is_feature_visible(): bool {
return $this->features_controller->feature_is_enabled( 'custom_order_tables' );
}
/**
* Makes the feature visible, so that dedicated entries will be added to the debug tools page.
*
* This method shouldn't be used anymore, see the FeaturesController class.
*/
public function show_feature() {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
wc_doing_it_wrong(
$class_and_method,
__( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ),
'7.0'
);
}
/**
* Hides the feature, so that no entries will be added to the debug tools page.
*
* This method shouldn't be used anymore, see the FeaturesController class.
*/
public function hide_feature() {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
wc_doing_it_wrong(
$class_and_method,
__( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ),
'7.0'
);
}
/**
* Is the custom orders table usage enabled via settings?
* This can be true only if the feature is enabled and a table regeneration has been completed.
*
* @return bool True if the custom orders table usage is enabled
*/
public function custom_orders_table_usage_is_enabled(): bool {
return $this->is_feature_visible() && get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
}
/**
* Gets the instance of the orders data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_orders_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order' );
}
/**
* Gets the instance of the refunds data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_refunds_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order_refund' );
}
/**
* Gets the instance of a given data store.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
* @param string $type The type of the data store to get.
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_data_store_instance( $default_data_store, string $type ) {
if ( $this->custom_orders_table_usage_is_enabled() ) {
switch ( $type ) {
case 'order_refund':
return $this->refund_data_store;
default:
return $this->data_store;
}
} else {
return $default_data_store;
}
}
/**
* Add an entry to Status - Tools to create or regenerate the custom orders table,
* and also an entry to delete the table as appropriate.
*
* @param array $tools_array The array of tools to add the tool to.
* @return array The updated array of tools-
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ): array {
if ( ! $this->is_feature_visible() ) {
return $tools_array;
}
if ( $this->data_synchronizer->check_orders_table_exists() ) {
$tools_array['delete_custom_orders_table'] = array(
'name' => __( 'Delete the custom orders tables', 'woocommerce' ),
'desc' => sprintf(
'<strong class="red">%1$s</strong> %2$s',
__( 'Note:', 'woocommerce' ),
__( 'This will delete the custom orders tables. The tables can be deleted only if they are not not in use (via Settings > Advanced > Custom data stores). You can create them again at any time with the "Create the custom orders tables" tool.', 'woocommerce' )
),
'requires_refresh' => true,
'callback' => function () {
$this->delete_custom_orders_tables();
return __( 'Custom orders tables have been deleted.', 'woocommerce' );
},
'button' => __( 'Delete', 'woocommerce' ),
'disabled' => $this->custom_orders_table_usage_is_enabled(),
);
} else {
$tools_array['create_custom_orders_table'] = array(
'name' => __( 'Create the custom orders tables', 'woocommerce' ),
'desc' => __( 'This tool will create the custom orders tables. Once created you can go to WooCommerce > Settings > Advanced > Custom data stores and configure the usage of the tables.', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function() {
$this->create_custom_orders_tables();
return __( 'Custom orders tables have been created. You can now go to WooCommerce > Settings > Advanced > Custom data stores.', 'woocommerce' );
},
'button' => __( 'Create', 'woocommerce' ),
);
}
return $tools_array;
}
/**
* Create the custom orders tables in response to the user pressing the tool button.
*
* @throws \Exception Can't create the tables.
*/
private function create_custom_orders_tables() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) {
throw new \Exception( 'Invalid nonce' );
}
if ( ! $this->is_feature_visible() ) {
throw new \Exception( "Can't create the custom orders tables: the feature isn't enabled" );
}
$this->data_synchronizer->create_database_tables();
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
}
/**
* Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
*
* @throws \Exception Can't delete the tables.
*/
private function delete_custom_orders_tables() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Custom data stores)." );
}
delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
$this->data_synchronizer->delete_database_tables();
}
/**
* Get the settings sections for the "Advanced" tab, with a "Custom data stores" section added if appropriate.
*
* @param array $sections The original settings sections array.
* @return array The updated settings sections array.
*/
private function get_settings_sections( array $sections ): array {
if ( ! $this->is_feature_visible() ) {
return $sections;
}
$sections['custom_data_stores'] = __( 'Custom data stores', 'woocommerce' );
return $sections;
}
/**
* Get the settings for the "Custom data stores" section in the "Advanced" tab,
* with entries for managing the custom orders tables if appropriate.
*
* @param array $settings The original settings array.
* @param string $section_id The settings section to get the settings for.
* @return array The updated settings array.
*/
private function get_settings( array $settings, string $section_id ): array {
if ( ! $this->is_feature_visible() || $section_id !== 'custom_data_stores' ) {
return $settings;
}
if ( $this->data_synchronizer->check_orders_table_exists() ) {
$settings[] = array(
'title' => __( 'Custom orders tables', 'woocommerce' ),
'type' => 'title',
'id' => 'cot-title',
'desc' => sprintf(
/* translators: %1$s = <strong> tag, %2$s = </strong> tag. */
__( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ),
'<strong>',
'</strong>'
),
);
$sync_status = $this->data_synchronizer->get_sync_status();
$sync_is_pending = $sync_status['current_pending_count'] !== 0;
$settings[] = array(
'title' => __( 'Data store for orders', 'woocommerce' ),
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
'default' => 'no',
'type' => 'radio',
'options' => array(
'yes' => __( 'Use the WooCommerce orders tables', 'woocommerce' ),
'no' => __( 'Use the WordPress posts table', 'woocommerce' ),
),
'checkboxgroup' => 'start',
'disabled' => $sync_is_pending ? array( 'yes', 'no' ) : array(),
);
if ( $sync_is_pending ) {
$initial_pending_count = $sync_status['initial_pending_count'];
$current_pending_count = $sync_status['current_pending_count'];
if ( $initial_pending_count ) {
$text =
sprintf(
/* translators: %1$s=current number of orders pending sync, %2$s=initial number of orders pending sync */
_n( 'There\'s %1$s order (out of a total of %2$s) pending sync!', 'There are %1$s orders (out of a total of %2$s) pending sync!', $current_pending_count, 'woocommerce' ),
$current_pending_count,
$initial_pending_count
);
} else {
$text =
/* translators: %s=initial number of orders pending sync */
sprintf( _n( 'There\'s %s order pending sync!', 'There are %s orders pending sync!', $current_pending_count, 'woocommerce' ), $current_pending_count, 'woocommerce' );
}
if ( $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) ) ) {
$text .= __( "<br/>Synchronization for these orders is currently in progress.<br/>The authoritative table can't be changed until sync completes.", 'woocommerce' );
} else {
$text .= __( "<br/>The authoritative table can't be changed until these orders are synchronized.", 'woocommerce' );
}
$settings[] = array(
'type' => 'info',
'id' => 'cot-out-of-sync-warning',
'css' => 'color: #C00000',
'text' => $text,
);
}
$settings[] = array(
'desc' => __( 'Keep the posts table and the orders tables synchronized', 'woocommerce' ),
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
'type' => 'checkbox',
);
if ( $sync_is_pending ) {
if ( $this->data_synchronizer->data_sync_is_enabled() ) {
$message = $this->custom_orders_table_usage_is_enabled() ?
__( 'Switch to using the posts table as the authoritative data store for orders when sync finishes', 'woocommerce' ) :
__( 'Switch to using the orders table as the authoritative data store for orders when sync finishes', 'woocommerce' );
$settings[] = array(
'desc' => $message,
'id' => self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION,
'type' => 'checkbox',
);
}
}
$settings[] = array(
'desc' => __( 'Use database transactions for the orders data synchronization', 'woocommerce' ),
'id' => self::USE_DB_TRANSACTIONS_OPTION,
'type' => 'checkbox',
);
$isolation_level_names = self::get_valid_transaction_isolation_levels();
$settings[] = array(
'desc' => __( 'Database transaction isolation level to use', 'woocommerce' ),
'id' => self::DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION,
'type' => 'select',
'options' => array_combine( $isolation_level_names, $isolation_level_names ),
'default' => self::DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL,
);
} else {
$settings[] = array(
'title' => __( 'Custom orders tables', 'woocommerce' ),
'type' => 'title',
'desc' => sprintf(
/* translators: %1$s = <em> tag, %2$s = </em> tag. */
__( 'Create the tables first by going to %1$sWooCommerce > Status > Tools%2$s and running %1$sCreate the custom orders tables%2$s.', 'woocommerce' ),
'<em>',
'</em>'
),
);
}
$settings[] = array( 'type' => 'sectionend' );
return $settings;
}
/**
* Get the valid database transaction isolation level names.
*
* @return string[]
*/
public static function get_valid_transaction_isolation_levels() {
return array(
'REPEATABLE READ',
'READ COMMITTED',
'READ UNCOMMITTED',
'SERIALIZABLE',
);
}
/**
* Handler for the individual setting updated hook.
*
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
* @param mixed $value New value of the setting.
*/
private function process_updated_option( $option, $old_value, $value ) {
if ( $option === DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION && $value === 'no' ) {
$this->data_synchronizer->cleanup_synchronization_state();
}
}
/**
* Handler for the setting pre-update hook.
* We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
*
* @param mixed $value New value of the setting.
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
*
* @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
*/
private function process_pre_update_option( $value, $option, $old_value ) {
if ( $option !== self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION || $value === $old_value || $old_value === false ) {
return $value;
}
/**
* Commenting out for better testability.
$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
if ( $sync_is_pending ) {
throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
}
*/
return $value;
}
/**
* Handler for the synchronization finished hook.
* Here we switch the authoritative table if needed.
*/
private function process_sync_finished() {
if ( ! $this->auto_flip_authoritative_table_enabled() ) {
return;
}
update_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' );
if ( $this->custom_orders_table_usage_is_enabled() ) {
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
} else {
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
}
}
/**
* Is the automatic authoritative table switch setting set?
*
* @return bool
*/
private function auto_flip_authoritative_table_enabled(): bool {
return get_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ) === 'yes';
}
/**
* Handler for the all settings updated hook.
*/
private function process_options_updated() {
$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();
// Disabling the sync implies disabling the automatic authoritative table switch too.
if ( ! $data_sync_is_enabled && $this->auto_flip_authoritative_table_enabled() ) {
update_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' );
}
// Enabling/disabling the sync implies starting/stopping it too, if needed.
// We do this check here, and not in process_pre_update_option, so that if for some reason
// the setting is enabled but no sync is in process, sync will start by just saving the
// settings even without modifying them (and the opposite: sync will be stopped if for
// some reason it was ongoing while it was disabled).
if ( $data_sync_is_enabled ) {
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
} else {
$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
}
}
/**
* Handler for the woocommerce_after_register_post_type post,
* registers the post type for placeholder orders.
*
* @return void
*/
private function register_post_type_for_order_placeholders(): void {
wc_register_order_type(
DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
array(
'public' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'show_in_admin_bar' => false,
'show_in_rest' => false,
'rewrite' => false,
'query_var' => false,
'can_export' => false,
'supports' => array(),
'capabilities' => array(),
'exclude_from_order_count' => true,
'exclude_from_order_views' => true,
'exclude_from_order_reports' => true,
'exclude_from_order_sales_reports' => true,
)
);
}
}
DataStores/Orders/OrdersTableSearchQuery.php 0000644 00000007234 15233366071 0015162 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Exception;
/**
* Creates the join and where clauses needed to perform an order search using Custom Order Tables.
*
* @internal
*/
class OrdersTableSearchQuery {
/**
* Holds the Orders Table Query object.
*
* @var OrdersTableQuery
*/
private $query;
/**
* Holds the search term to be used in the WHERE clauses.
*
* @var string
*/
private $search_term;
/**
* Creates the JOIN and WHERE clauses needed to execute a search of orders.
*
* @internal
*
* @param OrdersTableQuery $query The order query object.
*/
public function __construct( OrdersTableQuery $query ) {
$this->query = $query;
$this->search_term = "'" . esc_sql( '%' . urldecode( $query->get( 's' ) ) . '%' ) . "'";
}
/**
* Supplies an array of clauses to be used in an order query.
*
* @internal
* @throws Exception If unable to generate either the JOIN or WHERE SQL fragments.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => array( $this->generate_join() ),
'where' => array( $this->generate_where() ),
);
}
/**
* Generates the necessary JOIN clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_join(): string {
$orders_table = $this->query->get_table_name( 'orders' );
$meta_table = $this->query->get_table_name( 'meta' );
$items_table = $this->query->get_table_name( 'items' );
return "
LEFT JOIN $meta_table AS search_query_meta ON search_query_meta.order_id = $orders_table.id
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
}
/**
* Generates the necessary WHERE clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_where(): string {
$where = '';
$meta_fields = $this->get_meta_fields_to_be_searched();
$possible_order_id = (string) absint( $this->query->get( 's' ) );
// Support the passing of an order ID as the search term.
if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
$where = $this->query->get_table_name( 'orders' ) . '.id = ' . $possible_order_id . ' OR ';
}
$where .= "
(
search_query_meta.meta_key IN ( $meta_fields )
AND search_query_meta.meta_value LIKE $this->search_term
)
OR search_query_items.order_item_name LIKE $this->search_term
";
return " ( $where ) ";
}
/**
* Returns the order meta field keys to be searched.
*
* These will be returned as a single string, where the meta keys have been escaped, quoted and are
* comma-separated (ie, "'abc', 'foo'" - ready for inclusion in a SQL IN() clause).
*
* @return string
*/
private function get_meta_fields_to_be_searched(): string {
/**
* Controls the order meta keys to be included in search queries.
*
* This hook is used when Custom Order Tables are in use: the corresponding hook when CPT-orders are in use
* is 'woocommerce_shop_order_search_fields'.
*
* @since 7.0.0
*
* @param array
*/
$meta_keys = apply_filters(
'woocommerce_order_table_search_query_meta_keys',
array(
'_billing_address_index',
'_shipping_address_index',
)
);
$meta_keys = (array) array_map(
function ( string $meta_key ): string {
return "'" . esc_sql( wc_clean( $meta_key ) ) . "'";
},
$meta_keys
);
return implode( ',', $meta_keys );
}
}
DataStores/Orders/OrdersTableQuery.php 0000644 00000102127 15233366071 0014031 0 ustar 00 <?php
// phpcs:disable Generic.Commenting.Todo.TaskFound
/**
* OrdersTableQuery class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class provides a `WP_Query`-like interface to custom order tables.
*
* @property-read int $found_orders Number of found orders.
* @property-read int $found_posts Alias of the `$found_orders` property.
* @property-read int $max_num_pages Max number of pages matching the current query.
* @property-read array $orders Order objects, or order IDs.
* @property-read array $posts Alias of the $orders property.
*/
class OrdersTableQuery {
/**
* Values to ignore when parsing query arguments.
*/
public const SKIPPED_VALUES = array( '', array(), null );
/**
* Regex used to catch "shorthand" comparisons in date-related query args.
*/
public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';
/**
* Highest possible unsigned bigint value (unsigned bigints being the type of the `id` column).
*
* This is deliberately held as a string, rather than a numeric type, for inclusion within queries.
*/
private const MYSQL_MAX_UNSIGNED_BIGINT = '18446744073709551615';
/**
* Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
*
* @var array
*/
private $tables = array();
/**
* Column mappings for all COT tables.
*
* @var array
*/
private $mappings = array();
/**
* Query vars after processing and sanitization.
*
* @var array
*/
private $args = array();
/**
* Columns to be selected in the SELECT clause.
*
* @var array
*/
private $fields = array();
/**
* Array of table aliases and conditions used to compute the JOIN clause of the query.
*
* @var array
*/
private $join = array();
/**
* Array of fields and conditions used to compute the WHERE clause of the query.
*
* @var array
*/
private $where = array();
/**
* Field to be used in the GROUP BY clause of the query.
*
* @var array
*/
private $groupby = array();
/**
* Array of fields used to compute the ORDER BY clause of the query.
*
* @var array
*/
private $orderby = array();
/**
* Limits used to compute the LIMIT clause of the query.
*
* @var array
*/
private $limits = array();
/**
* Results (order IDs) for the current query.
*
* @var array
*/
private $results = array();
/**
* Final SQL query to run after processing of args.
*
* @var string
*/
private $sql = '';
/**
* The number of pages (when pagination is enabled).
*
* @var int
*/
private $max_num_pages = 0;
/**
* The number of orders found.
*
* @var int
*/
private $found_orders = 0;
/**
* Field query parser.
*
* @var OrdersTableFieldQuery
*/
private $field_query = null;
/**
* Meta query parser.
*
* @var OrdersTableMetaQuery
*/
private $meta_query = null;
/**
* Search query parser.
*
* @var OrdersTableSearchQuery?
*/
private $search_query = null;
/**
* Date query parser.
*
* @var WP_Date_Query
*/
private $date_query = null;
/**
* Sets up and runs the query after processing arguments.
*
* @param array $args Array of query vars.
*/
public function __construct( $args = array() ) {
global $wpdb;
$datastore = wc_get_container()->get( OrdersTableDataStore::class );
// TODO: maybe OrdersTableDataStore::get_all_table_names() could return these keys/indices instead.
$this->tables = array(
'orders' => $datastore::get_orders_table_name(),
'addresses' => $datastore::get_addresses_table_name(),
'operational_data' => $datastore::get_operational_data_table_name(),
'meta' => $datastore::get_meta_table_name(),
'items' => $wpdb->prefix . 'woocommerce_order_items',
);
$this->mappings = $datastore->get_all_order_column_mappings();
$this->args = $args;
// TODO: args to be implemented.
unset( $this->args['customer_note'], $this->args['name'] );
$this->build_query();
$this->run_query();
}
/**
* Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
*
* @return void
*/
private function maybe_remap_args(): void {
$mapping = array(
// WP_Query legacy.
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
'post_status' => 'status',
'_date_completed' => 'date_completed_gmt',
'_date_paid' => 'date_paid_gmt',
'paged' => 'page',
'post_parent' => 'parent_order_id',
'post_parent__in' => 'parent_order_id',
'post_parent__not_in' => 'parent_exclude',
'post__not_in' => 'exclude',
'posts_per_page' => 'limit',
'p' => 'id',
'post__in' => 'id',
'post_type' => 'type',
'fields' => 'return',
'customer_user' => 'customer_id',
'order_currency' => 'currency',
'order_version' => 'woocommerce_version',
'cart_discount' => 'discount_total_amount',
'cart_discount_tax' => 'discount_tax_amount',
'order_shipping' => 'shipping_total_amount',
'order_shipping_tax' => 'shipping_tax_amount',
'order_tax' => 'tax_amount',
// Translate from WC_Order_Query to table structure.
'version' => 'woocommerce_version',
'date_created' => 'date_created_gmt',
'date_modified' => 'date_updated_gmt',
'date_modified_gmt' => 'date_updated_gmt',
'date_completed' => 'date_completed_gmt',
'date_completed_gmt' => 'date_completed_gmt',
'date_paid' => 'date_paid_gmt',
'discount_total' => 'discount_total_amount',
'discount_tax' => 'discount_tax_amount',
'shipping_total' => 'shipping_total_amount',
'shipping_tax' => 'shipping_tax_amount',
'cart_tax' => 'tax_amount',
'total' => 'total_amount',
'customer_ip_address' => 'ip_address',
'customer_user_agent' => 'user_agent',
'parent' => 'parent_order_id',
);
foreach ( $mapping as $query_key => $table_field ) {
if ( isset( $this->args[ $query_key ] ) ) {
$this->args[ $table_field ] = $this->args[ $query_key ];
unset( $this->args[ $query_key ] );
}
}
// meta_query.
$this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$shortcut_meta_query = array();
foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
if ( $this->arg_isset( "meta_{$key}" ) ) {
$shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
}
}
if ( ! empty( $shortcut_meta_query ) ) {
if ( ! empty( $this->args['meta_query'] ) ) {
$this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'AND',
$shortcut_meta_query,
$this->args['meta_query'],
);
} else {
$this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
}
}
/**
* Generates a `WP_Date_Query` compatible query from a given date.
* YYYY-MM-DD queries have 'day' precision for backwards compatibility.
*
* @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
private function date_to_date_query_arg( $date ): array {
$result = array(
'year' => '',
'month' => '',
'day' => '',
);
$precision = 'second';
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// YYYY-MM-DD queries have 'day' precision for backwards compat.
$date = wc_string_to_datetime( $date );
$precision = 'day';
}
$result['year'] = $date->date( 'Y' );
$result['month'] = $date->date( 'm' );
$result['day'] = $date->date( 'd' );
if ( 'second' === $precision ) {
$result['hour'] = $date->date( 'H' );
$result['minute'] = $date->date( 'i' );
$result['second'] = $date->date( 's' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
* @return void
* @throws \Exception When date args are invalid.
*/
private function process_date_args(): void {
$valid_operators = array( '>', '>=', '=', '<=', '<', '...' );
$date_queries = array();
$gmt_date_keys = array(
'date_created_gmt',
'date_updated_gmt',
'date_paid_gmt',
'date_completed_gmt',
);
foreach ( array_filter( $gmt_date_keys, array( $this, 'arg_isset' ) ) as $date_key ) {
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates = array();
if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
if ( ! empty( $matches[1] ) ) {
$dates[] = $this->date_to_date_query_arg( $matches[1] );
}
$dates[] = $this->date_to_date_query_arg( $matches[3] );
} else {
$dates[] = $this->date_to_date_query_arg( $date_value );
}
if ( empty( $dates ) || ! $operator || ( '...' === $operator && count( $dates ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
$operator_to_keys[] = 'after';
}
if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
$operator_to_keys[] = 'before';
}
$date_queries[] = array_merge(
array(
'column' => $date_key,
'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
),
'=' === $operator
? end( $dates )
: array_combine( $operator_to_keys, $dates )
);
}
// Add top-level date parameters to the date_query.
$tl_query = array();
foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
if ( $this->arg_isset( $tl_key ) ) {
$tl_query[ $tl_key ] = $this->args[ $tl_key ];
unset( $this->args[ $tl_key ] );
}
}
if ( $tl_query ) {
$tl_query['column'] = 'date_created_gmt';
$date_queries[] = $tl_query;
}
if ( $date_queries ) {
if ( ! $this->arg_isset( 'date_query' ) ) {
$this->args['date_query'] = array();
}
$this->args['date_query'] = array_merge(
array( 'relation' => 'AND' ),
$date_queries,
$this->args['date_query']
);
}
$this->process_date_query_columns();
}
/**
* Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
*
* @return void
*/
private function process_date_query_columns() {
global $wpdb;
$legacy_columns = array(
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
);
$table_mapping = array(
'date_created_gmt' => $this->tables['orders'],
'date_updated_gmt' => $this->tables['orders'],
'date_paid_gmt' => $this->tables['operational_data'],
'date_completed_gmt' => $this->tables['operational_data'],
);
if ( empty( $this->args['date_query'] ) ) {
return;
}
array_walk_recursive(
$this->args['date_query'],
function( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
if ( 'column' !== $key ) {
return;
}
// Translate legacy columns from wp_posts if necessary.
$value =
( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
? $legacy_columns[ $value ]
: $value;
$table = $table_mapping[ $value ] ?? null;
if ( ! $table ) {
return;
}
$value = "{$table}.{$value}";
if ( $table !== $this->tables['orders'] ) {
$this->join( $table, '', '', 'inner', true );
}
}
);
}
/**
* Sanitizes the 'status' query var.
*
* @return void
*/
private function sanitize_status(): void {
// Sanitize status.
$valid_statuses = array_keys( wc_get_order_statuses() );
if ( empty( $this->args['status'] ) || 'any' === $this->args['status'] ) {
$this->args['status'] = $valid_statuses;
} elseif ( 'all' === $this->args['status'] ) {
$this->args['status'] = array();
} else {
$this->args['status'] = is_array( $this->args['status'] ) ? $this->args['status'] : array( $this->args['status'] );
foreach ( $this->args['status'] as &$status ) {
$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
}
$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
}
}
/**
* Parses and sanitizes the 'orderby' query var.
*
* @return void
*/
private function sanitize_order_orderby(): void {
// Allowed keys.
// TODO: rand, meta keys, etc.
$allowed_keys = array( 'ID', 'id', 'type', 'date', 'modified', 'parent' );
// Translate $orderby to a valid field.
$mapping = array(
'ID' => "{$this->tables['orders']}.id",
'id' => "{$this->tables['orders']}.id",
'type' => "{$this->tables['orders']}.type",
'date' => "{$this->tables['orders']}.date_created_gmt",
'date_created' => "{$this->tables['orders']}.date_created_gmt",
'modified' => "{$this->tables['orders']}.date_updated_gmt",
'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
'parent' => "{$this->tables['orders']}.parent_order_id",
'total' => "{$this->tables['orders']}.total_amount",
'order_total' => "{$this->tables['orders']}.total_amount",
);
$order = $this->args['order'] ?? '';
$orderby = $this->args['orderby'] ?? '';
if ( 'none' === $orderby ) {
return;
}
if ( is_string( $orderby ) ) {
$orderby = array( $orderby => $order );
}
$this->args['orderby'] = array();
foreach ( $orderby as $order_key => $order ) {
if ( isset( $mapping[ $order_key ] ) ) {
$this->args['orderby'][ $mapping[ $order_key ] ] = $this->sanitize_order( $order );
}
}
}
/**
* Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
*
* @param string $order The unsanitized order.
* @return string The sanitized order.
*/
private function sanitize_order( string $order ): string {
$order = strtoupper( $order );
return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
}
/**
* Builds the final SQL query to be run.
*
* @return void
*/
private function build_query(): void {
$this->maybe_remap_args();
// Field queries.
if ( ! empty( $this->args['field_query'] ) ) {
$this->field_query = new OrdersTableFieldQuery( $this );
$sql = $this->field_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Build query.
$this->process_date_args();
$this->process_orders_table_query_args();
$this->process_operational_data_table_query_args();
$this->process_addresses_table_query_args();
// Search queries.
if ( ! empty( $this->args['s'] ) ) {
$this->search_query = new OrdersTableSearchQuery( $this );
$sql = $this->search_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Meta queries.
if ( ! empty( $this->args['meta_query'] ) ) {
$this->meta_query = new OrdersTableMetaQuery( $this );
$sql = $this->meta_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;
if ( $sql['join'] ) {
$this->groupby[] = "{$this->tables['orders']}.id";
}
}
// Date queries.
if ( ! empty( $this->args['date_query'] ) ) {
$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
$this->where[] = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
}
$this->process_orderby();
$this->process_limit();
$orders_table = $this->tables['orders'];
// SELECT [fields].
$this->fields = "{$orders_table}.id";
$fields = $this->fields;
// SQL_CALC_FOUND_ROWS.
if ( ( ! $this->arg_isset( 'no_found_rows' ) || ! $this->args['no_found_rows'] ) && $this->limits ) {
$found_rows = 'SQL_CALC_FOUND_ROWS';
} else {
$found_rows = '';
}
// JOIN.
$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );
// WHERE.
$where = '1=1';
foreach ( $this->where as $_where ) {
$where .= " AND ({$_where})";
}
// ORDER BY.
$orderby = $this->orderby ? ( 'ORDER BY ' . implode( ', ', $this->orderby ) ) : '';
// LIMITS.
$limits = '';
if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
list( $offset, $row_count ) = $this->limits;
$row_count = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
$limits = 'LIMIT ' . (int) $offset . ', ' . $row_count;
}
// GROUP BY.
$groupby = $this->groupby ? 'GROUP BY ' . implode( ', ', (array) $this->groupby ) : '';
$this->sql = "SELECT $found_rows DISTINCT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
}
/**
* Returns the table alias for a given table mapping.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string Table alias.
*
* @since 7.0.0
*/
public function get_core_mapping_alias( string $mapping_id ): string {
return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
? $mapping_id
: $this->tables[ $mapping_id ];
}
/**
* Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string The JOIN clause.
*
* @since 7.0.0
*/
public function get_core_mapping_join( string $mapping_id ): string {
global $wpdb;
if ( 'orders' === $mapping_id ) {
return '';
}
$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );
$alias = $this->get_core_mapping_alias( $mapping_id );
$table = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
$join = '';
$join_on = '';
$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );
if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
if ( $is_address_mapping ) {
$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
}
/**
* JOINs the main orders table with another table.
*
* @param string $table Table name (including prefix).
* @param string $alias Table alias to use. Defaults to $table.
* @param string $on ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
* @param string $join_type JOIN type: LEFT, RIGHT or INNER.
* @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
* @return void
* @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
*/
private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
$alias = empty( $alias ) ? $table : $alias;
$join_type = strtoupper( trim( $join_type ) );
if ( $this->tables['orders'] === $alias ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
}
if ( empty( $on ) ) {
if ( $this->tables['orders'] === $table ) {
$on = "{$this->tables['orders']}.id = {$alias}.id";
} else {
$on = "{$this->tables['orders']}.id = {$alias}.order_id";
}
}
if ( isset( $this->join[ $alias ] ) ) {
if ( ! $alias_once ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
}
return;
}
if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
$join_type = 'INNER';
}
$sql_join = '';
$sql_join .= "{$join_type} JOIN {$table} ";
$sql_join .= ( $alias !== $table ) ? "AS {$alias} " : '';
$sql_join .= "ON ( {$on} )";
$this->join[ $alias ] = $sql_join;
}
/**
* Generates a properly escaped and sanitized WHERE condition for a given field.
*
* @param string $table The table the field belongs to.
* @param string $field The field or column name.
* @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
* @param mixed $value The value.
* @param string $type The column type as specified in {@see OrdersTableDataStore} column mappings.
* @return string The resulting WHERE condition.
*/
public function where( string $table, string $field, string $operator, $value, string $type ): string {
global $wpdb;
$db_util = wc_get_container()->get( DatabaseUtil::class );
$operator = strtoupper( '' !== $operator ? $operator : '=' );
try {
$format = $db_util->get_wpdb_format_for_type( $type );
} catch ( \Exception $e ) {
$format = '%s';
}
// = and != can be shorthands for IN and NOT in for array values.
if ( is_array( $value ) && '=' === $operator ) {
$operator = 'IN';
} elseif ( is_array( $value ) && '!=' === $operator ) {
$operator = 'NOT IN';
}
if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN' ), true ) ) {
return false;
}
if ( is_array( $value ) ) {
$value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
} else {
$value = $db_util->format_object_value_for_db( $value, $type );
}
if ( is_array( $value ) ) {
$placeholder = array_fill( 0, count( $value ), $format );
$placeholder = '(' . implode( ',', $placeholder ) . ')';
} else {
$placeholder = $format;
}
$sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $sql;
}
/**
* Processes fields related to the orders table.
*
* @return void
*/
private function process_orders_table_query_args(): void {
$this->sanitize_status();
$fields = array_filter(
array(
'id',
'status',
'type',
'currency',
'tax_amount',
'customer_id',
'billing_email',
'total_amount',
'parent_order_id',
'payment_method',
'payment_method_title',
'transaction_id',
'ip_address',
'user_agent',
),
array( $this, 'arg_isset' )
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
}
if ( $this->arg_isset( 'parent_exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
}
if ( $this->arg_isset( 'exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
}
// 'customer' is a very special field.
if ( $this->arg_isset( 'customer' ) ) {
$customer_query = $this->generate_customer_query( $this->args['customer'] );
if ( $customer_query ) {
$this->where[] = $customer_query;
}
}
}
/**
* Generate SQL conditions for the 'customer' query.
*
* @param array $values List of customer ids or emails.
* @param string $relation 'OR' or 'AND' relation used to build the customer query.
* @return string SQL to be used in a WHERE clause.
*/
private function generate_customer_query( $values, string $relation = 'OR' ): string {
$values = is_array( $values ) ? $values : array( $values );
$ids = array();
$emails = array();
foreach ( $values as $value ) {
if ( is_array( $value ) ) {
$sql = $this->generate_customer_query( $value, 'AND' );
$pieces[] = $sql ? '(' . $sql . ')' : '';
} elseif ( is_numeric( $value ) ) {
$ids[] = absint( $value );
} elseif ( is_string( $value ) && is_email( $value ) ) {
$emails[] = sanitize_email( $value );
}
}
if ( $ids ) {
$pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
}
if ( $emails ) {
$pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
}
return $pieces ? implode( " $relation ", $pieces ) : '';
}
/**
* Processes fields related to the operational data table.
*
* @return void
*/
private function process_operational_data_table_query_args(): void {
$fields = array_filter(
array(
'created_via',
'woocommerce_version',
'prices_include_tax',
'order_key',
'discount_total_amount',
'discount_tax_amount',
'shipping_total_amount',
'shipping_tax_amount',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
return;
}
$this->join(
$this->tables['operational_data'],
'',
'',
'inner',
true
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
}
}
/**
* Processes fields related to the addresses table.
*
* @return void
*/
private function process_addresses_table_query_args(): void {
global $wpdb;
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$fields = array_filter(
array(
$address_type . '_first_name',
$address_type . '_last_name',
$address_type . '_company',
$address_type . '_address_1',
$address_type . '_address_2',
$address_type . '_city',
$address_type . '_state',
$address_type . '_postcode',
$address_type . '_country',
$address_type . '_phone',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
continue;
}
$this->join(
$this->tables['addresses'],
$address_type,
$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'inner',
false
);
foreach ( $fields as $arg_key ) {
$column_name = str_replace( "{$address_type}_", '', $arg_key );
$this->where[] = $this->where(
$address_type,
$column_name,
'=',
$this->args[ $arg_key ],
$this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
);
}
}
}
/**
* Generates the ORDER BY clause.
*
* @return void
*/
private function process_orderby(): void {
// 'order' and 'orderby' vars.
$this->args['order'] = $this->sanitize_order( $this->args['order'] ?? '' );
$this->sanitize_order_orderby();
$orderby = $this->args['orderby'];
if ( 'none' === $orderby ) {
$this->orderby = '';
return;
}
$orderby_array = array();
foreach ( $this->args['orderby'] as $_orderby => $order ) {
$orderby_array[] = "{$_orderby} {$order}";
}
$this->orderby = $orderby_array;
}
/**
* Generates the limits to be used in the LIMIT clause.
*
* @return void
*/
private function process_limit(): void {
$row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
$page = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
$offset = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );
// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
if ( false === $row_count || $row_count < -1 ) {
return;
}
if ( false === $offset && $row_count > -1 ) {
$offset = (int) ( ( $page - 1 ) * $row_count );
}
$this->limits = array( $offset, $row_count );
}
/**
* Checks if a query var is set (i.e. not one of the "skipped values").
*
* @param string $arg_key Query var.
* @return bool TRUE if query var is set.
*/
public function arg_isset( string $arg_key ): bool {
return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
}
/**
* Runs the SQL query.
*
* @return void
*/
private function run_query(): void {
global $wpdb;
// Run query.
$this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Set max_num_pages and found_orders if necessary.
if ( ( $this->arg_isset( 'no_found_rows' ) && ! $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
return;
}
if ( $this->limits ) {
$this->found_orders = absint( $wpdb->get_var( 'SELECT FOUND_ROWS()' ) );
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
} else {
$this->found_orders = count( $this->orders );
}
}
/**
* Make some private available for backwards compatibility.
*
* @param string $name Property to get.
* @return mixed
*/
public function __get( string $name ) {
switch ( $name ) {
case 'found_orders':
case 'found_posts':
return $this->found_orders;
case 'max_num_pages':
return $this->max_num_pages;
case 'posts':
case 'orders':
return $this->results;
case 'request':
return $this->sql;
default:
break;
}
}
/**
* Returns the value of one of the query arguments.
*
* @param string $arg_name Query var.
* @return mixed
*/
public function get( string $arg_name ) {
return $this->args[ $arg_name ] ?? null;
}
/**
* Returns the name of one of the OrdersTableDatastore tables.
*
* @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
* @return string The prefixed table name.
* @throws \Exception When table ID is not found.
*/
public function get_table_name( string $table_id = '' ): string {
if ( ! isset( $this->tables[ $table_id ] ) ) {
// Translators: %s is a table identifier.
throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
}
return $this->tables[ $table_id ];
}
/**
* Finds table and mapping information about a field or column.
*
* @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
* @return false|array {
* @type string $table Full table name where the field is located.
* @type string $mapping_id Unprefixed table or mapping name.
* @type string $field_name Name of the corresponding order field.
* @type string $column Column in $table that corresponds to the field.
* @type string $type Field type.
* }
*/
public function get_field_mapping_info( $field ) {
global $wpdb;
$result = array(
'table' => '',
'mapping_id' => '',
'field_name' => '',
'column' => '',
'column_type' => '',
);
$mappings_to_search = array();
if ( false !== strstr( $field, '.' ) ) {
list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );
$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;
if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
$result['mapping_id'] = $mapping_or_table;
$result['column'] = $field_name_or_col;
} else {
$mappings_to_search = array( $mapping_or_table );
}
}
} else {
$field_name_or_col = $field;
$mappings_to_search = array_keys( $this->mappings );
}
foreach ( $mappings_to_search as $mapping_id ) {
foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
$result['mapping_id'] = $mapping_id;
$result['column'] = $column_name;
break 2;
}
}
}
if ( ! $result['mapping_id'] || ! $result['column'] ) {
return false;
}
$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];
$result['field_name'] = $field_info['name'];
$result['column_type'] = $field_info['type'];
$result['table'] = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
? $this->tables['addresses']
: $this->tables[ $result['mapping_id'] ];
return $result;
}
}
DataStores/Orders/DataSynchronizer.php 0000644 00000042126 15233366071 0014066 0 ustar 00 <?php
/**
* DataSynchronizer class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilites are:
*
* - Providing entry points for creating and deleting the required database tables.
* - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen.
*/
class DataSynchronizer implements BatchProcessorInterface {
use AccessiblePrivateMethods;
public const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled';
private const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count';
public const PENDING_SYNCHRONIZATION_FINISHED_ACTION = 'woocommerce_orders_sync_finished';
public const PLACEHOLDER_ORDER_POST_TYPE = 'shop_order_placehold';
private const ORDERS_SYNC_BATCH_SIZE = 250;
// Allowed values for $type in get_ids_of_orders_pending_sync method.
public const ID_TYPE_MISSING_IN_ORDERS_TABLE = 0;
public const ID_TYPE_MISSING_IN_POSTS_TABLE = 1;
public const ID_TYPE_DIFFERENT_UPDATE_DATE = 2;
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
private $database_util;
/**
* The posts to COT migrator to use.
*
* @var PostsToOrdersMigrationController
*/
private $posts_to_cot_migrator;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* Class constructor.
*/
public function __construct() {
self::add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
self::add_action( 'woocommerce_new_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
self::add_filter( 'woocommerce_feature_description_tip', array( $this, 'handle_feature_description_tip' ), 10, 3 );
}
/**
* Class initialization, invoked by the DI container.
*
* @param OrdersTableDataStore $data_store The data store to use.
* @param DatabaseUtil $database_util The database util class to use.
* @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
* @internal
*/
final public function init( OrdersTableDataStore $data_store, DatabaseUtil $database_util, PostsToOrdersMigrationController $posts_to_cot_migrator, LegacyProxy $legacy_proxy ) {
$this->data_store = $data_store;
$this->database_util = $database_util;
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
}
/**
* Does the custom orders tables exist in the database?
*
* @return bool True if the custom orders tables exist in the database.
*/
public function check_orders_table_exists(): bool {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
return count( $missing_tables ) === 0;
}
/**
* Create the custom orders database tables.
*/
public function create_database_tables() {
$this->database_util->dbdelta( $this->data_store->get_database_schema() );
}
/**
* Delete the custom orders database tables.
*/
public function delete_database_tables() {
$table_names = $this->data_store->get_all_table_names();
foreach ( $table_names as $table_name ) {
$this->database_util->drop_database_table( $table_name );
}
}
/**
* Is the data sync between old and new tables currently enabled?
*
* @return bool
*/
public function data_sync_is_enabled(): bool {
return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
}
/**
* Get the current sync process status.
* The information is meaningful only if pending_data_sync_is_in_progress return true.
*
* @return array
*/
public function get_sync_status() {
return array(
'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ),
'current_pending_count' => $this->get_total_pending_count(),
);
}
/**
* Get the total number of orders pending synchronization.
*
* @return int
*/
public function get_current_orders_pending_sync_count_cached() : int {
return $this->get_current_orders_pending_sync_count( true );
}
/**
* Calculate how many orders need to be synchronized currently.
* A database query is performed to get how many orders match one of the following:
*
* - Existing in the authoritative table but not in the backup table.
* - Existing in both tables, but they have a different update date.
*
* @param bool $use_cache Whether to use the cached value instead of fetching from database.
*/
public function get_current_orders_pending_sync_count( $use_cache = false ): int {
global $wpdb;
if ( $use_cache ) {
$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count' );
if ( false !== $pending_count ) {
return (int) $pending_count;
}
}
$orders_table = $this->data_store::get_orders_table_name();
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
if ( $this->custom_orders_table_is_authoritative() ) {
$missing_orders_count_sql = "
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
";
$operator = '>';
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.id=orders.id
WHERE
posts.post_type in ($order_post_type_placeholder)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL",
$order_post_types
);
// phpcs:enable
$operator = '<';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $missing_orders_count_sql is prepared.
$sql = $wpdb->prepare(
"
SELECT(
($missing_orders_count_sql)
+
(SELECT COUNT(1) FROM (
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholder)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
) x)
) count",
$order_post_types
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$pending_count = (int) $wpdb->get_var( $sql );
wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count );
return $pending_count;
}
/**
* Is the custom orders table the authoritative data source for orders currently?
*
* @return bool Whether the custom orders table the authoritative data source for orders currently.
*/
public function custom_orders_table_is_authoritative(): bool {
return wc_string_to_bool( get_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) );
}
/**
* Get a list of ids of orders than are out of sync.
*
* Valid values for $type are:
*
* ID_TYPE_MISSING_IN_ORDERS_TABLE: orders that exist in posts table but not in orders table.
* ID_TYPE_MISSING_IN_POSTS_TABLE: orders that exist in orders table but not in posts table (the corresponding post entries are placeholders).
* ID_TYPE_DIFFERENT_UPDATE_DATE: orders that exist in both tables but have different last update dates.
*
* @param int $type One of ID_TYPE_MISSING_IN_ORDERS_TABLE, ID_TYPE_MISSING_IN_POSTS_TABLE, ID_TYPE_DIFFERENT_UPDATE_DATE.
* @param int $limit Maximum number of ids to return.
* @return array An array of order ids.
* @throws \Exception Invalid parameter.
*/
public function get_ids_of_orders_pending_sync( int $type, int $limit ) {
global $wpdb;
if ( $limit < 1 ) {
throw new \Exception( '$limit must be at least 1' );
}
$orders_table = $this->data_store::get_orders_table_name();
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
switch ( $type ) {
case self::ID_TYPE_MISSING_IN_ORDERS_TABLE:
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL",
$order_post_types
);
break;
case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
$sql = "
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
";
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
",
$order_post_types
);
// phpcs:enable
break;
default:
throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' );
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB
return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) );
}
/**
* Cleanup all the synchronization status information,
* because the process has been disabled by the user via settings,
* or because there's nothing left to synchronize.
*/
public function cleanup_synchronization_state() {
delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION );
}
/**
* Process data for current batch.
*
* @param array $batch Batch details.
*/
public function process_batch( array $batch ) : void {
if ( $this->custom_orders_table_is_authoritative() ) {
foreach ( $batch as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
$this->error_logger->error( "Order $id not found during batch process, skipping." );
continue;
}
$data_store = $order->get_data_store();
$data_store->backfill_post_record( $order );
}
} else {
$this->posts_to_cot_migrator->migrate_orders( $batch );
}
if ( 0 === $this->get_total_pending_count() ) {
$this->cleanup_synchronization_state();
}
}
/**
* Get total number of pending records that require update.
*
* @return int Number of pending records.
*/
public function get_total_pending_count(): int {
return $this->get_current_orders_pending_sync_count();
}
/**
* Returns the batch with records that needs to be processed for a given size.
*
* @param int $size Size of the batch.
*
* @return array Batch of records.
*/
public function get_next_batch_to_process( int $size ): array {
if ( $this->custom_orders_table_is_authoritative() ) {
$order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_MISSING_IN_POSTS_TABLE, $size );
} else {
$order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_MISSING_IN_ORDERS_TABLE, $size );
}
if ( count( $order_ids ) >= $size ) {
return $order_ids;
}
$order_ids = $order_ids + $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) );
return $order_ids;
}
/**
* Default batch size to use.
*
* @return int Default batch size.
*/
public function get_default_batch_size(): int {
$batch_size = self::ORDERS_SYNC_BATCH_SIZE;
if ( $this->custom_orders_table_is_authoritative() ) {
// Back-filling is slower than migration.
$batch_size = absint( self::ORDERS_SYNC_BATCH_SIZE / 10 ) + 1;
}
/**
* Filter to customize the count of orders that will be synchronized in each step of the custom orders table to/from posts table synchronization process.
*
* @since 6.6.0
*
* @param int Default value for the count.
*/
return apply_filters( 'woocommerce_orders_cot_and_posts_sync_step_size', $batch_size );
}
/**
* A user friendly name for this process.
*
* @return string Name of the process.
*/
public function get_name(): string {
return 'Order synchronizer';
}
/**
* A user friendly description for this process.
*
* @return string Description.
*/
public function get_description(): string {
return 'Synchronizes orders between posts and custom order tables.';
}
/**
* Handle the 'deleted_post' action.
*
* When posts is authoritative and sync is enabled, deleting a post also deletes COT data.
*
* @param int $postid The post id.
* @param WP_Post $post The deleted post.
*/
private function handle_deleted_post( $postid, $post ): void {
if ( 'shop_order' === $post->post_type && ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
$this->data_store->delete_order_data_from_custom_order_tables( $postid );
}
}
/**
* Handle the 'woocommerce_update_order' action.
*
* When posts is authoritative and sync is enabled, updating a post triggers a corresponding change in the COT table.
*
* @param int $order_id The order id.
*/
private function handle_updated_order( $order_id ): void {
if ( ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
$this->posts_to_cot_migrator->migrate_orders( array( $order_id ) );
}
}
/**
* Handle the 'woocommerce_feature_description_tip' filter.
*
* When the COT feature is enabled and there are orders pending sync (in either direction),
* show a "you should ync before disabling" warning under the feature in the features page.
* Skip this if the UI prevents changing the feature enable status.
*
* @param string $desc_tip The original description tip for the feature.
* @param string $feature_id The feature id.
* @param bool $ui_disabled True if the UI doesn't allow to enable or disable the feature.
* @return string The new description tip for the feature.
*/
private function handle_feature_description_tip( $desc_tip, $feature_id, $ui_disabled ): string {
if ( 'custom_order_tables' !== $feature_id || $ui_disabled ) {
return $desc_tip;
}
$features_controller = wc_get_container()->get( FeaturesController::class );
$feature_is_enabled = $features_controller->feature_is_enabled( 'custom_order_tables' );
if ( ! $feature_is_enabled ) {
return $desc_tip;
}
$pending_sync_count = $this->get_current_orders_pending_sync_count();
if ( ! $pending_sync_count ) {
return $desc_tip;
}
if ( $this->custom_orders_table_is_authoritative() ) {
$extra_tip = sprintf(
_n(
"⚠ There's one order pending sync from the orders table to the posts table. The feature shouldn't be disabled until this order is synchronized.",
"⚠ There are %1\$d orders pending sync from the orders table to the posts table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
} else {
$extra_tip = sprintf(
_n(
"⚠ There's one order pending sync from the posts table to the orders table. The feature shouldn't be disabled until this order is synchronized.",
"⚠ There are %1\$d orders pending sync from the posts table to the orders table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
}
$cot_settings_url = add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'advanced',
'section' => 'custom_data_stores',
),
admin_url( 'admin.php' )
);
/* translators: %s = URL of the custom data stores settings page */
$manage_cot_settings_link = sprintf( __( "<a href='%s'>Manage orders synchronization</a>", 'woocommerce' ), $cot_settings_url );
return $desc_tip ? "{$desc_tip}<br/>{$extra_tip} {$manage_cot_settings_link}" : "{$extra_tip} {$manage_cot_settings_link}";
}
}
DataStores/Orders/OrdersTableDataStore.php 0000644 00000221235 15233366071 0014614 0 ustar 00 <?php
/**
* OrdersTableDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use WC_Data;
use WC_Order;
defined( 'ABSPATH' ) || exit;
/**
* This class is the standard data store to be used when the custom orders table is in use.
*/
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {
/**
* Order IDs for which we are checking read on sync in the current request.
*
* @var array.
*/
private static $reading_order_ids = array();
/**
* Data stored in meta keys, but not considered "meta" for an order.
*
* @since 7.0.0
* @var array
*/
protected $internal_meta_keys = array(
'_customer_user',
'_order_key',
'_order_currency',
'_billing_first_name',
'_billing_last_name',
'_billing_company',
'_billing_address_1',
'_billing_address_2',
'_billing_city',
'_billing_state',
'_billing_postcode',
'_billing_country',
'_billing_email',
'_billing_phone',
'_shipping_first_name',
'_shipping_last_name',
'_shipping_company',
'_shipping_address_1',
'_shipping_address_2',
'_shipping_city',
'_shipping_state',
'_shipping_postcode',
'_shipping_country',
'_shipping_phone',
'_completed_date',
'_paid_date',
'_edit_lock',
'_edit_last',
'_cart_discount',
'_cart_discount_tax',
'_order_shipping',
'_order_shipping_tax',
'_order_tax',
'_order_total',
'_payment_method',
'_payment_method_title',
'_transaction_id',
'_customer_ip_address',
'_customer_user_agent',
'_created_via',
'_order_version',
'_prices_include_tax',
'_date_completed',
'_date_paid',
'_payment_tokens',
'_billing_address_index',
'_shipping_address_index',
'_recorded_sales',
'_recorded_coupon_usage_counts',
'_download_permissions_granted',
'_order_stock_reduced',
'_new_order_email_sent',
);
/**
* Handles custom metadata in the wc_orders_meta table.
*
* @var OrdersTableDataStoreMeta
*/
protected $data_store_meta;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
protected $database_util;
/**
* The posts data store object to use.
*
* @var \WC_Order_Data_Store_CPT
*/
private $cpt_data_store;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* Initialize the object.
*
* @internal
* @param OrdersTableDataStoreMeta $data_store_meta Metadata instance.
* @param DatabaseUtil $database_util The database util instance to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
*
* @return void
*/
final public function init( OrdersTableDataStoreMeta $data_store_meta, DatabaseUtil $database_util, LegacyProxy $legacy_proxy ) {
$this->data_store_meta = $data_store_meta;
$this->database_util = $database_util;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->internal_meta_keys = $this->get_internal_meta_keys();
}
/**
* Get the custom orders table name.
*
* @return string The custom orders table name.
*/
public static function get_orders_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders';
}
/**
* Get the order addresses table name.
*
* @return string The order addresses table name.
*/
public static function get_addresses_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_addresses';
}
/**
* Get the orders operational data table name.
*
* @return string The orders operational data table name.
*/
public static function get_operational_data_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_operational_data';
}
/**
* Get the orders meta data table name.
*
* @return string Name of order meta data table.
*/
public static function get_meta_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders_meta';
}
/**
* Get the names of all the tables involved in the custom orders table feature.
*
* @return string[]
*/
public function get_all_table_names() {
return array(
$this->get_orders_table_name(),
$this->get_addresses_table_name(),
$this->get_operational_data_table_name(),
$this->get_meta_table_name(),
);
}
/**
* Table column to WC_Order mapping for wc_orders table.
*
* @var \string[][]
*/
protected $order_column_mapping = array(
'id' => array(
'type' => 'int',
'name' => 'id',
),
'status' => array(
'type' => 'string',
'name' => 'status',
),
'type' => array(
'type' => 'string',
'name' => 'type',
),
'currency' => array(
'type' => 'string',
'name' => 'currency',
),
'tax_amount' => array(
'type' => 'decimal',
'name' => 'cart_tax',
),
'total_amount' => array(
'type' => 'decimal',
'name' => 'total',
),
'customer_id' => array(
'type' => 'int',
'name' => 'customer_id',
),
'billing_email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'date_created_gmt' => array(
'type' => 'date',
'name' => 'date_created',
),
'date_updated_gmt' => array(
'type' => 'date',
'name' => 'date_modified',
),
'parent_order_id' => array(
'type' => 'int',
'name' => 'parent_id',
),
'payment_method' => array(
'type' => 'string',
'name' => 'payment_method',
),
'payment_method_title' => array(
'type' => 'string',
'name' => 'payment_method_title',
),
'ip_address' => array(
'type' => 'string',
'name' => 'customer_ip_address',
),
'transaction_id' => array(
'type' => 'string',
'name' => 'transaction_id',
),
'user_agent' => array(
'type' => 'string',
'name' => 'customer_user_agent',
),
'customer_note' => array(
'type' => 'string',
'name' => 'customer_note',
),
);
/**
* Table column to WC_Order mapping for billing addresses in wc_address table.
*
* @var \string[][]
*/
protected $billing_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'billing_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'billing_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'billing_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'billing_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'billing_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'billing_city',
),
'state' => array(
'type' => 'string',
'name' => 'billing_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'billing_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'billing_country',
),
'email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'phone' => array(
'type' => 'string',
'name' => 'billing_phone',
),
);
/**
* Table column to WC_Order mapping for shipping addresses in wc_address table.
*
* @var \string[][]
*/
protected $shipping_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'shipping_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'shipping_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'shipping_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'shipping_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'shipping_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'shipping_city',
),
'state' => array(
'type' => 'string',
'name' => 'shipping_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'shipping_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'shipping_country',
),
'email' => array( 'type' => 'string' ),
'phone' => array(
'type' => 'string',
'name' => 'shipping_phone',
),
);
/**
* Table column to WC_Order mapping for wc_operational_data table.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'created_via' => array(
'type' => 'string',
'name' => 'created_via',
),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'download_permission_granted' => array(
'type' => 'bool',
'name' => 'download_permissions_granted',
),
'cart_hash' => array(
'type' => 'string',
'name' => 'cart_hash',
),
'new_order_email_sent' => array(
'type' => 'bool',
'name' => 'new_order_email_sent',
),
'order_key' => array(
'type' => 'string',
'name' => 'order_key',
),
'order_stock_reduced' => array(
'type' => 'bool',
'name' => 'order_stock_reduced',
),
'date_paid_gmt' => array(
'type' => 'date',
'name' => 'date_paid',
),
'date_completed_gmt' => array(
'type' => 'date',
'name' => 'date_completed',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
'recorded_sales' => array(
'type' => 'bool',
'name' => 'recorded_sales',
),
);
/**
* Cache variable to store combined mapping.
*
* @var array[][][]
*/
private $all_order_column_mapping;
/**
* Return combined mappings for all order tables.
*
* @return array|\array[][][] Return combined mapping.
*/
public function get_all_order_column_mappings() {
if ( ! isset( $this->all_order_column_mapping ) ) {
$this->all_order_column_mapping = array(
'orders' => $this->order_column_mapping,
'billing_address' => $this->billing_address_column_mapping,
'shipping_address' => $this->shipping_address_column_mapping,
'operational_data' => $this->operational_data_column_mapping,
);
}
return $this->all_order_column_mapping;
}
/**
* Helper function to get alias for op table, this is used in select query.
*
* @return string Alias.
*/
private function get_op_table_alias() : string {
return 'order_operational_data';
}
/**
* Helper function to get alias for address table, this is used in select query.
*
* @param string $type Address type.
*
* @return string Alias.
*/
private function get_address_table_alias( string $type ) : string {
return "address_$type";
}
/**
* Helper method to get a CPT data store instance to use.
*
* @return \WC_Order_Data_Store_CPT Data store instance.
*/
public function get_cpt_data_store_instance() {
if ( ! isset( $this->cpt_data_store ) ) {
$this->cpt_data_store = $this->get_post_data_store_for_backfill();
}
return $this->cpt_data_store;
}
/**
* Returns data store object to use backfilling.
*
* @return \Abstract_WC_Order_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Data_Store_CPT();
}
/**
* Backfills order details in to WP_Post DB. Uses WC_Order_Data_store_CPT.
*
* @param \WC_Abstract_Order $order Order object to backfill.
*/
public function backfill_post_record( $order ) {
$cpt_data_store = $this->get_post_data_store_for_backfill();
if ( is_null( $cpt_data_store ) || ! method_exists( $cpt_data_store, 'update_order_from_object' ) ) {
return;
}
$cpt_data_store->update_order_from_object( $order );
foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
if (
is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
is_callable( array( $this, "get_$getter_name" ) )
) {
call_user_func_array(
array(
$cpt_data_store,
"set_$getter_name",
),
array(
$order,
$this->{"get_$getter_name"}( $order ),
)
);
}
}
}
/**
* Get information about whether permissions are granted yet.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether permissions are granted.
*/
public function get_download_permissions_granted( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_download_permissions_granted();
}
/**
* Stores information about whether permissions were generated yet.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_download_permissions_granted( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_download_permissions_granted( $set );
$order->save();
}
/**
* Gets information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether sales are recorded.
*/
public function get_recorded_sales( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_recorded_sales();
}
/**
* Stores information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_sales( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_sales( $set );
$order->save();
}
/**
* Gets information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether coupon counts were updated.
*/
public function get_recorded_coupon_usage_counts( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_recorded_coupon_usage_counts();
}
/**
* Stores information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_coupon_usage_counts( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_coupon_usage_counts( $set );
$order->save();
}
/**
* Whether email have been sent for this order.
*
* @param \WC_Order|int $order Order object.
*
* @return bool Whether email is sent.
*/
public function get_email_sent( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_new_order_email_sent();
}
/**
* Stores information about whether email was sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Helper setter for email_sent.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether email was sent.
*/
public function get_new_order_email_sent( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_new_order_email_sent();
}
/**
* Helper setter for new order email sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_new_order_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Gets information about whether stock was reduced.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether stock was reduced.
*/
public function get_stock_reduced( $order ) {
$order = is_int( $order ) ? wc_get_order( $order ) : $order;
return $order->get_order_stock_reduced();
}
/**
* Stores information about whether stock was reduced.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_stock_reduced( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_order_stock_reduced( $set );
$order->save();
}
/**
* Helper getter for `order_stock_reduced`.
*
* @param \WC_Order $order Order object.
* @return bool Whether stock was reduced.
*/
public function get_order_stock_reduced( $order ) {
return $this->get_stock_reduced( $order );
}
/**
* Helper setter for `order_stock_reduced`.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set Whether stock was reduced.
*/
public function set_order_stock_reduced( $order, $set ) {
$this->set_stock_reduced( $order, $set );
}
/**
* Get amount already refunded.
*
* @param \WC_Order $order Order object.
*
* @return float Refunded amount.
*/
public function get_total_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
"
SELECT SUM( total_amount ) FROM $order_table
WHERE
type = %s AND
parent_order_id = %d
;
",
// phpcs:enable
'shop_order_refund',
$order->get_id()
)
);
return -1 * ( isset( $total ) ? $total : 0 );
}
/**
* Get the total tax refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_tax_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Get the total shipping refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_shipping_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('cost')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Finds an Order ID based on an order key.
*
* @param string $order_key An order key has generated by.
* @return int The ID of an order, or 0 if the order could not be found
*/
public function get_order_id_by_order_key( $order_key ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$op_table = self::get_operational_data_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT {$orders_table}.id FROM {$orders_table}
INNER JOIN {$op_table} ON {$op_table}.order_id = {$orders_table}.id
WHERE {$op_table}.order_key = %s",
$order_key
)
);
// phpcs:enable
}
/**
* Return count of orders with a specific status.
*
* @param string $status Order status. Function wc_get_order_statuses() returns a list of valid statuses.
* @return int
*/
public function get_order_count( $status ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
return absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$orders_table} WHERE type = %s AND status = %s", 'shop_order', $status ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get all orders matching the passed in args.
*
* @deprecated 3.1.0 - Use {@see wc_get_orders} instead.
* @param array $args List of args passed to wc_get_orders().
* @return array|object
*/
public function get_orders( $args = array() ) {
wc_deprecated_function( __METHOD__, '3.1.0', 'Use wc_get_orders instead.' );
return wc_get_orders( $args );
}
/**
* Get unpaid orders last updated before the specified date.
*
* @param int $date Timestamp.
* @return array
*/
public function get_unpaid_orders( $date ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$order_types_sql = "('" . implode( "','", wc_get_order_types() ) . "')";
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$orders_table} WHERE
{$orders_table}.type IN {$order_types_sql}
AND {$orders_table}.status = %s
AND {$orders_table}.date_updated_gmt < %s",
'wc-pending',
gmdate( 'Y-m-d H:i:s', absint( $date ) )
)
);
// phpcs:enable
}
/**
* Search order data for a term and return matching order IDs.
*
* @param string $term Search term.
*
* @return int[] Array of order IDs.
*/
public function search_orders( $term ) {
$order_ids = wc_get_orders( array( 's' => $term ) );
/**
* Provides an opportunity to modify the list of order IDs obtained during an order search.
*
* This hook is used for Custom Order Table queries. For Custom Post Type order searches, the corresponding hook
* is `woocommerce_shop_order_search_results`.
*
* @since 7.0.0
*
* @param int[] $order_ids Search results as an array of order IDs.
* @param string $term The search term.
*/
return array_map( 'intval', (array) apply_filters( 'woocommerce_cot_shop_order_search_results', $order_ids, $term ) );
}
//phpcs:enable Squiz.Commenting, Generic.Commenting
/**
* Fetch order type for orders in bulk.
*
* @param array $order_ids Order IDs.
*
* @return array array( $order_id1 => $type1, ... ) Array for all orders.
*/
public function get_orders_type( $order_ids ) {
global $wpdb;
if ( empty( $order_ids ) ) {
return array();
}
$orders_table = self::get_orders_table_name();
$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, type FROM {$orders_table} WHERE id IN ( $order_ids_placeholder )",
$order_ids
)
);
// phpcs:enable
$order_types = array();
foreach ( $results as $row ) {
$order_types[ $row->id ] = $row->type;
}
return $order_types;
}
/**
* Get order type from DB.
*
* @param int $order_id Order ID.
*
* @return string Order type.
*/
public function get_order_type( $order_id ) {
$type = $this->get_orders_type( array( $order_id ) );
return $type[ $order_id ] ?? '';
}
/**
* Method to read an order from custom tables.
*
* @param \WC_Order $order Order object.
*
* @throws \Exception If passed order is invalid.
*/
public function read( &$order ) {
$orders_array = array( $order->get_id() => $order );
$this->read_multiple( $orders_array );
}
/**
* Reads multiple orders from custom tables in one pass.
*
* @since 6.9.0
* @param array[\WC_Order] $orders Order objects.
* @throws \Exception If passed an invalid order.
*/
public function read_multiple( &$orders ) {
$order_ids = array_keys( $orders );
$data = $this->get_order_data_for_ids( $order_ids );
if ( count( $data ) !== count( $order_ids ) ) {
throw new \Exception( __( 'Invalid order IDs in call to read_multiple()', 'woocommerce' ) );
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( ! $data_synchronizer instanceof DataSynchronizer ) {
return;
}
$data_sync_enabled = $data_synchronizer->data_sync_is_enabled() && 0 === $data_synchronizer->get_current_orders_pending_sync_count_cached();
$load_posts_for = array_diff( $order_ids, self::$reading_order_ids );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
foreach ( $data as $order_data ) {
$order_id = absint( $order_data->id );
$order = $orders[ $order_id ];
$this->init_order_record( $order, $order_id, $order_data );
if ( $data_sync_enabled && $this->should_sync_order( $order ) && isset( $post_orders[ $order_id ] ) ) {
self::$reading_order_ids[] = $order_id;
$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
}
}
}
/**
* Helper method to check whether to sync the order.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return bool Whether the order should be synced.
*/
private function should_sync_order( \WC_Abstract_Order $order ) : bool {
$draft_order = in_array( $order->get_status(), array( 'draft', 'auto-draft' ), true );
$already_synced = in_array( $order->get_id(), self::$reading_order_ids, true );
return ! $draft_order && ! $already_synced;
}
/**
* Helper method to initialize order object from DB data.
*
* @param \WC_Abstract_Order $order Order object.
* @param int $order_id Order ID.
* @param \stdClass $order_data Order data fetched from DB.
*
* @return void
*/
private function init_order_record( \WC_Abstract_Order &$order, int $order_id, \stdClass $order_data ) {
$order->set_defaults();
$order->set_id( $order_id );
$filtered_meta_data = $this->filter_raw_meta_data( $order, $order_data->meta_data );
$order->init_meta_data( $filtered_meta_data );
$this->set_order_props_from_data( $order, $order_data );
$order->set_object_read( true );
}
/**
* For post based data stores, this was used to filter internal meta data. For custom tables, technically there is no internal meta data,
* (i.e. we store all core data as properties for the order, and not in meta data). So this method is a no-op.
*
* Except that some meta such as billing_address_index and shipping_address_index are infact stored in meta data, so we need to filter those out.
*
* However, declaring $internal_meta_keys is still required so that our backfill and other comparison checks works as expected.
*
* @param \WC_Data $object Object to filter meta data for.
* @param array $raw_meta_data Raw meta data.
*
* @return array Filtered meta data.
*/
public function filter_raw_meta_data( &$object, $raw_meta_data ) {
$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
$allowed_keys = array(
'_billing_address_index',
'_shipping_address_index',
);
$allowed_meta = array_filter(
$raw_meta_data,
function( $meta ) use ( $allowed_keys ) {
return in_array( $meta->meta_key, $allowed_keys, true );
}
);
return array_merge( $allowed_meta, $filtered_meta_data );
}
/**
* Sync order to/from posts tables if we are able to detect difference between order and posts but the sync is enabled.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object initialized from post.
*
* @return void
* @throws \Exception If passed an invalid order.
*/
private function maybe_sync_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
if ( ! $this->is_post_different_from_order( $order, $post_order ) ) {
return;
}
// Modified dates can be empty when the order is created but never updated again. Fallback to created date in those cases.
$order_modified_date = $order->get_date_modified() ?? $order->get_date_created();
$order_modified_date = is_null( $order_modified_date ) ? 0 : $order_modified_date->getTimestamp();
$post_order_modified_date = $post_order->get_date_modified() ?? $post_order->get_date_created();
$post_order_modified_date = is_null( $post_order_modified_date ) ? 0 : $post_order_modified_date->getTimestamp();
/**
* We are here because there was difference in posts and order data, although the sync is enabled.
* When order modified date is more recent than post modified date, it can only mean that COT definitely has more updated version of the order.
*
* In a case where post meta was updated (without updating post_modified date), post_modified would be equal to order_modified date.
*
* So we write back to the order table when order modified date is more recent than post modified date. Otherwise, we write to the post table.
*/
if ( $post_order_modified_date >= $order_modified_date ) {
$this->migrate_post_record( $order, $post_order );
}
}
/**
* Get the post type order representation.
*
* @param \WP_Post $post Post object.
*
* @return \WC_Order Order object.
*/
private function get_cpt_order( $post ) {
$cpt_order = new \WC_Order();
$cpt_order->set_id( $post->ID );
$cpt_data_store = $this->get_cpt_data_store_instance();
$cpt_data_store->read( $cpt_order );
return $cpt_order;
}
/**
* Helper function to get posts data for an order in bullk. We use to this to compute posts object in bulk so that we can compare it with COT data.
*
* @param array $orders List of orders mapped by $order_id.
*
* @return array List of posts.
*/
private function get_post_orders_for_ids( array $orders ): array {
$order_ids = array_keys( $orders );
// We have to bust meta cache, otherwise we will just get the meta cached by OrderTableDataStore.
foreach ( $order_ids as $order_id ) {
wp_cache_delete( WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
}
$cpt_stores = array();
$cpt_store_orders = array();
foreach ( $orders as $order_id => $order ) {
$table_data_store = $order->get_data_store();
$cpt_data_store = $table_data_store->get_cpt_data_store_instance();
$cpt_store_class_name = get_class( $cpt_data_store );
if ( ! isset( $cpt_stores[ $cpt_store_class_name ] ) ) {
$cpt_stores[ $cpt_store_class_name ] = $cpt_data_store;
$cpt_store_orders[ $cpt_store_class_name ] = array();
}
$cpt_store_orders[ $cpt_store_class_name ][ $order_id ] = $order;
}
$cpt_orders = array();
foreach ( $cpt_stores as $cpt_store_name => $cpt_store ) {
// Prime caches if we can.
if ( method_exists( $cpt_store, 'prime_caches_for_orders' ) ) {
$cpt_store->prime_caches_for_orders( array_keys( $cpt_store_orders[ $cpt_store_name ] ), array() );
}
foreach ( $cpt_store_orders[ $cpt_store_name ] as $order_id => $order ) {
$cpt_order_class_name = wc_get_order_type( $order->get_type() )['class_name'];
$cpt_order = new $cpt_order_class_name();
$cpt_order->set_id( $order_id );
$cpt_store->read( $cpt_order );
$cpt_orders[ $order_id ] = $cpt_order;
}
}
return $cpt_orders;
}
/**
* Computes whether post has been updated after last order. Tries to do it as efficiently as possible.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return bool True if post is different than order.
*/
private function is_post_different_from_order( $order, $post_order ): bool {
if ( ArrayUtil::deep_compare_array_diff( $order->get_base_data(), $post_order->get_base_data(), false ) ) {
return true;
}
$meta_diff = $this->get_diff_meta_data_between_orders( $order, $post_order );
if ( ! empty( $meta_diff ) ) {
return true;
}
return false;
}
/**
* Migrate meta data from post to order.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return array List of meta data that was migrated.
*/
private function migrate_meta_data_from_post_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
$diff = $this->get_diff_meta_data_between_orders( $order, $post_order, true );
$order->save_meta_data();
return $diff;
}
/**
* Helper function to compute diff between metadata of post and cot data for an order.
*
* Also provides an option to sync the metadata as well, since we are already computing the diff.
*
* @param \WC_Abstract_Order $order1 Order object read from posts.
* @param \WC_Abstract_Order $order2 Order object read from COT.
* @param bool $sync Whether to also sync the meta data.
*
* @return array Difference between post and COT meta data.
*/
private function get_diff_meta_data_between_orders( \WC_Abstract_Order &$order1, \WC_Abstract_Order $order2, $sync = false ): array {
$order1_meta = ArrayUtil::select( $order1->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order2_meta = ArrayUtil::select( $order2->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order1_meta_by_key = ArrayUtil::select_as_assoc( $order1_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$order2_meta_by_key = ArrayUtil::select_as_assoc( $order2_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$diff = array();
foreach ( $order1_meta_by_key as $key => $value ) {
if ( in_array( $key, $this->internal_meta_keys, true ) ) {
// These should have already been verified in the base data comparison.
continue;
}
$order1_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
if ( ! array_key_exists( $key, $order2_meta_by_key ) ) {
$sync && $order1->delete_meta_data( $key );
$diff[ $key ] = $order1_values;
unset( $order2_meta_by_key[ $key ] );
continue;
}
$order2_values = ArrayUtil::select( $order2_meta_by_key[ $key ], 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
$new_diff = ArrayUtil::deep_assoc_array_diff( $order1_values, $order2_values );
if ( ! empty( $new_diff ) && $sync ) {
if ( count( $order2_values ) > 1 ) {
$sync && $order1->delete_meta_data( $key );
foreach ( $order2_values as $post_order_value ) {
$sync && $order1->add_meta_data( $key, $post_order_value, false );
}
} else {
$sync && $order1->update_meta_data( $key, $order2_values[0] );
}
$diff[ $key ] = $new_diff;
unset( $order2_meta_by_key[ $key ] );
}
}
foreach ( $order2_meta_by_key as $key => $value ) {
if ( array_key_exists( $key, $order1_meta_by_key ) || in_array( $key, $this->internal_meta_keys, true ) ) {
continue;
}
$order2_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
foreach ( $order2_values as $meta_value ) {
$sync && $order1->add_meta_data( $key, $meta_value );
}
$diff[ $key ] = $order2_values;
}
return $diff;
}
/**
* Log difference between post and COT data for an order.
*
* @param array $diff Difference between post and COT data.
*
* @return void
*/
private function log_diff( array $diff ): void {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- This is a log function.
$this->error_logger->notice( 'Diff found: ' . print_r( $diff, true ) );
}
/**
* Migrate post record from a given order object.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts.
*
* @return void
*/
private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
$this->migrate_meta_data_from_post_order( $order, $post_order );
$post_order_base_data = $post_order->get_base_data();
foreach ( $post_order_base_data as $key => $value ) {
$this->set_order_prop( $order, $key, $value );
}
$this->persist_updates( $order, false );
}
/**
* Sets order properties based on a row from the database.
*
* @param \WC_Abstract_Order $order The order object.
* @param object $order_data A row of order data from the database.
*/
private function set_order_props_from_data( &$order, $order_data ) {
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
foreach ( $column_mapping as $column_name => $prop_details ) {
if ( ! isset( $prop_details['name'] ) ) {
continue;
}
$prop_value = $order_data->{$prop_details['name']};
if ( is_null( $prop_value ) ) {
continue;
}
if ( 'date' === $prop_details['type'] ) {
$prop_value = $this->string_to_timestamp( $prop_value );
}
$this->set_order_prop( $order, $prop_details['name'], $prop_value );
}
}
}
/**
* Set order prop if a setter exists in either the order object or in the data store.
*
* @param \WC_Abstract_Order $order Order object.
* @param string $prop_name Property name.
* @param mixed $prop_value Property value.
*
* @return bool True if the property was set, false otherwise.
*/
private function set_order_prop( \WC_Abstract_Order $order, string $prop_name, $prop_value ) {
$prop_setter_function_name = "set_{$prop_name}";
if ( is_callable( array( $order, $prop_setter_function_name ) ) ) {
return $order->{$prop_setter_function_name}( $prop_value );
} elseif ( is_callable( array( $this, $prop_setter_function_name ) ) ) {
return $this->{$prop_setter_function_name}( $order, $prop_value, false );
}
return false;
}
/**
* Return order data for a single order ID.
*
* @param int $id Order ID.
*
* @return object|\WP_Error DB order object or WP_Error.
*/
private function get_order_data_for_id( $id ) {
$results = $this->get_order_data_for_ids( array( $id ) );
return is_array( $results ) && count( $results ) > 0 ? $results[ $id ] : $results;
}
/**
* Return order data for multiple IDs.
*
* @param array $ids List of order IDs.
*
* @return \stdClass[]|object|null DB Order objects or error.
*/
private function get_order_data_for_ids( $ids ) {
if ( ! $ids ) {
return array();
}
global $wpdb;
if ( empty( $ids ) ) {
return array();
}
$order_table_query = $this->get_order_table_select_statement();
$id_placeholder = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$order_meta_table = self::get_meta_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
$table_data = $wpdb->get_results(
$wpdb->prepare(
"$order_table_query WHERE wc_order.id in ( $id_placeholder )",
$ids
)
);
// phpcs:enable
$meta_data_query = $this->get_order_meta_select_statement();
$order_data = array();
$meta_data = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_data_query and $order_meta_table is autogenerated and should already be prepared. $id_placeholder is already prepared.
"$meta_data_query WHERE $order_meta_table.order_id in ( $id_placeholder )",
$ids
)
);
foreach ( $table_data as $table_datum ) {
$order_data[ $table_datum->id ] = $table_datum;
$order_data[ $table_datum->id ]->meta_data = array();
}
foreach ( $meta_data as $meta_datum ) {
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Not a meta query.
$order_data[ $meta_datum->order_id ]->meta_data[] = (object) array(
'meta_id' => $meta_datum->id,
'meta_key' => $meta_datum->meta_key,
'meta_value' => $meta_datum->meta_value,
);
// phpcs:enable
}
return $order_data;
}
/**
* Helper method to generate combined select statement.
*
* @return string Select SQL statement to fetch order.
*/
private function get_order_table_select_statement() {
$order_table = $this::get_orders_table_name();
$order_table_alias = 'wc_order';
$select_clause = $this->generate_select_clause_for_props( $order_table_alias, $this->order_column_mapping );
$billing_address_table_alias = $this->get_address_table_alias( 'billing' );
$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
$op_data_table_alias = $this->get_op_table_alias();
$billing_address_clauses = $this->join_billing_address_table_to_order_query( $order_table_alias, $billing_address_table_alias );
$shipping_address_clauses = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
$operational_data_clauses = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );
return "
SELECT $select_clause, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, {$operational_data_clauses['select']}
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
LEFT JOIN {$operational_data_clauses['join']}
";
}
/**
* Helper function to generate select statement for fetching metadata in bulk.
*
* @return string Select SQL statement to fetch order metadata.
*/
private function get_order_meta_select_statement() {
$order_meta_table = self::get_meta_table_name();
return "
SELECT $order_meta_table.id, $order_meta_table.order_id, $order_meta_table.meta_key, $order_meta_table.meta_value
FROM $order_meta_table
";
}
/**
* Helper method to generate join query for billing addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for billing address table.
*/
private function join_billing_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'billing', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join query for shipping addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for shipping address table.
*/
private function join_shipping_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'shipping', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join and select query for address table.
*
* @param string $address_type Type of address. Typically will be `billing` or `shipping`.
* @param string $order_table_alias Alias of order table to use.
* @param string $address_table_alias Alias for address table to use.
*
* @return array Select and join statements for address table.
*/
private function join_address_table_order_query( $address_type, $order_table_alias, $address_table_alias ) {
global $wpdb;
$address_table = $this::get_addresses_table_name();
$column_props_map = 'billing' === $address_type ? $this->billing_address_column_mapping : $this->shipping_address_column_mapping;
$clauses = $this->generate_select_and_join_clauses( $order_table_alias, $address_table, $address_table_alias, $column_props_map );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $clauses['join'] and $address_table_alias are hardcoded.
$clauses['join'] = $wpdb->prepare(
"{$clauses['join']} AND $address_table_alias.address_type = %s",
$address_type
);
// phpcs:enable
return array(
'select' => $clauses['select'],
'join' => $clauses['join'],
);
}
/**
* Helper method to join order operational data table.
*
* @param string $order_table_alias Alias to use for order table.
* @param string $operational_table_alias Alias to use for operational data table.
*
* @return array Select and join queries for operational data table.
*/
private function join_operational_data_table_to_order_query( $order_table_alias, $operational_table_alias ) {
$operational_data_table = $this::get_operational_data_table_name();
return $this->generate_select_and_join_clauses(
$order_table_alias,
$operational_data_table,
$operational_table_alias,
$this->operational_data_column_mapping
);
}
/**
* Helper method to generate join and select clauses.
*
* @param string $order_table_alias Alias for order table.
* @param string $table Table to join.
* @param string $table_alias Alias for table to join.
* @param array[] $column_props_map Column to prop map for table to join.
*
* @return array Select and join queries.
*/
private function generate_select_and_join_clauses( $order_table_alias, $table, $table_alias, $column_props_map ) {
// Add aliases to column names so they will be unique when fetching.
$select_clause = $this->generate_select_clause_for_props( $table_alias, $column_props_map );
$join_clause = "$table $table_alias ON $table_alias.order_id = $order_table_alias.id";
return array(
'select' => $select_clause,
'join' => $join_clause,
);
}
/**
* Helper method to generate select clause for props.
*
* @param string $table_alias Alias for table.
* @param array[] $props Props to column mapping for table.
*
* @return string Select clause.
*/
private function generate_select_clause_for_props( $table_alias, $props ) {
$select_clauses = array();
foreach ( $props as $column_name => $prop_details ) {
$select_clauses[] = isset( $prop_details['name'] ) ? "$table_alias.$column_name as {$prop_details['name']}" : "$table_alias.$column_name as {$table_alias}_$column_name";
}
return implode( ', ', $select_clauses );
}
/**
* Persists order changes to the database.
*
* @param \WC_Abstract_Order $order The order.
* @param bool $force_all_fields Force saving all fields to DB and just changed.
*
* @throws \Exception If order data is not valid.
*
* @since 6.8.0
*/
protected function persist_order_to_db( &$order, bool $force_all_fields = false ) {
$context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update';
$data_sync = wc_get_container()->get( DataSynchronizer::class );
if ( 'create' === $context ) {
$post_id = wp_insert_post(
array(
'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
'post_status' => 'draft',
)
);
if ( ! $post_id ) {
throw new \Exception( __( 'Could not create order in posts table.', 'woocommerce' ) );
}
$order->set_id( $post_id );
}
$only_changes = ! $force_all_fields && 'update' === $context;
// Figure out what needs to be updated in the database.
$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );
// Persist changes.
foreach ( $db_updates as $update ) {
// Make sure 'data' and 'format' entries match before passing to $wpdb.
ksort( $update['data'] );
ksort( $update['format'] );
$result = $this->database_util->insert_on_duplicate_key_update(
$update['table'],
$update['data'],
array_values( $update['format'] )
);
if ( false === $result ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Could not persist order to database table "%s".', 'woocommerce' ), $update['table'] ) );
}
}
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
}
/**
* Generates an array of rows with all the details required to insert or update an order in the database.
*
* @param \WC_Abstract_Order $order The order.
* @param string $context The context: 'create' or 'update'.
* @param boolean $only_changes Whether to consider only changes in the order for generating the rows.
*
* @return array
* @throws \Exception When invalid data is found for the given context.
*
* @since 6.8.0
*/
protected function get_db_rows_for_order( \WC_Abstract_Order $order, string $context = 'create', bool $only_changes = false ): array {
$result = array();
$row = $this->get_db_row_from_order( $order, $this->order_column_mapping, $only_changes );
if ( 'create' === $context && ! $row ) {
throw new \Exception( 'No data for new record.' ); // This shouldn't occur.
}
if ( $row ) {
$result[] = array(
'table' => self::get_orders_table_name(),
'data' => array_merge( $row['data'], array( 'id' => $order->get_id() ) ),
'format' => array_merge( $row['format'], array( 'id' => '%d' ) ),
);
}
// wc_order_operational_data.
$row = $this->get_db_row_from_order( $order, $this->operational_data_column_mapping, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_operational_data_table_name(),
'data' => array_merge( $row['data'], array( 'order_id' => $order->get_id() ) ),
'format' => array_merge( $row['format'], array( 'order_id' => '%d' ) ),
);
}
// wc_order_addresses.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$row = $this->get_db_row_from_order( $order, $this->{$address_type . '_address_column_mapping'}, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_addresses_table_name(),
'data' => array_merge(
$row['data'],
array(
'order_id' => $order->get_id(),
'address_type' => $address_type,
)
),
'format' => array_merge(
$row['format'],
array(
'order_id' => '%d',
'address_type' => '%s',
)
),
);
}
}
/**
* Allow third parties to include rows that need to be inserted/updated in custom tables when persisting an order.
*
* @since 6.8.0
*
* @param array Array of rows to be inserted/updated when persisting an order. Each entry should be an array with
* keys 'table', 'data' (the row), 'format' (row format), 'where' and 'where_format'.
* @param \WC_Order The order object.
* @param string The context of the operation: 'create' or 'update'.
*/
$ext_rows = apply_filters( 'woocommerce_orders_table_datastore_extra_db_rows_for_order', array(), $order, $context );
return array_merge( $result, $ext_rows );
}
/**
* Produces an array with keys 'row' and 'format' that can be passed to `$wpdb->update()` as the `$data` and
* `$format` parameters. Values are taken from the order changes array and properly formatted for inclusion in the
* database.
*
* @param \WC_Abstract_Order $order Order.
* @param array $column_mapping Table column mapping.
* @param bool $only_changes Whether to consider only changes in the order object or all fields.
* @return array
*
* @since 6.8.0
*/
protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );
$changes['type'] = $order->get_type();
// Make sure 'status' is correct.
if ( array_key_exists( 'status', $column_mapping ) ) {
$changes['status'] = $this->get_post_status( $order );
}
$row = array();
$row_format = array();
foreach ( $column_mapping as $column => $details ) {
if ( ! isset( $details['name'] ) || ! array_key_exists( $details['name'], $changes ) ) {
continue;
}
$row[ $column ] = $this->database_util->format_object_value_for_db( $changes[ $details['name'] ], $details['type'] );
$row_format[ $column ] = $this->database_util->get_wpdb_format_for_type( $details['type'] );
}
if ( ! $row ) {
return false;
}
return array(
'data' => $row,
'format' => $row_format,
);
}
//phpcs:disable Squiz.Commenting, Generic.Commenting
/**
* Method to delete an order from the database.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$order, $args = array() ) {
$order_id = $order->get_id();
if ( ! $order_id ) {
return;
}
if ( ! empty( $args['force_delete'] ) ) {
/**
* Fires immediately before an order is deleted from the database.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be deleted.
* @param WC_Order $order Instance of the order that is about to be deleted.
*/
do_action( 'woocommerce_before_delete_order', $order_id, $order );
$this->delete_order_data_from_custom_order_tables( $order_id );
$order->set_id( 0 );
// If this datastore method is called while the posts table is authoritative, refrain from deleting post data.
if ( ! is_a( $order->get_data_store(), self::class ) ) {
return;
}
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $order_id );
do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
} else {
/**
* Fires immediately before an order is trashed.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be deleted.
* @param WC_Order $order Instance of the order that is about to be deleted.
*/
do_action( 'woocommerce_before_trash_order', $order_id, $order );
$this->trash_order( $order );
do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
}
/**
* Trashes an order.
*
* @param WC_Order $order The order object
* @return void
*/
public function trash_order( $order ) {
global $wpdb;
if ( 'trash' === $order->get_status( 'edit' ) ) {
return;
}
$trash_metadata = array(
'_wp_trash_meta_status' => 'wc-' . $order->get_status( 'edit' ),
'_wp_trash_meta_time' => time(),
);
foreach ( $trash_metadata as $meta_key => $meta_value ) {
$this->add_meta(
$order,
(object) array(
'key' => $meta_key,
'value' => $meta_value,
)
);
}
$wpdb->update(
self::get_orders_table_name(),
array(
'status' => 'trash',
'date_updated_gmt' => current_time( 'Y-m-d H:i:s', true ),
),
array( 'id' => $order->get_id() ),
array( '%s', '%s' ),
array( '%d' )
);
$order->set_status( 'trash' );
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
wp_trash_post( $order->get_id() );
}
}
/**
* Attempts to restore the specified order back to its original status (after having been trashed).
*
* @param WC_Order $order The order to be untrashed.
*
* @return bool If the operation was successful.
*/
public function untrash_order( WC_Order $order ): bool {
$id = $order->get_id();
$status = $order->get_status();
if ( 'trash' !== $status ) {
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Order %1$d cannot be restored from the trash: it has already been restored to status "%2$s".', 'woocommerce' ),
$id,
$status
)
);
return false;
}
$previous_status = $order->get_meta( '_wp_trash_meta_status' );
$valid_statuses = wc_get_order_statuses();
$previous_state_is_invalid = ! array_key_exists( $previous_status, $valid_statuses );
$pending_is_valid_status = array_key_exists( 'wc-pending', $valid_statuses );
if ( $previous_state_is_invalid && $pending_is_valid_status ) {
// If the previous status is no longer valid, let's try to restore it to "pending" instead.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It has been restored to "pending" status instead.', 'woocommerce' ),
$id,
$previous_status
)
);
$previous_status = 'pending';
} elseif ( $previous_state_is_invalid ) {
// If we cannot restore to pending, we should probably stand back and let the merchant intervene some other way.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It could not be restored.', 'woocommerce' ),
$id,
$previous_status
)
);
return false;
}
/**
* Fires before an order is restored from the trash.
*
* @since 7.2.0
*
* @param int $order_id Order ID.
* @param string $previous_status The status of the order before it was trashed.
*/
do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );
$order->set_status( $previous_status );
$order->save();
// Was the status successfully restored? Let's clean up the meta and indicate success...
if ( 'wc-' . $order->get_status() === $previous_status ) {
$order->delete_meta_data( '_wp_trash_meta_status' );
$order->delete_meta_data( '_wp_trash_meta_time' );
$order->delete_meta_data( '_wp_trash_meta_comments_status' );
$order->save_meta_data();
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
//The previous $order->save() will have forced a sync to the posts table,
//this implies that the post status is not "trash" anymore, and thus
//wp_untrash_post would do nothing.
wp_update_post(
array(
'ID' => $id,
'post_status' => 'trash',
)
);
wp_untrash_post( $id );
}
return true;
}
// ...Or log a warning and bail.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Something went wrong when trying to restore order %d from the trash. It could not be restored.', 'woocommerce' ),
$id
)
);
return false;
}
/**
* Deletes order data from custom order tables.
*
* @param int $order_id The order ID.
* @return void
*/
public function delete_order_data_from_custom_order_tables( $order_id ) {
global $wpdb;
// Delete COT-specific data.
foreach ( $this->get_all_table_names() as $table ) {
$wpdb->delete(
$table,
( self::get_orders_table_name() === $table )
? array( 'id' => $order_id )
: array( 'order_id' => $order_id ),
array( '%d' )
);
}
}
/**
* Method to create an order in the database.
*
* @param \WC_Order $order Order object.
*/
public function create( &$order ) {
if ( '' === $order->get_order_key() ) {
$order->set_order_key( wc_generate_order_key() );
}
$this->persist_save( $order );
/**
* Fires when a new order is created.
*
* @since 2.7.0
*
* @param int Order ID.
* @param \WC_Order Order object.
*/
do_action( 'woocommerce_new_order', $order->get_id(), $order );
}
/**
* Helper method responsible for persisting new data to order table.
*
* This should not contain and specific meta or actions, so that it can be used other order types safely.
*
* @param \WC_Order $order Order object.
*
* @return void
*
* @throws \Exception When unable to save data.
*/
protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) {
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
$order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() );
if ( ! $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
$this->update_order_meta( $order );
$this->persist_order_to_db( $order, $force_all_fields );
$order->save_meta_data();
$order->apply_changes();
if ( $backfill ) {
$this->maybe_backfill_post_record( $order );
}
$this->clear_caches( $order );
}
/**
* Method to update an order in the database.
*
* @param \WC_Order $order
*/
public function update( &$order ) {
// Before updating, ensure date paid is set if missing.
if (
! $order->get_date_paid( 'edit' )
&& version_compare( $order->get_version( 'edit' ), '3.0', '<' )
&& $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
) {
$order->set_date_paid( $order->get_date_created( 'edit' ) );
}
if ( null === $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
// Fetch changes.
$changes = $order->get_changes();
$this->persist_updates( $order );
// Update download permissions if necessary.
if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
$data_store = \WC_Data_Store::load( 'customer-download' );
$data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() );
}
// Mark user account as active.
if ( array_key_exists( 'customer_id', $changes ) ) {
wc_update_user_last_active( $order->get_customer_id() );
}
$order->apply_changes();
$this->clear_caches( $order );
do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
/**
* Proxy to udpating order meta. Here for backward compatibility reasons.
*
* @param \WC_Order $order Order object.
*
* @return void
*/
protected function update_post_meta( &$order ) {
$this->update_order_meta( $order );
}
/**
* Helper method that is responsible for persisting order updates to the database.
*
* This is expected to be reused by other order types, and should not contain any specific metadata updates or actions.
*
* @param \WC_Order $order Order object.
*
* @return array $changes Array of changes.
*
* @throws \Exception When unable to persist order.
*/
protected function persist_updates( &$order, $backfill = true ) {
// Fetch changes.
$changes = $order->get_changes();
if ( ! isset( $changes['date_modified'] ) ) {
$order->set_date_modified( time() );
}
if ( $backfill ) {
$this->maybe_backfill_post_record( $order );
}
$this->persist_order_to_db( $order );
$order->save_meta_data();
return $changes;
}
/**
* Helper function to decide whether to backfill post record.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return void
*/
private function maybe_backfill_post_record( $order ) {
$data_sync = wc_get_container()->get( DataSynchronizer::class );
if ( $data_sync->data_sync_is_enabled() ) {
$this->backfill_post_record( $order );
}
}
/**
* Helper method that updates post meta based on an order object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $order Order object.
*
* @since 7.0.0
*/
public function update_order_meta( &$order ) {
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
}
/**
* Helper function to update billing and shipping address metadata.
* @param \WC_Abstract_Order $order Order Object
* @param array $changes Array of changes.
*
* @return void
*/
private function update_address_index_meta( $order, $changes ) {
// If address changed, store concatenated version to make searches faster.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
if ( isset( $changes[ $address_type ] ) ) {
$order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) );
}
}
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
* Pass $coupon_id if key for only one of the coupon is needed.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
*/
public function get_coupon_held_keys( $order, $coupon_id = null ) {
$held_keys = $order->get_meta( '_coupon_held_keys' );
if ( $coupon_id ) {
return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null;
}
return $held_keys;
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return mixed
*/
public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) {
$held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' );
if ( $coupon_id ) {
return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null;
}
return $held_keys_for_user;
}
/**
* Add/Update list of meta keys that are currently being used by this order to hold a coupon.
* This is used to figure out what all meta entries we should delete when order is cancelled/completed.
*
* @param WC_Order $order Order object.
* @param array $held_keys Array of coupon_code => meta_key.
* @param array $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
*
* @return mixed
*/
public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) {
if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
$order->update_meta_data( '_coupon_held_keys', $held_keys );
}
if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
$order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
}
}
/**
* Release all coupons held by this order.
*
* @param WC_Order $order Current order object.
* @param bool $save Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
*/
public function release_held_coupons( $order, $save = true ) {
$coupon_held_keys = $this->get_coupon_held_keys( $order );
if ( is_array( $coupon_held_keys ) ) {
foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys' );
$coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order );
if ( is_array( $coupon_held_keys_for_users ) ) {
foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys_for_users' );
if ( $save ) {
$order->save_meta_data();
}
}
public function query( $query_vars ) {
if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) {
$query_vars['no_found_rows'] = true;
}
if ( isset( $query_vars['anonymized'] ) ) {
$query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
if ( $query_vars['anonymized'] ) {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'value' => 'yes',
);
} else {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'compare' => 'NOT EXISTS',
);
}
}
try {
$query = new OrdersTableQuery( $query_vars );
} catch ( \Exception $e ) {
$query = (object) array(
'orders' => array(),
'found_orders' => 0,
'max_num_pages' => 0,
);
}
if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) {
$orders = $query->orders;
} else {
$orders = WC()->order_factory->get_orders( $query->orders );
}
if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
return (object) array(
'orders' => $orders,
'total' => $query->found_orders,
'max_num_pages' => $query->max_num_pages,
);
}
return $orders;
}
public function get_order_item_type( $order, $order_item_id ) {
return 'line_item';
}
//phpcs:enable Squiz.Commenting, Generic.Commenting
/**
* Get the SQL needed to create all the tables needed for the custom orders table feature.
*
* @return string
*/
public function get_database_schema() {
global $wpdb;
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
$orders_table_name = $this->get_orders_table_name();
$addresses_table_name = $this->get_addresses_table_name();
$operational_data_table_name = $this->get_operational_data_table_name();
$meta_table = $this->get_meta_table_name();
$sql = "
CREATE TABLE $orders_table_name (
id bigint(20) unsigned,
status varchar(20) null,
currency varchar(10) null,
type varchar(20) null,
tax_amount decimal(26,8) null,
total_amount decimal(26,8) null,
customer_id bigint(20) unsigned null,
billing_email varchar(320) null,
date_created_gmt datetime null,
date_updated_gmt datetime null,
parent_order_id bigint(20) unsigned null,
payment_method varchar(100) null,
payment_method_title text null,
transaction_id varchar(100) null,
ip_address varchar(100) null,
user_agent text null,
customer_note text null,
PRIMARY KEY (id),
KEY status (status),
KEY date_created (date_created_gmt),
KEY customer_id_billing_email (customer_id, billing_email),
KEY billing_email (billing_email),
KEY type_status (type, status),
KEY parent_order_id (parent_order_id),
KEY date_updated (date_updated_gmt)
) $collate;
CREATE TABLE $addresses_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NOT NULL,
address_type varchar(20) null,
first_name text null,
last_name text null,
company text null,
address_1 text null,
address_2 text null,
city text null,
state text null,
postcode text null,
country text null,
email varchar(320) null,
phone varchar(100) null,
KEY order_id (order_id),
UNIQUE KEY address_type_order_id (address_type, order_id),
KEY email (email),
KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NULL,
created_via varchar(100) NULL,
woocommerce_version varchar(20) NULL,
prices_include_tax tinyint(1) NULL,
coupon_usages_are_counted tinyint(1) NULL,
download_permission_granted tinyint(1) NULL,
cart_hash varchar(100) NULL,
new_order_email_sent tinyint(1) NULL,
order_key varchar(100) NULL,
order_stock_reduced tinyint(1) NULL,
date_paid_gmt datetime NULL,
date_completed_gmt datetime NULL,
shipping_tax_amount decimal(26, 8) NULL,
shipping_total_amount decimal(26, 8) NULL,
discount_tax_amount decimal(26, 8) NULL,
discount_total_amount decimal(26, 8) NULL,
recorded_sales tinyint(1) NULL,
UNIQUE KEY order_id (order_id),
UNIQUE KEY order_key (order_key)
) $collate;
CREATE TABLE $meta_table (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned null,
meta_key varchar(255),
meta_value text null,
KEY meta_key_value (meta_key, meta_value(100)),
KEY order_id_meta_key_meta_value (order_id, meta_key, meta_value(100))
) $collate;
";
return $sql;
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
$raw_meta_data = $this->data_store_meta->read_meta( $object );
return $this->filter_raw_meta_data( $object, $raw_meta_data );
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing at least ->id).
*/
public function delete_meta( &$object, $meta ) {
return $this->data_store_meta->delete_meta( $object, $meta );
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
* @return int meta ID
*/
public function add_meta( &$object, $meta ) {
return $this->data_store_meta->add_meta( $object, $meta );
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->id, ->key and ->value).
*/
public function update_meta( &$object, $meta ) {
return $this->data_store_meta->update_meta( $object, $meta );
}
}
DataStores/Orders/OrdersTableRefundDataStore.php 0000644 00000012445 15233366071 0015761 0 ustar 00 <?php
/**
* Order refund data store. Refunds are based on orders (essentially negative orders) but there is slight difference in how we save them.
* For example, order save hooks etc can't be fired when saving refund, so we need to do it a separate datastore.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
/**
* Class OrdersTableRefundDataStore.
*/
class OrdersTableRefundDataStore extends OrdersTableDataStore {
/**
* We do not have and use all the getters and setters from OrderTableDataStore, so we only select the props we actually need.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
);
/**
* Delete a refund order from database.
*
* @param \WC_Order $refund Refund object to delete.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$refund, $args = array() ) {
$refund_id = $refund->get_id();
if ( ! $refund_id ) {
return;
}
$this->delete_order_data_from_custom_order_tables( $refund_id );
$refund->set_id( 0 );
// If this datastore method is called while the posts table is authoritative, refrain from deleting post data.
if ( ! is_a( $refund->get_data_store(), self::class ) ) {
return;
}
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $refund_id );
}
/**
* Read a refund object from custom tables.
*
* @param \WC_Abstract_Order $refund Refund object.
*
* @return void
*/
public function read( &$refund ) {
parent::read( $refund );
$this->set_refund_props( $refund );
}
/**
* Read multiple refund objects from custom tables.
*
* @param \WC_Order $refunds Refund objects.
*/
public function read_multiple( &$refunds ) {
parent::read_multiple( $refunds );
foreach ( $refunds as $refund ) {
$this->set_refund_props( $refund );
}
}
/**
* Helper method to set refund props.
*
* @param \WC_Order $refund Refund object.
*/
private function set_refund_props( $refund ) {
$refund->set_props(
array(
'amount' => $refund->get_meta( '_refund_amount', true ),
'refunded_by' => $refund->get_meta( '_refunded_by', true ),
'refunded_payment' => wc_string_to_bool( $refund->get_meta( '_refunded_payment', true ) ),
'reason' => $refund->get_meta( '_refund_reason', true ),
)
);
}
/**
* Method to create a refund in the database.
*
* @param \WC_Abstract_Order $refund Refund object.
*/
public function create( &$refund ) {
$refund->set_status( 'completed' ); // Refund are always marked completed.
$this->persist_save( $refund );
}
/**
* Update refund in database.
*
* @param \WC_Order $refund Refund object.
*/
public function update( &$refund ) {
$this->persist_updates( $refund );
}
/**
* Helper method that updates post meta based on an refund object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $refund Refund object.
*/
public function update_order_meta( &$refund ) {
parent::update_order_meta( $refund );
// Update additional props.
$updated_props = array();
$meta_key_to_props = array(
'_refund_amount' => 'amount',
'_refunded_by' => 'refunded_by',
'_refunded_payment' => 'refunded_payment',
'_refund_reason' => 'reason',
);
$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
foreach ( $props_to_update as $meta_key => $prop ) {
$value = $refund->{"get_$prop"}( 'edit' );
$refund->update_meta_data( $meta_key, $value );
$updated_props[] = $prop;
}
/**
* Fires after updating meta for a order refund.
*
* @since 2.7.0
*/
do_action( 'woocommerce_order_refund_object_updated_props', $refund, $updated_props );
}
/**
* Get a title for the new post type.
*
* @return string
*/
protected function get_post_title() {
return sprintf(
/* translators: %s: Order date */
__( 'Refund – %s', 'woocommerce' ),
( new \DateTime( 'now' ) )->format( _x( 'M d, Y @ h:i A', 'Order date parsed by DateTime::format', 'woocommerce' ) ) // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText
);
}
/**
* Returns data store object to use backfilling.
*
* @return \WC_Order_Refund_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Refund_Data_Store_CPT();
}
}
DataStores/Orders/OrdersTableFieldQuery.php 0000644 00000020730 15233366071 0014774 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
* complex queries against order fields in the database.
*
* @internal
*/
class OrdersTableFieldQuery {
/**
* List of valid SQL operators to use as field_query 'compare' values.
*
* @var array
*/
private const VALID_COMPARISON_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* The original query object.
*
* @var OrdersTableQuery
*/
private $query = null;
/**
* Determines whether the field query should produce no results due to an invalid argument.
*
* @var boolean
*/
private $force_no_results = false;
/**
* Holds a sanitized version of the `field_query`.
*
* @var array
*/
private $queries = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$field_query = $q->get( 'field_query' );
if ( ! $field_query || ! is_array( $field_query ) ) {
return;
}
$this->query = $q;
$this->queries = $this->sanitize_query( $field_query );
$this->where = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
}
/**
* Sanitizes the field_query argument.
*
* @param array $q A field_query array.
* @return array A sanitized field query array.
* @throws \Exception When field table info is missing.
*/
private function sanitize_query( array $q ) {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
continue;
}
// Sanitize 'compare'.
$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';
if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
$arg['compare'] = 'IN';
}
// Sanitize 'cast'.
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$field_info = $this->query->get_field_mapping_info( $arg['field'] );
if ( ! $field_info ) {
$this->force_no_results = true;
continue;
}
$arg = array_merge( $arg, $field_info );
$sanitized[ $key ] = $arg;
} else {
$sanitized_arg = $this->sanitize_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $q A field query.
* @return string An SQL WHERE statement.
*/
private function process( array $q ) {
$where = '';
if ( empty( $q ) ) {
return $where;
}
if ( $this->is_atomic( $q ) ) {
$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
$where = $this->generate_where_for_clause( $q );
} else {
$relation = $q['relation'];
unset( $q['relation'] );
foreach ( $q as $query ) {
$chunks[] = $this->process( $query );
}
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = '(' . implode( " {$relation} ", $chunks ) . ')';
}
}
return $where;
}
/**
* Checks whether a given field_query clause is atomic or not (i.e. not nested).
*
* @param array $q The field_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( $q ) {
return isset( $q['field'] );
}
/**
* Finds a common table alias that the field_query clause can use, or creates one.
*
* @param array $q An atomic field_query clause.
* @return string A table alias for use in an SQL JOIN clause.
* @throws \Exception When table info for clause is missing.
*/
private function find_or_create_table_alias_for_clause( $q ) {
global $wpdb;
if ( ! empty( $q['alias'] ) ) {
return $q['alias'];
}
if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
}
$join = '';
if ( isset( $q['mapping_id'] ) ) {
// Re-use JOINs and aliases from OrdersTableQuery for core tables.
$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
$join = $this->query->get_core_mapping_join( $q['mapping_id'] );
} else {
$alias = $q['table'];
$join = '';
}
if ( in_array( $alias, $this->table_aliases, true ) ) {
return $alias;
}
$this->table_aliases[] = $alias;
if ( $join ) {
$this->join[] = $join;
}
return $alias;
}
/**
* Returns the correct type for a given clause 'type'.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( $type ) {
$clause_type = strtoupper( $type );
if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $clause_type ) {
$clause_type = 'SIGNED';
}
return $clause_type;
}
/**
* Generates an SQL WHERE clause for a given field_query atomic clause.
*
* @param array $clause An atomic field_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause( $clause ): string {
global $wpdb;
$clause_value = $clause['value'] ?? '';
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $clause_value ) ) {
$clause_value = preg_split( '/[,\s]+/', $clause_value );
}
} elseif ( is_string( $clause_value ) ) {
$clause_value = trim( $clause_value );
}
$clause_compare = $clause['compare'];
switch ( $clause_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
break;
case 'EXISTS':
// EXISTS with a value is interpreted as '='.
if ( $clause_value ) {
$clause_compare = '=';
$where = $wpdb->prepare( '%s', $clause_value );
} else {
$clause_compare = 'IS NOT';
$where = 'NULL';
}
break;
case 'NOT EXISTS':
// 'value' is ignored for NOT EXISTS.
$clause_compare = 'IS';
$where = 'NULL';
break;
default:
$where = $wpdb->prepare( '%s', $clause_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
} else {
return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
}
}
return '';
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses() {
return array(
'join' => $this->join,
'where' => $this->where ? array( $this->where ) : array(),
);
}
}
DataStores/Orders/OrdersTableMetaQuery.php 0000644 00000040553 15233366071 0014644 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Class used to implement meta queries for the orders table datastore via {@see OrdersTableQuery}.
* Heavily inspired by WordPress' own `WP_Meta_Query` for backwards compatibility reasons.
*
* Parts of the implementation have been adapted from {@link https://core.trac.wordpress.org/browser/tags/6.0.1/src/wp-includes/class-wp-meta-query.php}.
*/
class OrdersTableMetaQuery {
/**
* List of non-numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NON_NUMERIC_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
);
/**
* List of numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NUMERIC_OPERATORS = array(
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* Prefix used when generating aliases for the metadata table.
*
* @var string
*/
private const ALIAS_PREFIX = 'meta';
/**
* Name of the main orders table.
*
* @var string
*/
private $meta_table = '';
/**
* Name of the metadata table.
*
* @var string
*/
private $orders_table = '';
/**
* Sanitized `meta_query`.
*
* @var array
*/
private $queries = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the meta query. Used to optimize JOINs when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$meta_query = $q->get( 'meta_query' );
if ( ! $meta_query ) {
return;
}
$this->queries = $this->sanitize_meta_query( $meta_query );
$this->meta_table = $q->get_table_name( 'meta' );
$this->orders_table = $q->get_table_name( 'orders' );
$this->build_query();
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => $this->sanitize_join( $this->join ),
'where' => $this->flatten_where_clauses( $this->where ),
);
}
/**
* Checks whether a given meta_query clause is atomic or not (i.e. not nested).
*
* @param array $arg The meta_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( array $arg ): bool {
return isset( $arg['key'] ) || isset( $arg['value'] );
}
/**
* Sanitizes the meta_query argument.
*
* @param array $q A meta_query array.
* @return array A sanitized meta query array.
*/
private function sanitize_meta_query( array $q ): array {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
unset( $arg['value'] );
}
$arg['compare'] = isset( $arg['compare'] ) ? strtoupper( $arg['compare'] ) : ( isset( $arg['value'] ) && is_array( $arg['value'] ) ? 'IN' : '=' );
$arg['compare_key'] = isset( $arg['compare_key'] ) ? strtoupper( $arg['compare_key'] ) : ( isset( $arg['key'] ) && is_array( $arg['key'] ) ? 'IN' : '=' );
if ( ! in_array( $arg['compare'], self::NON_NUMERIC_OPERATORS, true ) && ! in_array( $arg['compare'], self::NUMERIC_OPERATORS, true ) ) {
$arg['compare'] = '=';
}
if ( ! in_array( $arg['compare_key'], self::NON_NUMERIC_OPERATORS, true ) ) {
$arg['compare_key'] = '=';
}
$sanitized[ $key ] = $arg;
} else {
$sanitized_arg = $this->sanitize_meta_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Returns the correct type for a given meta type.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( string $type = '' ): string {
$meta_type = strtoupper( $type );
if ( ! $meta_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $meta_type ) {
$meta_type = 'SIGNED';
}
return $meta_type;
}
/**
* Makes sure a JOIN array does not have duplicates.
*
* @param array $join A JOIN array.
* @return array A sanitized JOIN array.
*/
private function sanitize_join( array $join ): array {
return array_filter( array_unique( array_map( 'trim', $join ) ) );
}
/**
* Flattens a nested WHERE array.
*
* @param array $where A possibly nested WHERE array with AND/OR operators.
* @return string An SQL WHERE clause.
*/
private function flatten_where_clauses( $where ): string {
if ( is_string( $where ) ) {
return trim( $where );
}
$chunks = array();
$operator = $this->sanitize_relation( $where['operator'] ?? '' );
foreach ( $where as $key => $w ) {
if ( 'operator' === $key ) {
continue;
}
$flattened = $this->flatten_where_clauses( $w );
if ( $flattened ) {
$chunks[] = $flattened;
}
}
if ( $chunks ) {
return '(' . implode( " {$operator} ", $chunks ) . ')';
} else {
return '';
}
}
/**
* Builds all the required internal bits for this meta query.
*
* @return void
*/
private function build_query(): void {
if ( ! $this->queries ) {
return;
}
$queries = $this->queries;
$sql_where = $this->process( $queries );
$this->where = $sql_where;
}
/**
* Processes meta_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $arg A meta query.
* @param null|array $parent The parent of the element being processed.
* @return array A nested array of WHERE conditions.
*/
private function process( array &$arg, &$parent = null ): array {
$where = array();
if ( $this->is_atomic( $arg ) ) {
$arg['alias'] = $this->find_or_create_table_alias_for_clause( $arg, $parent );
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$where = array_filter(
array(
$this->generate_where_for_clause_key( $arg ),
$this->generate_where_for_clause_value( $arg ),
)
);
} else {
// Nested.
$relation = $arg['relation'];
unset( $arg['relation'] );
foreach ( $arg as $key => &$clause ) {
$chunks[] = $this->process( $clause, $arg );
}
// Merge chunks of the form OR(m) with the surrounding clause.
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = array_merge(
array(
'operator' => $relation,
),
$chunks
);
}
}
return $where;
}
/**
* Generates a JOIN clause to handle an atomic meta_query clause.
*
* @param array $clause An atomic meta_query clause.
* @param string $alias Metadata table alias to use.
* @return string An SQL JOIN clause.
*/
private function generate_join_for_clause( array $clause, string $alias ): string {
global $wpdb;
if ( 'NOT EXISTS' === $clause['compare'] ) {
if ( 'LIKE' === $clause['compare_key'] ) {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key LIKE %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'%' . $wpdb->esc_like( $clause['key'] ) . '%'
);
} else {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$clause['key']
);
}
}
return "INNER JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id )";
}
/**
* Finds a common table alias that the meta_query clause can use, or creates one.
*
* @param array $clause An atomic meta_query clause.
* @param array $parent_query The parent query this clause is in.
* @return string A table alias for use in an SQL JOIN clause.
*/
private function find_or_create_table_alias_for_clause( array $clause, array $parent_query ): string {
if ( ! empty( $clause['alias'] ) ) {
return $clause['alias'];
}
$alias = false;
$siblings = array_filter(
$parent_query,
array( __CLASS__, 'is_atomic' )
);
foreach ( $siblings as $sibling ) {
if ( empty( $sibling['alias'] ) ) {
continue;
}
if ( $this->is_operator_compatible_with_shared_join( $clause, $sibling, $parent_query['relation'] ?? 'AND' ) ) {
$alias = $sibling['alias'];
break;
}
}
if ( ! $alias ) {
$alias = self::ALIAS_PREFIX . count( $this->table_aliases );
$this->join[] = $this->generate_join_for_clause( $clause, $alias );
$this->table_aliases[] = $alias;
}
return $alias;
}
/**
* Checks whether two meta_query clauses can share a JOIN.
*
* @param array $clause An atomic meta_query clause.
* @param array $sibling An atomic meta_query clause.
* @param string $relation The relation involving both clauses.
* @return boolean TRUE if the clauses can share a table alias, FALSE otherwise.
*/
private function is_operator_compatible_with_shared_join( array $clause, array $sibling, string $relation = 'AND' ): bool {
if ( ! $this->is_atomic( $clause ) || ! $this->is_atomic( $sibling ) ) {
return false;
}
$valid_operators = array();
if ( 'OR' === $relation ) {
$valid_operators = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
} elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
$valid_operators = array( '!=', 'NOT IN', 'NOT LIKE' );
}
return in_array( strtoupper( $clause['compare'] ), $valid_operators, true ) && in_array( strtoupper( $sibling['compare'] ), $valid_operators, true );
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta key.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_key( array $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'key', $clause ) ) {
return '';
}
if ( 'NOT EXISTS' === $clause['compare'] ) {
return "{$clause['alias']}.order_id IS NULL";
}
$alias = $clause['alias'];
if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
$i = count( $this->table_aliases );
$subquery_alias = self::ALIAS_PREFIX . $i;
$this->table_aliases[] = $subquery_alias;
$meta_compare_string_start = 'NOT EXISTS (';
$meta_compare_string_start .= "SELECT 1 FROM {$this->meta_table} {$subquery_alias} ";
$meta_compare_string_start .= "WHERE {$subquery_alias}.order_id = {$alias}.order_id ";
$meta_compare_string_end = 'LIMIT 1';
$meta_compare_string_end .= ')';
}
switch ( $clause['compare_key'] ) {
case '=':
case 'EXISTS':
$where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'LIKE':
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'IN':
$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')';
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'RLIKE':
case 'REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$where = $wpdb->prepare( "$alias.meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case '!=':
case 'NOT EXISTS':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key = %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT LIKE':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key LIKE %s " . $meta_compare_string_end;
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT IN':
$array_subclause = '(' . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ') ';
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key IN " . $array_subclause . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key REGEXP $cast %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
default:
$where = '';
break;
}
return $where;
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta value.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_value( $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'value', $clause ) ) {
return '';
}
$meta_value = $clause['value'];
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $meta_value ) ) {
$meta_value = preg_split( '/[,\s]+/', $meta_value );
}
} elseif ( is_string( $meta_value ) ) {
$meta_value = trim( $meta_value );
}
$meta_compare = $clause['compare'];
switch ( $meta_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $meta_value[0], $meta_value[1] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $meta_value ) . '%' );
break;
// EXISTS with a value is interpreted as '='.
case 'EXISTS':
$meta_compare = '=';
$where = $wpdb->prepare( '%s', $meta_value );
break;
// 'value' is ignored for NOT EXISTS.
case 'NOT EXISTS':
$where = '';
break;
default:
$where = $wpdb->prepare( '%s', $meta_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "{$clause['alias']}.meta_value {$meta_compare} {$where}";
} else {
return "CAST({$clause['alias']}.meta_value AS {$clause['cast']}) {$meta_compare} {$where}";
}
}
}
}
DataStores/CustomMetaDataStore.php 0000644 00000011656 15233366071 0013235 0 ustar 00 <?php
/**
* CustomMetaDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores;
/**
* Implements functions similar to WP's add_metadata(), get_metadata(), and friends using a custom table.
*
* @see WC_Data_Store_WP For an implementation using WP's metadata functions and tables.
*/
abstract class CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
abstract protected function get_table_name();
/**
* Returns the name of the field/column used for identifiying metadata entries.
*
* @return string
*/
protected function get_meta_id_field() {
return 'id';
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'object_id';
}
/**
* Describes the structure of the metadata table.
*
* @return array Array elements: table, object_id_field, meta_id_field.
*/
protected function get_db_info() {
return array(
'table' => $this->get_table_name(),
'meta_id_field' => $this->get_meta_id_field(),
'object_id_field' => $this->get_object_id_field(),
);
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw_meta_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']} AS meta_id, meta_key, meta_value FROM {$db_info['table']} WHERE {$db_info['object_id_field']} = %d ORDER BY meta_id",
$object->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $raw_meta_data;
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing at least ->id).
*/
public function delete_meta( &$object, $meta ) {
global $wpdb;
if ( ! isset( $meta->id ) ) {
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta->id );
return (bool) $wpdb->delete( $db_info['table'], array( $db_info['meta_id_field'] => $meta_id ) );
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
* @return int meta ID
*/
public function add_meta( &$object, $meta ) {
global $wpdb;
$db_info = $this->get_db_info();
$object_id = $object->get_id();
$meta_key = wp_unslash( wp_slash( $meta->key ) );
$meta_value = maybe_serialize( is_string( $meta->value ) ? wp_unslash( wp_slash( $meta->value ) ) : $meta->value );
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$result = $wpdb->insert(
$db_info['table'],
array(
$db_info['object_id_field'] => $object_id,
'meta_key' => $meta_key,
'meta_value' => $meta_value,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
return $result ? (int) $wpdb->insert_id : false;
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->id, ->key and ->value).
*/
public function update_meta( &$object, $meta ) {
global $wpdb;
if ( ! isset( $meta->id ) || empty( $meta->key ) ) {
return false;
}
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$data = array(
'meta_key' => $meta->key,
'meta_value' => maybe_serialize( $meta->value ),
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$db_info = $this->get_db_info();
$result = $wpdb->update(
$db_info['table'],
$data,
array( $db_info['meta_id_field'] => $meta->id ),
'%s',
'%d'
);
return 1 === $result;
}
/**
* Retrieves metadata by meta ID.
*
* @param int $meta_id Meta ID.
* @return object|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_id( $meta_id ) {
global $wpdb;
if ( ! is_numeric( $meta_id ) || floor( $meta_id ) != $meta_id ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta_id );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_row(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE {$db_info['meta_id_field']} = %d",
$meta_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
if ( isset( $meta->meta_value ) ) {
$meta->meta_value = maybe_unserialize( $meta->meta_value );
}
return $meta;
}
}