plugin updates

This commit is contained in:
Tony Volpe
2024-02-21 16:19:46 +00:00
parent c72f206574
commit 21d4c85c00
1214 changed files with 102269 additions and 179257 deletions

View File

@@ -1,78 +0,0 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template registry.
*/
final class BlockTemplateRegistry {
/**
* Class instance.
*
* @var BlockTemplateRegistry|null
*/
private static $instance = null;
/**
* Templates.
*
* @var array
*/
protected $templates = array();
/**
* Get the instance of the class.
*/
public static function get_instance(): BlockTemplateRegistry {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a single template.
*
* @param BlockTemplateInterface $template Template to register.
*
* @throws \ValueError If a template with the same ID already exists.
*/
public function register( BlockTemplateInterface $template ) {
$id = $template->get_id();
if ( isset( $this->templates[ $id ] ) ) {
throw new \ValueError( 'A template with the specified ID already exists in the registry.' );
}
/**
* Fires when a template is registered.
*
* @param BlockTemplateInterface $template Template that was registered.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_register', $template );
$this->templates[ $id ] = $template;
}
/**
* Get the registered templates.
*/
public function get_all_registered(): array {
return $this->templates;
}
/**
* Get a single registered template.
*
* @param string $id ID of the template.
*/
public function get_registered( $id ): BlockTemplateInterface {
return isset( $this->templates[ $id ] ) ? $this->templates[ $id ] : null;
}
}

View File

@@ -1,53 +0,0 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
/**
* Block template controller.
*/
class BlockTemplatesController {
/**
* Block template registry
*
* @var BlockTemplateRegistry
*/
private $block_template_registry;
/**
* Block template transformer.
*
* @var TemplateTransformer
*/
private $template_transformer;
/**
* Init.
*/
public function init( $block_template_registry, $template_transformer ) {
$this->block_template_registry = $block_template_registry;
$this->template_transformer = $template_transformer;
add_action( 'rest_api_init', array( $this, 'register_templates' ) );
}
/**
* Register templates in the blocks endpoint.
*/
public function register_templates() {
$templates = $this->block_template_registry->get_all_registered();
foreach ( $templates as $template ) {
add_filter( 'pre_get_block_templates', function( $query_result, $query, $template_type ) use( $template ) {
if ( ! isset( $query['area'] ) || $query['area'] !== $template->get_area() ) {
return $query_result;
}
$wp_block_template = $this->template_transformer->transform( $template );
$query_result[] = $wp_block_template;
return $query_result;
}, 10, 3 );
}
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Template transformer.
*/
class TemplateTransformer {
/**
* Transform the WooCommerceBlockTemplate to a WP_Block_Template.
*
* @param object $block_template The product template.
*/
public function transform( BlockTemplateInterface $block_template ): \WP_Block_Template {
$template = new \WP_Block_Template();
$template->id = $block_template->get_id();
$template->theme = 'woocommerce/woocommerce';
$template->content = $block_template->get_formatted_template();
$template->source = 'plugin';
$template->slug = $block_template->get_id();
$template->type = 'wp_template';
$template->title = $block_template->get_title();
$template->description = $block_template->get_description();
$template->status = 'publish';
$template->has_theme_file = true;
$template->origin = 'plugin';
$template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
$template->area = $block_template->get_area();
return $template;
}
}

View File

@@ -131,4 +131,19 @@ abstract class AbstractBlockTemplate implements BlockTemplateInterface {
return $inner_blocks_formatted_template;
}
/**
* Get the template as JSON like array.
*
* @return array The JSON.
*/
public function to_json(): array {
return array(
'id' => $this->get_id(),
'title' => $this->get_title(),
'description' => $this->get_description(),
'area' => $this->get_area(),
'blockTemplates' => $this->get_formatted_template(),
);
}
}

View File

@@ -210,25 +210,35 @@ class BlockTemplateLogger {
}
/**
* Get all template events for a given template.
* Get all template events for a given template as a JSON like array.
*
* @param string $template_id Template ID.
*/
public function get_formatted_template_events( string $template_id ): array {
public function template_events_to_json( string $template_id ): array {
if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
return array();
}
$template_events = $this->all_template_events[ $template_id ];
$template = $this->templates[ $template_id ];
$formatted_template_events = array();
return $this->to_json( $template_events );
}
/**
* Get all template events as a JSON like array.
*
* @param array $template_events Template events.
*
* @return array The JSON.
*/
private function to_json( array $template_events ): array {
$json = array();
foreach ( $template_events as $template_event ) {
$container = $template_event['container'];
$block = $template_event['block'];
$formatted_template_events[] = array(
$json[] = array(
'level' => $template_event['level'],
'event_type' => $template_event['event_type'],
'message' => $template_event['message'],
@@ -246,7 +256,7 @@ class BlockTemplateLogger {
);
}
return $formatted_template_events;
return $json;
}
/**
@@ -311,7 +321,7 @@ class BlockTemplateLogger {
* @param array $template_events Template events.
*/
private function generate_template_events_hash( array $template_events ): string {
return md5( wp_json_encode( $template_events ) );
return md5( wp_json_encode( $this->to_json( $template_events ) ) );
}
/**

View File

@@ -8,7 +8,6 @@ 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\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\Admin\Settings;
@@ -73,7 +72,6 @@ class Loader {
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
wc_get_container()->get( BlockTemplatesController::class );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );

View File

@@ -86,7 +86,7 @@ class FileController {
* Class FileController
*/
public function __construct() {
$this->log_directory = trailingslashit( realpath( Constants::get_constant( 'WC_LOG_DIR' ) ) );
$this->log_directory = trailingslashit( Constants::get_constant( 'WC_LOG_DIR' ) );
}
/**

View File

@@ -3,8 +3,7 @@
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController };
use WC_Log_Handler;
/**
@@ -18,11 +17,19 @@ class LogHandlerFileV2 extends WC_Log_Handler {
*/
private $file_controller;
/**
* Instance of the Settings class.
*
* @var Settings
*/
private $settings;
/**
* LogHandlerFileV2 class.
*/
public function __construct() {
$this->file_controller = wc_get_container()->get( FileController::class );
$this->settings = wc_get_container()->get( Settings::class );
}
/**
@@ -73,20 +80,17 @@ class LogHandlerFileV2 extends WC_Log_Handler {
$time_string = static::format_time( $timestamp );
$level_string = strtoupper( $level );
// Remove line breaks so the whole entry is on one line in the file.
$formatted_message = str_replace( PHP_EOL, ' ', $message );
unset( $context['source'] );
if ( ! empty( $context ) ) {
if ( isset( $context['backtrace'] ) && true === filter_var( $context['backtrace'], FILTER_VALIDATE_BOOLEAN ) ) {
$context['backtrace'] = static::get_backtrace();
}
$formatted_context = wp_json_encode( $context );
$formatted_message .= " CONTEXT: $formatted_context";
$formatted_context = wp_json_encode( $context );
$message .= " CONTEXT: $formatted_context";
}
$entry = "$time_string $level_string $formatted_message";
$entry = "$time_string $level_string $message";
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This filter is documented in includes/abstracts/abstract-wc-log-handler.php */
@@ -152,6 +156,63 @@ class LogHandlerFileV2 extends WC_Log_Handler {
return sanitize_title( $source );
}
/**
* Delete all logs from a specific source.
*
* @param string $source The source of the log entries.
*
* @return int The number of files that were deleted.
*/
public function clear( string $source ): int {
$source = File::sanitize_source( $source );
$files = $this->file_controller->get_files(
array(
'source' => $source,
)
);
if ( is_wp_error( $files ) || count( $files ) < 1 ) {
return 0;
}
$file_ids = array_map(
fn( $file ) => $file->get_file_id(),
$files
);
$deleted = $this->file_controller->delete_files( $file_ids );
if ( $deleted > 0 ) {
$this->handle(
time(),
'info',
sprintf(
esc_html(
// translators: %1$s is a number of log files, %2$s is a slug-style name for a file.
_n(
'%1$s log file from source %2$s was deleted.',
'%1$s log files from source %2$s were deleted.',
$deleted,
'woocommerce'
)
),
number_format_i18n( $deleted ),
sprintf(
'<code>%s</code>',
esc_html( $source )
)
),
array(
'source' => 'wc_logger',
'backtrace' => true,
)
);
}
return $deleted;
}
/**
* Delete all logs older than a specified timestamp.
*
@@ -172,7 +233,7 @@ class LogHandlerFileV2 extends WC_Log_Handler {
)
);
if ( is_wp_error( $files ) ) {
if ( is_wp_error( $files ) || count( $files ) < 1 ) {
return 0;
}
@@ -181,12 +242,8 @@ class LogHandlerFileV2 extends WC_Log_Handler {
$files
);
$deleted = $this->file_controller->delete_files( $file_ids );
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This filter is documented in includes/class-wc-logger.php. */
$retention_days = absint( apply_filters( 'woocommerce_logger_days_to_retain_logs', 30 ) );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$deleted = $this->file_controller->delete_files( $file_ids );
$retention_days = $this->settings->get_retention_period();
if ( $deleted > 0 ) {
$this->handle(

View File

@@ -4,7 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, Settings };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController, FileListTable, SearchListTable };
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WC_Admin_Status;
@@ -26,6 +26,13 @@ class PageController {
*/
private $file_controller;
/**
* Instance of Settings.
*
* @var Settings
*/
private $settings;
/**
* Instance of FileListTable or SearchListTable.
*
@@ -39,13 +46,16 @@ class PageController {
* @internal
*
* @param FileController $file_controller Instance of FileController.
* @param Settings $settings Instance of Settings.
*
* @return void
*/
final public function init(
FileController $file_controller
FileController $file_controller,
Settings $settings
): void {
$this->file_controller = $file_controller;
$this->settings = $settings;
$this->init_hooks();
}
@@ -56,8 +66,61 @@ class PageController {
* @return void
*/
private function init_hooks(): void {
self::add_action( 'load-woocommerce_page_wc-status', array( $this, 'setup_screen_options' ) );
self::add_action( 'load-woocommerce_page_wc-status', array( $this, 'handle_list_table_bulk_actions' ) );
self::add_action( 'load-woocommerce_page_wc-status', array( $this, 'maybe_do_logs_tab_action' ), 2 );
self::add_action( 'wc_logs_load_tab', array( $this, 'setup_screen_options' ) );
self::add_action( 'wc_logs_load_tab', array( $this, 'handle_list_table_bulk_actions' ) );
self::add_action( 'wc_logs_load_tab', array( $this, 'notices' ) );
}
/**
* Determine if the current tab on the Status page is Logs, and if so, fire an action.
*
* @return void
*/
private function maybe_do_logs_tab_action(): void {
$is_logs_tab = 'logs' === filter_input( INPUT_GET, 'tab' );
if ( $is_logs_tab ) {
$params = $this->get_query_params( array( 'view' ) );
/**
* Action fires when the Logs tab starts loading.
*
* @param string $view The current view within the Logs tab.
*
* @since 8.6.0
*/
do_action( 'wc_logs_load_tab', $params['view'] );
}
}
/**
* Notices to display on Logs screens.
*
* @return void
*/
private function notices() {
if ( ! $this->settings->logging_is_enabled() ) {
add_action(
'admin_notices',
function() {
?>
<div class="notice notice-warning">
<p>
<?php
printf(
// translators: %s is a URL to another admin screen.
wp_kses_post( __( 'Logging is disabled. It can be enabled in <a href="%s">Logs Settings</a>.', 'woocommerce' ) ),
esc_url( add_query_arg( 'view', 'settings', $this->get_logs_tab_url() ) )
);
?>
</p>
</div>
<?php
}
);
}
}
/**
@@ -75,40 +138,83 @@ class PageController {
);
}
/**
* Determine the default log handler.
*
* @return string
*/
public function get_default_handler(): string {
$handler = Constants::get_constant( 'WC_LOG_HANDLER' );
if ( is_null( $handler ) || ! class_exists( $handler ) ) {
$handler = WC_Log_Handler_File::class;
}
return $handler;
}
/**
* Render the "Logs" tab, depending on the current default log handler.
*
* @return void
*/
public function render(): void {
$handler = $this->get_default_handler();
$handler = $this->settings->get_default_handler();
$params = $this->get_query_params( array( 'view' ) );
$this->render_section_nav();
if ( 'settings' === $params['view'] ) {
$this->settings->render_form();
return;
}
switch ( $handler ) {
case LogHandlerFileV2::class:
$this->render_filev2();
break;
case 'WC_Log_Handler_DB':
return;
case WC_Log_Handler_DB::class:
WC_Admin_Status::status_logs_db();
break;
default:
return;
case WC_Log_Handler_File::class:
WC_Admin_Status::status_logs_file();
break;
return;
}
/**
* Action fires only if there is not a built-in rendering method for the current default log handler.
*
* This is intended as a way for extensions to render log views for custom handlers.
*
* @param string $handler
*
* @since 8.6.0
*/
do_action( 'wc_logs_render_page', $handler );
}
/**
* Render navigation to switch between logs browsing and settings.
*
* @return void
*/
private function render_section_nav(): void {
$params = $this->get_query_params( array( 'view' ) );
$browse_url = $this->get_logs_tab_url();
$settings_url = add_query_arg( 'view', 'settings', $this->get_logs_tab_url() );
?>
<ul class="subsubsub">
<li>
<?php
printf(
'<a href="%1$s"%2$s>%3$s</a>',
esc_url( $browse_url ),
'settings' !== $params['view'] ? ' class="current"' : '',
esc_html__( 'Browse', 'woocommerce' )
);
?>
|
</li>
<li>
<?php
printf(
'<a href="%1$s"%2$s>%3$s</a>',
esc_url( $settings_url ),
'settings' === $params['view'] ? ' class="current"' : '',
esc_html__( 'Settings', 'woocommerce' )
);
?>
</li>
</ul>
<br class="clear">
<?php
}
/**
@@ -294,6 +400,17 @@ class PageController {
?>
<?php endwhile; ?>
</section>
<script>
// Clear the line number hash and highlight with a click.
document.documentElement.addEventListener( 'click', ( event ) => {
if ( window.location.hash && ! event.target.classList.contains( 'line-anchor' ) ) {
let scrollPos = document.documentElement.scrollTop;
window.location.hash = '';
document.documentElement.scrollTop = scrollPos;
history.replaceState( null, '', window.location.pathname + window.location.search );
}
} );
</script>
<?php
}
@@ -303,7 +420,7 @@ class PageController {
* @return void
*/
private function render_search_results_view(): void {
$params = $this->get_query_params( array( 'order', 'orderby', 'search', 'source', 'view' ) );
$params = $this->get_query_params( array( 'view' ) );
$list_table = $this->get_list_table( $params['view'] );
$list_table->prepare_items();
@@ -380,7 +497,7 @@ class PageController {
'view' => array(
'filter' => FILTER_VALIDATE_REGEXP,
'options' => array(
'regexp' => '/^(list_files|single_file|search_results)$/',
'regexp' => '/^(list_files|single_file|search_results|settings)$/',
'default' => $defaults['view'],
),
),
@@ -423,17 +540,18 @@ class PageController {
/**
* Register screen options for the logging views.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*/
private function setup_screen_options(): void {
$params = $this->get_query_params( array( 'view' ) );
$handler = $this->get_default_handler();
private function setup_screen_options( string $view ): void {
$handler = $this->settings->get_default_handler();
$list_table = null;
switch ( $handler ) {
case LogHandlerFileV2::class:
if ( in_array( $params['view'], array( 'list_files', 'search_results' ), true ) ) {
$list_table = $this->get_list_table( $params['view'] );
if ( in_array( $view, array( 'list_files', 'search_results' ), true ) ) {
$list_table = $this->get_list_table( $view );
}
break;
case 'WC_Log_Handler_DB':
@@ -458,22 +576,24 @@ class PageController {
/**
* Process bulk actions initiated from the log file list table.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*/
private function handle_list_table_bulk_actions(): void {
private function handle_list_table_bulk_actions( string $view ): void {
// Bail if we're not using the file handler.
if ( LogHandlerFileV2::class !== $this->get_default_handler() ) {
if ( LogHandlerFileV2::class !== $this->settings->get_default_handler() ) {
return;
}
$params = $this->get_query_params( array( 'file_id', 'view' ) );
$params = $this->get_query_params( array( 'file_id' ) );
// Bail if this is not the list table view.
if ( 'list_files' !== $params['view'] ) {
if ( 'list_files' !== $view ) {
return;
}
$action = $this->get_list_table( $params['view'] )->current_action();
$action = $this->get_list_table( $view )->current_action();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : $this->get_logs_tab_url();
@@ -562,10 +682,9 @@ class PageController {
* @return string
*/
private function format_line( string $line, int $line_number ): string {
$severity_levels = WC_Log_Levels::get_all_severity_levels();
$classes = array( 'line' );
$classes = array( 'line' );
$line = esc_html( trim( $line ) );
$line = esc_html( $line );
if ( empty( $line ) ) {
$line = '&nbsp;';
}
@@ -583,11 +702,11 @@ class PageController {
$has_timestamp = true;
}
if ( isset( $segments[1] ) && in_array( strtolower( $segments[1] ), $severity_levels, true ) ) {
if ( isset( $segments[1] ) && WC_Log_Levels::is_valid_level( strtolower( $segments[1] ) ) ) {
$segments[1] = sprintf(
'<span class="%1$s">%2$s</span>',
esc_attr( 'log-level log-level--' . strtolower( $segments[1] ) ),
esc_html( $segments[1] )
esc_html( WC_Log_Levels::get_level_label( strtolower( $segments[1] ) ) )
);
$has_level = true;
}
@@ -600,7 +719,7 @@ class PageController {
$context = json_decode( $maybe_json, false, 512, JSON_THROW_ON_ERROR );
$message_chunks[1] = sprintf(
'<details><summary>%1$s</summary><pre>%2$s</pre></details>',
'<details><summary>%1$s</summary>%2$s</details>',
esc_html__( 'Additional context', 'woocommerce' ),
wp_json_encode( $context, JSON_PRETTY_PRINT )
);

View File

@@ -0,0 +1,449 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WC_Admin_Settings;
use WC_Log_Handler, WC_Log_Handler_DB, WC_Log_Handler_File, WC_Log_Levels;
/**
* Settings class.
*/
class Settings {
use AccessiblePrivateMethods;
/**
* Default values for logging settings.
*
* @const array
*/
private const DEFAULTS = array(
'logging_enabled' => true,
'default_handler' => LogHandlerFileV2::class,
'retention_period_days' => 30,
'level_threshold' => 'none',
'file_entry_collapse_lines' => true,
);
/**
* The prefix for settings keys used in the options table.
*
* @const string
*/
private const PREFIX = 'woocommerce_logs_';
/**
* Class Settings.
*/
public function __construct() {
self::add_action( 'wc_logs_load_tab', array( $this, 'save_settings' ) );
}
/**
* The definitions used by WC_Admin_Settings to render and save settings controls.
*
* @return array
*/
private function get_settings_definitions(): array {
$settings = array(
'start' => array(
'title' => __( 'Logs settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'logging_enabled' => array(
'title' => __( 'Logger', 'woocommerce' ),
'desc' => __( 'Enable logging', 'woocommerce' ),
'id' => self::PREFIX . 'logging_enabled',
'type' => 'checkbox',
'value' => $this->logging_is_enabled() ? 'yes' : 'no',
'default' => self::DEFAULTS['logging_enabled'] ? 'yes' : 'no',
'autoload' => false,
),
'default_handler' => array(),
'retention_period_days' => array(),
'level_threshold' => array(),
'end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
if ( true === $this->logging_is_enabled() ) {
$settings['default_handler'] = $this->get_default_handler_setting_definition();
$settings['retention_period_days'] = $this->get_retention_period_days_setting_definition();
$settings['level_threshold'] = $this->get_level_threshold_setting_definition();
}
$default_handler = $this->get_default_handler();
if ( in_array( $default_handler, array( LogHandlerFileV2::class, WC_Log_Handler_File::class ), true ) ) {
$settings += $this->get_filesystem_settings_definitions();
} elseif ( WC_Log_Handler_DB::class === $default_handler ) {
$settings += $this->get_database_settings_definitions();
}
return $settings;
}
/**
* The definition for the default_handler setting.
*
* @return array
*/
private function get_default_handler_setting_definition(): array {
$handler_options = array(
LogHandlerFileV2::class => __( 'File system (default)', 'woocommerce' ),
WC_Log_Handler_DB::class => __( 'Database (not recommended on live sites)', 'woocommerce' ),
);
/**
* Filter the list of logging handlers that can be set as the default handler.
*
* @param array $handler_options An associative array of class_name => description.
*
* @since 8.6.0
*/
$handler_options = apply_filters( 'woocommerce_logger_handler_options', $handler_options );
$current_value = $this->get_default_handler();
if ( ! array_key_exists( $current_value, $handler_options ) ) {
$handler_options[ $current_value ] = $current_value;
}
$desc = array();
$desc[] = __( 'Note that if this setting is changed, any log entries that have already been recorded will remain stored in their current location, but will not migrate.', 'woocommerce' );
$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_HANDLER' ) );
if ( $hardcoded ) {
$desc[] = sprintf(
// translators: %s is the name of a code variable.
__( 'This setting cannot be changed here because it is defined in the %s constant.', 'woocommerce' ),
'<code>WC_LOG_HANDLER</code>'
);
}
return array(
'title' => __( 'Log storage', 'woocommerce' ),
'desc_tip' => __( 'This determines where log entries are saved.', 'woocommerce' ),
'id' => self::PREFIX . 'default_handler',
'type' => 'radio',
'value' => $current_value,
'default' => self::DEFAULTS['default_handler'],
'autoload' => false,
'options' => $handler_options,
'disabled' => $hardcoded ? array_keys( $handler_options ) : array(),
'desc' => implode( '<br><br>', $desc ),
'desc_at_end' => true,
);
}
/**
* The definition for the retention_period_days setting.
*
* @return array
*/
private function get_retention_period_days_setting_definition(): array {
$custom_attributes = array(
'min' => 1,
'step' => 1,
);
$hardcoded = has_filter( 'woocommerce_logger_days_to_retain_logs' );
$desc = '';
if ( $hardcoded ) {
$custom_attributes['disabled'] = 'true';
$desc = sprintf(
// translators: %s is the name of a filter hook.
__( 'This setting cannot be changed here because it is being set by a filter on the %s hook.', 'woocommerce' ),
'<code>woocommerce_logger_days_to_retain_logs</code>'
);
}
return array(
'title' => __( 'Retention period', 'woocommerce' ),
'desc_tip' => __( 'This sets how many days log entries will be kept before being auto-deleted.', 'woocommerce' ),
'id' => self::PREFIX . 'retention_period_days',
'type' => 'number',
'value' => $this->get_retention_period(),
'default' => self::DEFAULTS['retention_period_days'],
'autoload' => false,
'custom_attributes' => $custom_attributes,
'css' => 'width:70px;',
'row_class' => 'logs-retention-period-days',
'suffix' => sprintf(
' %s',
__( 'days', 'woocommerce' ),
),
'desc' => $desc,
);
}
/**
* The definition for the level_threshold setting.
*
* @return array
*/
private function get_level_threshold_setting_definition(): array {
$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_THRESHOLD' ) );
$desc = '';
if ( $hardcoded ) {
$desc = sprintf(
// translators: %1$s is the name of a code variable. %2$s is the name of a file.
__( 'This setting cannot be changed here because it is defined in the %1$s constant, probably in your %2$s file.', 'woocommerce' ),
'<code>WC_LOG_THRESHOLD</code>',
'<b>wp-config.php</b>'
);
}
$labels = WC_Log_Levels::get_all_level_labels();
$labels['none'] = __( 'None', 'woocommerce' );
$custom_attributes = array();
if ( $hardcoded ) {
$custom_attributes['disabled'] = 'true';
}
return array(
'title' => __( 'Level threshold', 'woocommerce' ),
'desc_tip' => __( 'This sets the minimum severity level of logs that will be stored. Lower severity levels will be ignored. "None" means all logs will be stored.', 'woocommerce' ),
'id' => self::PREFIX . 'level_threshold',
'type' => 'select',
'value' => $this->get_level_threshold(),
'default' => self::DEFAULTS['level_threshold'],
'autoload' => false,
'options' => $labels,
'custom_attributes' => $custom_attributes,
'css' => 'width:auto;',
'desc' => $desc,
);
}
/**
* The definitions used by WC_Admin_Settings to render settings related to filesystem log handlers.
*
* @return array
*/
private function get_filesystem_settings_definitions(): array {
$location_info = array();
$directory = trailingslashit( realpath( Constants::get_constant( 'WC_LOG_DIR' ) ) );
$location_info[] = sprintf(
// translators: %s is a location in the filesystem.
__( 'Log files are stored in this directory: %s', 'woocommerce' ),
sprintf(
'<code>%s</code>',
esc_html( $directory )
)
);
if ( ! wp_is_writable( $directory ) ) {
$location_info[] = __( '⚠️ This directory does not appear to be writable.', 'woocommerce' );
}
$location_info[] = sprintf(
// translators: %1$s is a code variable. %2$s is the name of a file.
__( 'Change the location by defining the %1$s constant in your %2$s file with a new path.', 'woocommerce' ),
'<code>WC_LOG_DIR</code>',
'<code>wp-config.php</code>'
);
return array(
'file_start' => array(
'title' => __( 'File system settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'log_directory' => array(
'type' => 'info',
'text' => implode( "\n\n", $location_info ),
),
'entry_format' => array(),
'file_end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
}
/**
* The definitions used by WC_Admin_Settings to render settings related to database log handlers.
*
* @return array
*/
private function get_database_settings_definitions(): array {
global $wpdb;
$table = "{$wpdb->prefix}woocommerce_log";
$location_info = sprintf(
// translators: %s is a location in the filesystem.
__( 'Log entries are stored in this database table: %s', 'woocommerce' ),
"<code>$table</code>"
);
return array(
'file_start' => array(
'title' => __( 'Database settings', 'woocommerce' ),
'id' => self::PREFIX . 'settings',
'type' => 'title',
),
'database_table' => array(
'type' => 'info',
'text' => $location_info,
),
'file_end' => array(
'id' => self::PREFIX . 'settings',
'type' => 'sectionend',
),
);
}
/**
* Handle the submission of the settings form and update the settings values.
*
* @param string $view The current view within the Logs tab.
*
* @return void
*/
private function save_settings( string $view ): void {
$is_saving = 'settings' === $view && isset( $_POST['save_settings'] );
if ( $is_saving ) {
check_admin_referer( self::PREFIX . 'settings' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to manage logging settings.', 'woocommerce' ) );
}
$settings = $this->get_settings_definitions();
WC_Admin_Settings::save_fields( $settings );
}
}
/**
* Render the settings page.
*
* @return void
*/
public function render_form(): void {
$settings = $this->get_settings_definitions();
?>
<form id="mainform" class="wc-logs-settings" method="post">
<?php WC_Admin_Settings::output_fields( $settings ); ?>
<?php
/**
* Action fires after the built-in logging settings controls have been rendered.
*
* This is intended as a way to allow other logging settings controls to be added by extensions.
*
* @param bool $enabled True if logging is currently enabled.
*
* @since 8.6.0
*/
do_action( 'wc_logs_settings_form_fields', $this->logging_is_enabled() );
?>
<?php wp_nonce_field( self::PREFIX . 'settings' ); ?>
<?php submit_button( __( 'Save changes', 'woocommerce' ), 'primary', 'save_settings' ); ?>
</form>
<?php
}
/**
* Determine the current value of the logging_enabled setting.
*
* @return bool
*/
public function logging_is_enabled(): bool {
$key = self::PREFIX . 'logging_enabled';
$enabled = WC_Admin_Settings::get_option( $key, self::DEFAULTS['logging_enabled'] );
$enabled = filter_var( $enabled, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
if ( is_null( $enabled ) ) {
$enabled = self::DEFAULTS['logging_enabled'];
}
return $enabled;
}
/**
* Determine the current value of the default_handler setting.
*
* @return string
*/
public function get_default_handler(): string {
$key = self::PREFIX . 'default_handler';
$handler = Constants::get_constant( 'WC_LOG_HANDLER' );
if ( is_null( $handler ) ) {
$handler = WC_Admin_Settings::get_option( $key );
}
if ( ! class_exists( $handler ) || ! is_a( $handler, 'WC_Log_Handler_Interface', true ) ) {
$handler = self::DEFAULTS['default_handler'];
}
return $handler;
}
/**
* Determine the current value of the retention_period_days setting.
*
* @return int
*/
public function get_retention_period(): int {
$key = self::PREFIX . 'retention_period_days';
$retention_period = self::DEFAULTS['retention_period_days'];
if ( has_filter( 'woocommerce_logger_days_to_retain_logs' ) ) {
/**
* Filter the retention period of log entries.
*
* @param int $days The number of days to retain log entries.
*
* @since 3.4.0
*/
$retention_period = apply_filters( 'woocommerce_logger_days_to_retain_logs', $retention_period );
} else {
$retention_period = WC_Admin_Settings::get_option( $key );
}
$retention_period = absint( $retention_period );
if ( $retention_period < 1 ) {
$retention_period = self::DEFAULTS['retention_period_days'];
}
return $retention_period;
}
/**
* Determine the current value of the level_threshold setting.
*
* @return string
*/
public function get_level_threshold(): string {
$key = self::PREFIX . 'level_threshold';
$threshold = Constants::get_constant( 'WC_LOG_THRESHOLD' );
if ( is_null( $threshold ) ) {
$threshold = WC_Admin_Settings::get_option( $key );
}
if ( ! WC_Log_Levels::is_valid_level( $threshold ) ) {
$threshold = self::DEFAULTS['level_threshold'];
}
return $threshold;
}
}

View File

@@ -16,6 +16,20 @@ class Marketing {
use CouponsMovedTrait;
/**
* Constant representing the key for the submenu name value in the global $submenu array.
*
* @var int
*/
const SUBMENU_NAME_KEY = 0;
/**
* Constant representing the key for the submenu location value in the global $submenu array.
*
* @var int
*/
const SUBMENU_LOCATION_KEY = 2;
/**
* Class instance.
*
@@ -44,6 +58,9 @@ class Marketing {
add_action( 'admin_menu', array( $this, 'register_pages' ), 5 );
add_action( 'admin_menu', array( $this, 'add_parent_menu_item' ), 6 );
// Overwrite submenu default ordering for marketing menu. High priority gives plugins the chance to register their own menu items.
add_action( 'admin_menu', array( $this, 'reorder_marketing_submenu' ), 99 );
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 30 );
}
@@ -140,6 +157,67 @@ class Marketing {
}
}
/**
* Order marketing menu items alphabeticaly.
* Overview should be first, and Coupons should be second, followed by other marketing menu items.
*
* @return void
*/
public function reorder_marketing_submenu() {
global $submenu;
if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
return;
}
$marketing_submenu = $submenu['woocommerce-marketing'];
$new_menu_order = array();
// Overview should be first.
$overview_key = array_search( 'Overview', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );
if ( false === $overview_key ) {
/*
* If Overview is not found we may be on a site witha different language.
* We can use a fallback and try to find the overview page by its path.
*/
$overview_key = array_search( 'admin.php?page=wc-admin&path=/marketing', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
}
if ( false !== $overview_key ) {
$new_menu_order[] = $marketing_submenu[ $overview_key ];
array_splice( $marketing_submenu, $overview_key, 1 );
}
// Coupons should be second.
$coupons_key = array_search( 'Coupons', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );
if ( false === $coupons_key ) {
/*
* If Coupons is not found we may be on a site witha different language.
* We can use a fallback and try to find the coupons page by its path.
*/
$coupons_key = array_search( 'edit.php?post_type=shop_coupon', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
}
if ( false !== $coupons_key ) {
$new_menu_order[] = $marketing_submenu[ $coupons_key ];
array_splice( $marketing_submenu, $coupons_key, 1 );
}
// Sort the rest of the items alphabetically.
usort(
$marketing_submenu,
function( $a, $b ) {
return strcmp( $a[0], $b[0] );
}
);
$new_menu_order = array_merge( $new_menu_order, $marketing_submenu );
$submenu['woocommerce-marketing'] = $new_menu_order; //phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
/**
* Add settings for marketing feature.
*

View File

@@ -14,13 +14,6 @@ namespace Automattic\WooCommerce\Internal\Admin\Marketing;
* @since x.x.x
*/
class MarketingSpecs {
/**
* Name of recommended plugins transient.
*
* @var string
*/
const RECOMMENDED_PLUGINS_TRANSIENT = 'wc_marketing_recommended_plugins';
/**
* Name of knowledge base post transient.
*
@@ -28,111 +21,6 @@ class MarketingSpecs {
*/
const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base';
/**
* Slug of the category specifying marketing extensions on the Woo.com store.
*
* @var string
*/
const MARKETING_EXTENSION_CATEGORY_SLUG = 'marketing';
/**
* Slug of the subcategory specifying marketing channels on the Woo.com store.
*
* @var string
*/
const MARKETING_CHANNEL_SUBCATEGORY_SLUG = 'sales-channels';
/**
* Load recommended plugins from Woo.com
*
* @return array
*/
public function get_recommended_plugins(): array {
$plugins = get_transient( self::RECOMMENDED_PLUGINS_TRANSIENT );
if ( false === $plugins ) {
$request = wp_remote_get(
'https://woocommerce.com/wp-json/wccom/marketing-tab/1.3/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 );
}
/**
* Return only the recommended marketing channels from Woo.com.
*
* @return array
*/
public function get_recommended_marketing_channels(): array {
return array_filter( $this->get_recommended_plugins(), [ $this, 'is_marketing_channel_plugin' ] );
}
/**
* Return all recommended marketing extensions EXCEPT the marketing channels from Woo.com.
*
* @return array
*/
public function get_recommended_marketing_extensions_excluding_channels(): array {
return array_filter(
$this->get_recommended_plugins(),
function ( array $plugin_data ) {
return $this->is_marketing_plugin( $plugin_data ) && ! $this->is_marketing_channel_plugin( $plugin_data );
}
);
}
/**
* Returns whether a plugin is a marketing extension.
*
* @param array $plugin_data The plugin properties returned by the API.
*
* @return bool
*/
protected function is_marketing_plugin( array $plugin_data ): bool {
$categories = $plugin_data['categories'] ?? [];
return in_array( self::MARKETING_EXTENSION_CATEGORY_SLUG, $categories, true );
}
/**
* Returns whether a plugin is a marketing channel.
*
* @param array $plugin_data The plugin properties returned by the API.
*
* @return bool
*/
protected function is_marketing_channel_plugin( array $plugin_data ): bool {
if ( ! $this->is_marketing_plugin( $plugin_data ) ) {
return false;
}
$subcategories = $plugin_data['subcategories'] ?? [];
foreach ( $subcategories as $subcategory ) {
if ( isset( $subcategory['slug'] ) && self::MARKETING_CHANNEL_SUBCATEGORY_SLUG === $subcategory['slug'] ) {
return true;
}
}
return false;
}
/**
* Load knowledge base posts from Woo.com
*
@@ -165,21 +53,21 @@ class MarketingSpecs {
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$posts = [];
$posts = array();
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$raw_posts = json_decode( $request['body'], true );
foreach ( $raw_posts as $raw_post ) {
$post = [
$post = array(
'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'] ?? [];
$featured_media = isset( $raw_post['_embedded']['wp:featuredmedia'] ) && is_array( $raw_post['_embedded']['wp:featuredmedia'] ) ? $raw_post['_embedded']['wp:featuredmedia'] : array();
if ( count( $featured_media ) > 0 ) {
$image = current( $featured_media );
$post['image'] = add_query_arg(

View File

@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\OrderAttribution;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
/**
@@ -250,6 +251,15 @@ class Edit {
'high'
);
// Add customer history meta box if analytics is enabled.
if ( 'yes' !== get_option( 'woocommerce_analytics_enabled' ) ) {
return;
}
if ( ! OrderUtil::is_order_edit_screen() ) {
return;
}
/**
* Customer history meta box.
*
@@ -260,7 +270,7 @@ class Edit {
add_meta_box(
'woocommerce-customer-history',
__( 'Customer history', 'woocommerce' ),
function( $post_or_order ) use ( $customer_history_meta_box ) {
function ( $post_or_order ) use ( $customer_history_meta_box ) {
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
if ( $order instanceof WC_Order ) {
$customer_history_meta_box->output( $order );

View File

@@ -372,7 +372,7 @@ class ListTable extends WP_List_Table {
'type' => $this->order_type,
);
foreach ( array( 'status', 's', 'm', '_customer_user' ) as $query_var ) {
foreach ( array( 'status', 's', 'm', '_customer_user', 'search-filter' ) as $query_var ) {
$this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
}
@@ -532,6 +532,11 @@ class ListTable extends WP_List_Table {
$this->order_query_args['s'] = $search_term;
$this->has_filter = true;
}
$filter = trim( sanitize_text_field( $this->request['search-filter'] ) );
if ( ! empty( $filter ) ) {
$this->order_query_args['search_filter'] = $filter;
}
}
/**
@@ -541,8 +546,22 @@ class ListTable extends WP_List_Table {
* @return array
*/
public function get_views() {
$view_links = array();
/**
* Filters the list of available list table view links before the actual query runs.
* This can be used to, e.g., remove counts from the links.
*
* @since 8.6.0
*
* @param string[] $views An array of available list table view links.
*/
$view_links = apply_filters( 'woocommerce_before_' . $this->order_type . '_list_table_view_links', $view_links );
if ( ! empty( $view_links ) ) {
return $view_links;
}
$view_counts = array();
$view_links = array();
$statuses = $this->get_visible_statuses();
$current = ! empty( $this->request['status'] ) ? sanitize_text_field( $this->request['status'] ) : 'all';
$all_count = 0;
@@ -620,6 +639,24 @@ class ListTable extends WP_List_Table {
* @return boolean TRUE when the blank state should be rendered, FALSE otherwise.
*/
private function should_render_blank_state(): bool {
/**
* Whether we should render a blank state so that custom count queries can be used.
*
* @since 8.6.0
*
* @param null $should_render_blank_state `null` will use the built-in counts. Sending a boolean will short-circuit that path.
* @param object ListTable The current instance of the class.
*/
$should_render_blank_state = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_should_render_blank_state',
null,
$this
);
if ( is_bool( $should_render_blank_state ) ) {
return $should_render_blank_state;
}
return ( ! $this->has_filter ) && 0 === $this->count_orders_by_status( array_keys( $this->get_visible_statuses() ) );
}
@@ -719,11 +756,22 @@ class ListTable extends WP_List_Table {
private function months_filter() {
// XXX: [review] we may prefer to move this logic outside of the ListTable class.
/**
* Filters whether to remove the 'Months' drop-down from the order list table.
*
* @since 8.6.0
*
* @param bool $disable Whether to disable the drop-down. Default false.
*/
if ( apply_filters( 'woocommerce_' . $this->order_type . '_list_table_disable_months_filter', false ) ) {
return;
}
global $wp_locale;
global $wpdb;
$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
$utc_offset = wc_timezone_offset();
$utc_offset = wc_timezone_offset();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_dates = $wpdb->get_results(
@@ -1347,7 +1395,7 @@ class ListTable extends WP_List_Table {
* @return int Number of orders that were trashed.
*/
private function do_delete( array $ids, bool $force_delete = false ): int {
$changed = 0;
$changed = 0;
foreach ( $ids as $id ) {
$order = wc_get_order( $id );
@@ -1537,4 +1585,55 @@ class ListTable extends WP_List_Table {
return $html;
}
/**
* Renders the search box with various options to limit order search results.
*
* @param string $text The search button text.
* @param string $input_id The search input ID.
*
* @return void
*/
public function search_box( $text, $input_id ) {
if ( empty( $_REQUEST['s'] ) && ! $this->has_items() ) {
return;
}
$input_id = $input_id . '-search-input';
if ( ! empty( $_REQUEST['orderby'] ) ) {
echo '<input type="hidden" name="orderby" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) ) . '" />';
}
if ( ! empty( $_REQUEST['order'] ) ) {
echo '<input type="hidden" name="order" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) . '" />';
}
?>
<p class="search-box">
<label class="screen-reader-text" for="<?php echo esc_attr( $input_id ); ?>"><?php echo esc_html( $text ); ?>:</label>
<input type="search" id="<?php echo esc_attr( $input_id ); ?>" name="s" value="<?php _admin_search_query(); ?>" />
<?php $this->search_filter(); ?>
<?php submit_button( $text, '', '', false, array( 'id' => 'search-submit' ) ); ?>
</p>
<?php
}
/**
* Renders the search filter dropdown.
*
* @return void
*/
private function search_filter() {
$options = array(
'order_id' => __( 'Order ID', 'woocommerce' ),
'customer_email' => __( 'Customer Email', 'woocommerce' ),
'customers' => __( 'Customers', 'woocommerce' ),
'products' => __( 'Products', 'woocommerce' ),
'all' => __( 'All', 'woocommerce' ),
);
?>
<select name="search-filter" id="order-search-filter">
<?php foreach ( $options as $value => $label ) { ?>
<option value="<?php echo esc_attr( wp_unslash( sanitize_text_field( $value ) ) ); ?>" <?php selected( $value, sanitize_text_field( wp_unslash( $_REQUEST['search-filter'] ?? 'all' ) ) ); ?>><?php echo esc_html( $label ); ?></option>
<?php
}
}
}

View File

@@ -2,7 +2,7 @@
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
use Automattic\WooCommerce\Admin\API\Reports\Customers\Query as CustomersQuery;
use WC_Order;
/**
@@ -12,8 +12,6 @@ use WC_Order;
*/
class CustomerHistory {
use OrderAttributionMeta;
/**
* Output the customer history template for the order.
*
@@ -22,34 +20,47 @@ class CustomerHistory {
* @return void
*/
public function output( WC_Order $order ): void {
$this->display_customer_history( $order->get_customer_id(), $order->get_billing_email() );
}
// No history when adding a new order.
if ( 'auto-draft' === $order->get_status() ) {
return;
}
/**
* Display the customer history template for the customer.
*
* @param int $customer_id The customer ID.
* @param string $billing_email The customer billing email.
*
* @return void
*/
private function display_customer_history( int $customer_id, string $billing_email ): void {
$has_customer_id = false;
if ( $customer_id ) {
$has_customer_id = true;
$args = $this->get_customer_history( $customer_id );
} elseif ( $billing_email ) {
$args = $this->get_customer_history( $billing_email );
} else {
$args = array(
'order_count' => 0,
'total_spent' => 0,
'average_spent' => 0,
$customer_history = null;
if ( method_exists( $order, 'get_report_customer_id' ) ) {
$customer_history = $this->get_customer_history( $order->get_report_customer_id() );
}
if ( ! $customer_history ) {
$customer_history = array(
'orders_count' => 0,
'total_spend' => 0,
'avg_order_value' => 0,
);
}
$args['has_customer_id'] = $has_customer_id;
wc_get_template( 'order/customer-history.php', $args );
wc_get_template( 'order/customer-history.php', $customer_history );
}
/**
* Get the order history for the customer (data matches Customers report).
*
* @param int $customer_report_id The reports customer ID (not necessarily User ID).
*
* @return array|null Order count, total spend, and average spend per order.
*/
private function get_customer_history( $customer_report_id ): ?array {
$args = array(
'customers' => array( $customer_report_id ),
// If unset, these params have default values that affect the results.
'order_after' => null,
'order_before' => null,
);
$customers_query = new CustomersQuery( $args );
$customer_data = $customers_query->get_data();
return $customer_data->data[0] ?? null;
}
}

View File

@@ -79,6 +79,6 @@ class OrderAttribution {
// Only show more details toggle if there is more than just the origin.
'has_more_details' => array( 'origin' ) !== array_keys( $meta ),
);
wc_get_template( 'order/attribution-data-fields.php', $template_data );
wc_get_template( 'order/attribution-details.php', $template_data );
}
}

View File

@@ -97,7 +97,7 @@ abstract class Component {
*/
public static function get_argument_from_path( $arguments, $path, $delimiter = '.' ) {
$path_keys = explode( $delimiter, $path );
$num_keys = count( $path_keys );
$num_keys = false !== $path_keys ? count( $path_keys ) : 0;
$val = $arguments;
for ( $i = 0; $i < $num_keys; $i++ ) {

View File

@@ -64,7 +64,25 @@ class MailchimpScheduler {
return false;
}
$response = $this->make_request( $profile_data['store_email'] );
$country_code = WC()->countries->get_base_country();
$country_name = WC()->countries->countries[ $country_code ] ?? 'N/A';
$state = WC()->countries->get_base_state();
$state_name = WC()->countries->states[ $country_code ][ $state ] ?? 'N/A';
$address = array(
// Setting N/A for addr1, city, state, zipcode and country as they are
// required fields. Setting '' doesn't work.
'addr1' => 'N/A',
'addr2' => '',
'city' => 'N/A',
'state' => $state_name,
'zip' => 'N/A',
'country' => $country_name,
);
$response = $this->make_request( $profile_data['store_email'], $address );
if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
$this->handle_request_error();
return false;
@@ -85,10 +103,11 @@ class MailchimpScheduler {
*
* @internal
* @param string $store_email Email address to subscribe.
* @param array $address Store address.
*
* @return mixed
*/
public function make_request( $store_email ) {
public function make_request( $store_email, $address ) {
if ( true === defined( 'WP_ENVIRONMENT_TYPE' ) && 'development' === constant( 'WP_ENVIRONMENT_TYPE' ) ) {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT_DEV;
} else {
@@ -101,7 +120,8 @@ class MailchimpScheduler {
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
'method' => 'POST',
'body' => array(
'email' => $store_email,
'email' => $store_email,
'address' => $address,
),
)
);

View File

@@ -240,7 +240,19 @@ class Settings {
$settings['isWooPayEligible'] = WCPayPromotionInit::is_woopay_eligible();
$settings['gutenberg_version'] = defined( 'GUTENBERG_VERSION' ) ? constant( 'GUTENBERG_VERSION' ) : 0;
$has_gutenberg = is_plugin_active( 'gutenberg/gutenberg.php' );
$gutenberg_version = '';
if ( $has_gutenberg ) {
if ( defined( 'GUTENBERG_VERSION' ) ) {
$gutenberg_version = GUTENBERG_VERSION;
}
if ( ! $gutenberg_version ) {
$gutenberg_data = get_plugin_data( WP_PLUGIN_DIR . '/gutenberg/gutenberg.php' );
$gutenberg_version = $gutenberg_data['Version'];
}
}
$settings['gutenberg_version'] = $has_gutenberg ? $gutenberg_version : 0;
return $settings;
}

View File

@@ -36,6 +36,34 @@ class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway {
$this->method_description = $wc_pay_spec->content;
$this->has_fields = false;
// Set the promotion pseudo-gateway support features.
// If the promotion spec provides the supports property, use it.
if ( property_exists( $wc_pay_spec, 'supports' ) ) {
$this->supports = $wc_pay_spec->supports;
} else {
// Otherwise, use the default supported features in line with WooPayments ones.
// We include all features here, even if some of them are behind settings, since this is for info only.
$this->supports = array(
// Regular features.
'products',
'refunds',
// Subscriptions features.
'subscriptions',
'multiple_subscriptions',
'subscription_cancellation',
'subscription_reactivation',
'subscription_suspension',
'subscription_amount_changes',
'subscription_date_changes',
'subscription_payment_method_change_admin',
'subscription_payment_method_change_customer',
'subscription_payment_method_change',
// Saved cards features.
'tokenization',
'add_payment_method',
);
}
// Get setting values.
$this->enabled = false;

View File

@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;
use ActionScheduler;
use WC_Admin_Settings;
defined( 'ABSPATH' ) || exit;
@@ -326,9 +327,16 @@ class CustomOrdersTableController {
return;
}
if ( filter_input( INPUT_GET, self::SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
if ( ! filter_input( INPUT_GET, self::SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'hpos-sync-now' ) ) {
WC_Admin_Settings::add_error( esc_html__( 'Unable to start synchronization. The link you followed may have expired.', 'woocommerce' ) );
return;
}
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
}
/**
@@ -506,11 +514,14 @@ class CustomOrdersTableController {
$orders_pending_sync_count
);
} elseif ( $sync_is_pending ) {
$sync_now_url = add_query_arg(
array(
self::SYNC_QUERY_ARG => true,
$sync_now_url = wp_nonce_url(
add_query_arg(
array(
self::SYNC_QUERY_ARG => true,
),
wc_get_container()->get( FeaturesController::class )->get_features_page_url()
),
wc_get_container()->get( FeaturesController::class )->get_features_page_url()
'hpos-sync-now'
);
if ( ! $is_dangerous ) {

View File

@@ -5,9 +5,9 @@
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\BatchProcessing\{ BatchProcessingController, BatchProcessorInterface };
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
@@ -335,6 +335,33 @@ class DataSynchronizer implements BatchProcessorInterface {
return $interval;
}
/**
* Keys that can be ignored during synchronization or verification.
*
* @since 8.6.0
*
* @return string[]
*/
public function get_ignored_order_props() {
/**
* Allows modifying the list of order properties that are ignored during HPOS synchronization or verification.
*
* @param string[] List of order properties or meta keys.
* @since 8.6.0
*/
$ignored_props = apply_filters( 'woocommerce_hpos_sync_ignored_order_props', array() );
$ignored_props = array_filter( array_map( 'trim', array_filter( $ignored_props, 'is_string' ) ) );
return array_merge(
$ignored_props,
array(
'_paid_date', // This has been deprecated and replaced by '_date_paid' in the CPT datastore.
'_completed_date', // This has been deprecated and replaced by '_date_completed' in the CPT datastore.
EditLock::META_KEY_NAME,
)
);
}
/**
* Schedule an event to run background sync when the mode is set to interval.
*

View File

@@ -5,6 +5,8 @@
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
/**
@@ -171,13 +173,11 @@ class LegacyDataHandler {
/**
* Checks whether an HPOS-backed order is newer than the corresponding post.
*
* @param int|\WC_Order $order An HPOS order.
* @param \WC_Abstract_Order $order An HPOS order.
* @return bool TRUE if the order is up to date with the corresponding post.
* @throws \Exception When the order is not an HPOS order.
*/
private function is_order_newer_than_post( $order ): bool {
$order = is_a( $order, 'WC_Order' ) ? $order : wc_get_order( absint( $order ) );
private function is_order_newer_than_post( \WC_Abstract_Order $order ): bool {
if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
throw new \Exception( __( 'Order is not an HPOS order.', 'woocommerce' ) );
}
@@ -195,6 +195,137 @@ class LegacyDataHandler {
return $order_modified_gmt >= $post_modified_gmt;
}
/**
* Builds an array with properties and metadata for which HPOS and post record have different values.
* Given it's mostly informative nature, it doesn't perform any deep or recursive searches and operates only on top-level properties/metadata.
*
* @since 8.6.0
*
* @param int $order_id Order ID.
* @return array Array of [HPOS value, post value] keyed by property, for all properties where HPOS and post value differ.
*/
public function get_diff_for_order( int $order_id ): array {
$diff = array();
$hpos_order = $this->get_order_from_datastore( $order_id, 'hpos' );
$cpt_order = $this->get_order_from_datastore( $order_id, 'cpt' );
if ( $hpos_order->get_type() !== $cpt_order->get_type() ) {
$diff['type'] = array( $hpos_order->get_type(), $cpt_order->get_type() );
}
$hpos_meta = $this->order_meta_to_array( $hpos_order );
$cpt_meta = $this->order_meta_to_array( $cpt_order );
// Consider only keys for which we actually have a corresponding HPOS column or are meta.
$all_keys = array_unique(
array_diff(
array_merge(
$this->get_order_base_props(),
array_keys( $hpos_meta ),
array_keys( $cpt_meta )
),
$this->data_synchronizer->get_ignored_order_props()
)
);
foreach ( $all_keys as $key ) {
$val1 = in_array( $key, $this->get_order_base_props(), true ) ? $hpos_order->{"get_$key"}() : ( $hpos_meta[ $key ] ?? null );
$val2 = in_array( $key, $this->get_order_base_props(), true ) ? $cpt_order->{"get_$key"}() : ( $cpt_meta[ $key ] ?? null );
// Workaround for https://github.com/woocommerce/woocommerce/issues/43126.
if ( ! $val2 && in_array( $key, array( '_billing_address_index', '_shipping_address_index' ), true ) ) {
$val2 = get_post_meta( $order_id, $key, true );
}
if ( $val1 != $val2 ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
$diff[ $key ] = array( $val1, $val2 );
}
}
return $diff;
}
/**
* Returns an order object as seen by either the HPOS or CPT datastores.
*
* @since 8.6.0
*
* @param int $order_id Order ID.
* @param string $data_store_id Datastore to use. Should be either 'hpos' or 'cpt'. Defaults to 'hpos'.
* @return \WC_Order Order instance.
*/
public function get_order_from_datastore( int $order_id, string $data_store_id = 'hpos' ) {
$data_store = ( 'hpos' === $data_store_id ) ? $this->data_store : $this->data_store->get_cpt_data_store_instance();
wp_cache_delete( \WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
// Prime caches if we can.
if ( method_exists( $data_store, 'prime_caches_for_orders' ) ) {
$data_store->prime_caches_for_orders( array( $order_id ), array() );
}
$classname = wc_get_order_type( $data_store->get_order_type( $order_id ) )['class_name'];
$order = new $classname();
$order->set_id( $order_id );
// Switch datastore if necessary.
$update_data_store_func = function ( $data_store ) {
// Each order object contains a reference to its data store, but this reference is itself
// held inside of an instance of WC_Data_Store, so we create that first.
$data_store_wrapper = \WC_Data_Store::load( 'order' );
// Bind $data_store to our WC_Data_Store.
( function ( $data_store ) {
$this->current_class_name = get_class( $data_store );
$this->instance = $data_store;
} )->call( $data_store_wrapper, $data_store );
// Finally, update the $order object with our WC_Data_Store( $data_store ) instance.
$this->data_store = $data_store_wrapper;
};
$update_data_store_func->call( $order, $data_store );
// Read order.
$data_store->read( $order );
return $order;
}
/**
* Returns all metadata in an order object as an array.
*
* @param \WC_Order $order Order instance.
* @return array Array of metadata grouped by meta key.
*/
private function order_meta_to_array( \WC_Order &$order ): array {
$result = array();
foreach ( ArrayUtil::select( $order->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD ) as &$meta ) {
if ( array_key_exists( $meta['key'], $result ) ) {
$result[ $meta['key'] ] = array( $result[ $meta['key'] ] );
$result[ $meta['key'] ][] = $meta['value'];
} else {
$result[ $meta['key'] ] = $meta['value'];
}
}
return $result;
}
/**
* Returns names of all order base properties supported by HPOS.
*
* @return string[] Property names.
*/
private function get_order_base_props(): array {
return array_column(
call_user_func_array(
'array_merge',
array_values( $this->data_store->get_all_order_column_mappings() )
),
'name'
);
}
}

View File

@@ -97,6 +97,15 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
'_new_order_email_sent',
);
/**
* Meta keys that are considered ephemereal and do not trigger a full save (updating modified date) when changed.
*
* @var string[]
*/
protected $ephemeral_meta_keys = array(
EditLock::META_KEY_NAME,
);
/**
* Handles custom metadata in the wc_orders_meta table.
*
@@ -1253,12 +1262,10 @@ WHERE
$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.
* We are here because there was difference in the post and order data even though sync is enabled. If the modified date in
* the post is the same or more recent than the modified date in the order object, we update the order object with the data
* from the post. The opposite case is handled in 'backfill_post_record'. This mitigates the case where other plugins write
* to the post or postmeta directly.
*/
if ( $post_order_modified_date >= $order_modified_date ) {
$this->migrate_post_record( $order, $post_order );
@@ -1547,7 +1554,7 @@ WHERE
*
* @param array $ids List of order IDs.
*
* @return \stdClass[]|object|null DB Order objects or error.
* @return \stdClass[] DB Order objects or error.
*/
protected function get_order_data_for_ids( $ids ) {
global $wpdb;
@@ -2978,9 +2985,7 @@ CREATE TABLE $meta_table (
private function should_save_after_meta_change( $order, $meta = null ) {
$current_time = $this->legacy_proxy->call_function( 'current_time', 'mysql', 1 );
$current_date_time = new \WC_DateTime( $current_time, new \DateTimeZone( 'GMT' ) );
$skip_for = array(
EditLock::META_KEY_NAME,
);
return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() ) && ( ! is_object( $meta ) || ! in_array( $meta->key, $skip_for, true ) );
return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() ) && ( ! is_object( $meta ) || ! in_array( $meta->key, $this->ephemeral_meta_keys, true ) );
}
}

View File

@@ -185,7 +185,7 @@ class OrdersTableFieldQuery {
} else {
$relation = $q['relation'];
unset( $q['relation'] );
$chunks = array();
foreach ( $q as $query ) {
$chunks[] = $this->process( $query );
}
@@ -292,11 +292,10 @@ class OrdersTableFieldQuery {
}
$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
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( (array) $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
@@ -327,7 +326,7 @@ class OrdersTableFieldQuery {
break;
}
if ( $where ) {
if ( ! empty( $where ) ) {
if ( 'CHAR' === $clause['cast'] ) {
return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
} else {

View File

@@ -389,7 +389,7 @@ class OrdersTableMetaQuery {
// Nested.
$relation = $arg['relation'];
unset( $arg['relation'] );
$chunks = array();
foreach ( $arg as $index => &$clause ) {
$chunks[] = $this->process( $clause, $arg );
}
@@ -519,6 +519,9 @@ class OrdersTableMetaQuery {
$alias = $clause['alias'];
$meta_compare_string_start = '';
$meta_compare_string_end = '';
$subquery_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;
@@ -541,7 +544,7 @@ class OrdersTableMetaQuery {
$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 ) . ')';
$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( (array) $clause['key'] ) ), 1 ) . ')';
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'RLIKE':
@@ -566,7 +569,7 @@ class OrdersTableMetaQuery {
$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 ) . ') ';
$array_subclause = '(' . substr( str_repeat( ',%s', count( (array) $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;
@@ -618,7 +621,7 @@ class OrdersTableMetaQuery {
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
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( (array) $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':

View File

@@ -346,6 +346,7 @@ class OrdersTableQuery {
'day' => '',
);
$precision = null;
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
$precision = 'second';
@@ -919,6 +920,23 @@ class OrdersTableQuery {
}
$orders_table = $this->tables['orders'];
$this->count_sql = "SELECT COUNT(DISTINCT $fields) FROM $orders_table $join WHERE $where";
if ( ! $this->suppress_filters ) {
/**
* Filters the count SQL query.
*
* @since 8.6.0
*
* @param string $sql The count SQL query.
* @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
* @param array $args Query args.
* @param string $fields Prepared fields for SELECT clause.
* @param string $join Prepared JOIN clause.
* @param string $where Prepared WHERE clause.
* @param string $groupby Prepared GROUP BY clause.
*/
$this->count_sql = apply_filters_ref_array( 'woocommerce_orders_table_query_count_sql', array( $this->count_sql, &$this, $this->args, $fields, $join, $where, $groupby ) );
}
}
/**
@@ -1131,7 +1149,7 @@ class OrdersTableQuery {
$values = is_array( $values ) ? $values : array( $values );
$ids = array();
$emails = array();
$pieces = array();
foreach ( $values as $value ) {
if ( is_array( $value ) ) {
$sql = $this->generate_customer_query( $value, 'AND' );

View File

@@ -24,6 +24,13 @@ class OrdersTableSearchQuery {
*/
private $search_term;
/**
* Limits the search to a specific field.
*
* @var string
*/
private $search_filters;
/**
* Creates the JOIN and WHERE clauses needed to execute a search of orders.
*
@@ -32,8 +39,31 @@ class OrdersTableSearchQuery {
* @param OrdersTableQuery $query The order query object.
*/
public function __construct( OrdersTableQuery $query ) {
$this->query = $query;
$this->search_term = urldecode( $query->get( 's' ) );
$this->query = $query;
$this->search_term = urldecode( $query->get( 's' ) );
$this->search_filters = $this->sanitize_search_filters( urldecode( $query->get( 'search_filter' ) ) );
}
/**
* Sanitize search filter param.
*
* @param string $search_filter Search filter param.
*
* @return array Array of search filters.
*/
private function sanitize_search_filters( string $search_filter ) : array {
$available_filters = array(
'order_id',
'customer_email',
'customers', // customers also searches in meta.
'products',
);
if ( 'all' === $search_filter || '' === $search_filter ) {
return $available_filters;
} else {
return array_intersect( $available_filters, array( $search_filter ) );
}
}
/**
@@ -62,12 +92,34 @@ class OrdersTableSearchQuery {
* @return string
*/
private function generate_join(): string {
$orders_table = $this->query->get_table_name( 'orders' );
$items_table = $this->query->get_table_name( 'items' );
$join = array();
return "
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
foreach ( $this->search_filters as $search_filter ) {
$join[] = $this->generate_join_for_search_filter( $search_filter );
}
return implode( ' ', $join );
}
/**
* Generate JOIN clause for a given search filter.
* Right now we only have the products filter that actually does a JOIN, but in the future we may add more -- for example, custom order fields, payment tokens, and so on. This function makes it easier to add more filters in the future.
*
* If a search filter needs a JOIN, it will also need a WHERE clause.
*
* @param string $search_filter Name of the search filter.
*
* @return string JOIN clause.
*/
private function generate_join_for_search_filter( $search_filter ) : string {
if ( 'products' === $search_filter ) {
$orders_table = $this->query->get_table_name( 'orders' );
$items_table = $this->query->get_table_name( 'items' );
return "
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
}
return '';
}
/**
@@ -78,27 +130,66 @@ class OrdersTableSearchQuery {
* @return string
*/
private function generate_where(): string {
global $wpdb;
$where = '';
$where = array();
$possible_order_id = (string) absint( $this->search_term );
$order_table = $this->query->get_table_name( 'orders' );
// Support the passing of an order ID as the search term.
if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
$where = "`$order_table`.id = $possible_order_id OR ";
$where[] = "`$order_table`.id = $possible_order_id";
}
$meta_sub_query = $this->generate_where_for_meta_table();
foreach ( $this->search_filters as $search_filter ) {
$search_where = $this->generate_where_for_search_filter( $search_filter );
if ( ! empty( $search_where ) ) {
$where[] = $search_where;
}
}
$where .= $wpdb->prepare(
"
search_query_items.order_item_name LIKE %s
OR `$order_table`.id IN ( $meta_sub_query )
",
'%' . $wpdb->esc_like( $this->search_term ) . '%'
);
$where_statement = implode( ' OR ', $where );
return " ( $where ) ";
return " ( $where_statement ) ";
}
/**
* Generates WHERE clause for a given search filter. Right now we only have the products and customers filters that actually use WHERE, but in the future we may add more -- for example, custom order fields, payment tokens and so on. This function makes it easier to add more filters in the future.
*
* @param string $search_filter Name of the search filter.
*
* @return string WHERE clause.
*/
private function generate_where_for_search_filter( string $search_filter ) : string {
global $wpdb;
$order_table = $this->query->get_table_name( 'orders' );
if ( 'customer_email' === $search_filter ) {
return $wpdb->prepare(
"`$order_table`.billing_email LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->esc_like( $this->search_term ) . '%'
);
}
if ( 'order_id' === $search_filter && is_numeric( $this->search_term ) ) {
return $wpdb->prepare(
"`$order_table`.id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
absint( $this->search_term )
);
}
if ( 'products' === $search_filter ) {
return $wpdb->prepare(
'search_query_items.order_item_name LIKE %s',
'%' . $wpdb->esc_like( $this->search_term ) . '%'
);
}
if ( 'customers' === $search_filter ) {
$meta_sub_query = $this->generate_where_for_meta_table();
return "`$order_table`.id IN ( $meta_sub_query ) ";
}
return '';
}
/**
@@ -114,6 +205,12 @@ class OrdersTableSearchQuery {
global $wpdb;
$meta_table = $this->query->get_table_name( 'meta' );
$meta_fields = $this->get_meta_fields_to_be_searched();
if ( '' === $meta_fields ) {
return '-1';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $meta_fields is already escaped before imploding, $meta_table is hardcoded.
return $wpdb->prepare(
"
SELECT search_query_meta.order_id
@@ -124,6 +221,7 @@ GROUP BY search_query_meta.order_id
",
'%' . $wpdb->esc_like( $this->search_term ) . '%'
);
// phpcs:enable
}
/**
@@ -135,6 +233,11 @@ GROUP BY search_query_meta.order_id
* @return string
*/
private function get_meta_fields_to_be_searched(): string {
$meta_fields_to_search = array(
'_billing_address_index',
'_shipping_address_index',
);
/**
* Controls the order meta keys to be included in search queries.
*
@@ -147,10 +250,7 @@ GROUP BY search_query_meta.order_id
*/
$meta_keys = apply_filters(
'woocommerce_order_table_search_query_meta_keys',
array(
'_billing_address_index',
'_shipping_address_index',
)
$meta_fields_to_search
);
$meta_keys = (array) array_map(

View File

@@ -1,42 +0,0 @@
<?php
/**
* BlockTemplatesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplateRegistry;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\TemplateTransformer;
/**
* Service provider for the block templates controller classes in the Automattic\WooCommerce\Internal\BlockTemplateRegistry namespace.
*/
class BlockTemplatesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
BlockTemplateRegistry::class,
BlockTemplatesController::class,
TemplateTransformer::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( TemplateTransformer::class );
$this->share( BlockTemplateRegistry::class );
$this->share( BlockTemplatesController::class )->addArguments(
array(
BlockTemplateRegistry::class,
TemplateTransformer::class,
)
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* UtilsClassesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
/**
* Service provider for the engine classes in the Automattic\WooCommerce\src namespace.
*/
class EnginesServiceProvider extends AbstractInterfaceServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
TransientFilesEngine::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share_with_implements_tags( TransientFilesEngine::class )->addArgument( LegacyProxy::class );
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\LayoutTemplates\LayoutTemplateRegistry;
/**
* Service provider for layout templates.
*/
class LayoutTemplatesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
LayoutTemplateRegistry::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( LayoutTemplateRegistry::class );
}
}

View File

@@ -2,8 +2,8 @@
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ FileController, ListTable };
use Automattic\WooCommerce\Internal\Admin\Logging\{ PageController, Settings };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
@@ -18,6 +18,7 @@ class LoggingServiceProvider extends AbstractServiceProvider {
protected $provides = array(
FileController::class,
PageController::class,
Settings::class,
);
/**
@@ -31,7 +32,10 @@ class LoggingServiceProvider extends AbstractServiceProvider {
$this->share( PageController::class )->addArguments(
array(
FileController::class,
Settings::class,
)
);
$this->share( Settings::class );
}
}

View File

@@ -33,11 +33,13 @@ class OrderAttributionServiceProvider extends AbstractInterfaceServiceProvider {
* Register the classes.
*/
public function register() {
$this->share_with_implements_tags( WPConsentAPI::class );
$this->share_with_implements_tags( OrderAttributionController::class )
->addArguments(
array(
LegacyProxy::class,
FeaturesController::class,
WPConsentAPI::class,
)
);
$this->share_with_implements_tags( OrderAttributionBlocksController::class )
@@ -48,6 +50,5 @@ class OrderAttributionServiceProvider extends AbstractInterfaceServiceProvider {
OrderAttributionController::class,
)
);
$this->share_with_implements_tags( WPConsentAPI::class );
}
}

View File

@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\TimeUtil;
/**
* Service provider for the non-static utils classes in the Automattic\WooCommerce\src namespace.
@@ -33,6 +34,7 @@ class UtilsClassesServiceProvider extends AbstractServiceProvider {
PluginUtil::class,
COTMigrationUtil::class,
WebhookUtil::class,
TimeUtil::class,
);
/**
@@ -47,5 +49,6 @@ class UtilsClassesServiceProvider extends AbstractServiceProvider {
$this->share( COTMigrationUtil::class )
->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class ) );
$this->share( WebhookUtil::class );
$this->share( TimeUtil::class );
}
}

View File

@@ -9,7 +9,7 @@ use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface;
/**
* Simple Product Template.
* Product Variation Template.
*/
class ProductVariationTemplate extends AbstractProductFormTemplate implements ProductFormTemplateInterface {
/**
@@ -28,7 +28,7 @@ class ProductVariationTemplate extends AbstractProductFormTemplate implements Pr
const SINGLE_VARIATION_NOTICE_DISMISSED_OPTION = 'woocommerce_single_variation_notice_dismissed';
/**
* SimpleProductTemplate constructor.
* ProductVariationTemplate constructor.
*/
public function __construct() {
$this->add_group_blocks();

View File

@@ -16,12 +16,13 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
* The context name used to identify the editor.
*/
const GROUP_IDS = array(
'GENERAL' => 'general',
'ORGANIZATION' => 'organization',
'PRICING' => 'pricing',
'INVENTORY' => 'inventory',
'SHIPPING' => 'shipping',
'VARIATIONS' => 'variations',
'GENERAL' => 'general',
'ORGANIZATION' => 'organization',
'PRICING' => 'pricing',
'INVENTORY' => 'inventory',
'SHIPPING' => 'shipping',
'VARIATIONS' => 'variations',
'LINKED_PRODUCTS' => 'linked-products',
);
/**
@@ -35,6 +36,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$this->add_inventory_group_blocks();
$this->add_shipping_group_blocks();
$this->add_variation_group_blocks();
$this->add_linked_products_group_blocks();
}
/**
@@ -71,10 +73,37 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
),
)
);
// Variations tab.
if ( Features::is_enabled( 'product-variation-management' ) ) {
$variations_hide_conditions = array();
if ( Features::is_enabled( 'product-grouped' ) ) {
$variations_hide_conditions[] = array(
'expression' => 'editedProduct.type === "grouped"',
);
}
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
$variations_hide_conditions[] = array(
'expression' => 'editedProduct.type === "external"',
);
}
$this->add_group(
array(
'id' => $this::GROUP_IDS['VARIATIONS'],
'order' => 20,
'attributes' => array(
'title' => __( 'Variations', 'woocommerce' ),
),
'hideConditions' => $variations_hide_conditions,
)
);
}
$this->add_group(
array(
'id' => $this::GROUP_IDS['ORGANIZATION'],
'order' => 15,
'order' => 30,
'attributes' => array(
'title' => __( 'Organization', 'woocommerce' ),
),
@@ -83,7 +112,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$this->add_group(
array(
'id' => $this::GROUP_IDS['PRICING'],
'order' => 20,
'order' => 40,
'attributes' => array(
'title' => __( 'Pricing', 'woocommerce' ),
),
@@ -97,7 +126,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$this->add_group(
array(
'id' => $this::GROUP_IDS['INVENTORY'],
'order' => 30,
'order' => 50,
'attributes' => array(
'title' => __( 'Inventory', 'woocommerce' ),
),
@@ -118,34 +147,23 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$this->add_group(
array(
'id' => $this::GROUP_IDS['SHIPPING'],
'order' => 40,
'order' => 60,
'attributes' => array(
'title' => __( 'Shipping', 'woocommerce' ),
),
'hideConditions' => $shipping_hide_conditions,
)
);
if ( Features::is_enabled( 'product-variation-management' ) ) {
$variations_hide_conditions = array();
if ( Features::is_enabled( 'product-grouped' ) ) {
$variations_hide_conditions[] = array(
'expression' => 'editedProduct.type === "grouped"',
);
}
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
$variations_hide_conditions[] = array(
'expression' => 'editedProduct.type === "external"',
);
}
// Linked Products tab.
if ( Features::is_enabled( 'product-linked' ) ) {
$this->add_group(
array(
'id' => $this::GROUP_IDS['VARIATIONS'],
'order' => 50,
'attributes' => array(
'title' => __( 'Variations', 'woocommerce' ),
'id' => $this::GROUP_IDS['LINKED_PRODUCTS'],
'order' => 70,
'attributes' => array(
'title' => __( 'Linked products', 'woocommerce' ),
),
'hideConditions' => $variations_hide_conditions,
)
);
}
@@ -179,6 +197,13 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
),
)
);
$basic_details->add_block(
array(
'id' => 'product-details-section-description',
'blockName' => 'woocommerce/product-details-section-description',
'order' => 10,
)
);
$basic_details->add_block(
array(
'id' => 'product-name',
@@ -332,7 +357,6 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
'value' => 'url',
'message' => __( 'Link to the external product is an invalid URL.', 'woocommerce' ),
),
'required' => __( 'Link to the external product is required.', 'woocommerce' ),
),
)
);
@@ -431,10 +455,37 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
);
// Downloads section.
if ( Features::is_enabled( 'product-virtual-downloadable' ) ) {
$general_group->add_section(
$product_downloads_section_group = $general_group->add_section(
array(
'id' => 'product-downloads-section-group',
'order' => 50,
'attributes' => array(
'blockGap' => 'unit-40',
),
'hideConditions' => array(
array(
'expression' => 'editedProduct.type !== "simple"',
),
),
)
);
$product_downloads_section_group->add_block(
array(
'id' => 'product-downloadable',
'blockName' => 'woocommerce/product-checkbox-field',
'order' => 10,
'attributes' => array(
'property' => 'downloadable',
'label' => __( 'Include downloads', 'woocommerce' ),
),
)
);
$product_downloads_section_group->add_section(
array(
'id' => 'product-downloads-section',
'order' => 50,
'order' => 20,
'attributes' => array(
'title' => __( 'Downloads', 'woocommerce' ),
'description' => sprintf(
@@ -446,7 +497,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
),
'hideConditions' => array(
array(
'expression' => 'editedProduct.type !== "simple"',
'expression' => 'editedProduct.downloadable !== true',
),
),
)
@@ -1078,4 +1129,85 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
)
);
}
/**
* Adds the linked products group blocks to the template.
*/
private function add_linked_products_group_blocks() {
$linked_products_group = $this->get_group_by_id( $this::GROUP_IDS['LINKED_PRODUCTS'] );
if ( ! isset( $linked_products_group ) ) {
return;
}
$linked_products_group->add_section(
array(
'id' => 'product-linked-upsells-section',
'order' => 10,
'attributes' => array(
'title' => __( 'Upsells', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Learn more about linked products. %2$s: Learn more about linked products.*/
__( 'Upsells are typically products that are extra profitable or better quality or more expensive. Experiment with combinations to boost sales. %1$sLearn more about linked products.%2$s', 'woocommerce' ),
'<br /><a href="https://woo.com/document/related-products-up-sells-and-cross-sells/" target="_blank" rel="noreferrer">',
'</a>'
),
),
)
)->add_block(
array(
'id' => 'product-linked-upsells',
'blockName' => 'woocommerce/product-linked-list-field',
'order' => 10,
'attributes' => array(
'property' => 'upsell_ids',
'emptyState' => array(
'image' => 'ShoppingBags',
'tip' => __(
'Tip: Upsells are products that are extra profitable or better quality or more expensive. Experiment with combinations to boost sales.',
'woocommerce'
),
'isDismissible' => true,
),
),
)
);
$linked_products_group->add_section(
array(
'id' => 'product-linked-cross-sells-section',
'order' => 20,
'attributes' => array(
'title' => __( 'Cross-sells', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Learn more about linked products. %2$s: Learn more about linked products.*/
__( 'By suggesting complementary products in the cart using cross-sells, you can significantly increase the average order value. %1$sLearn more about linked products.%2$s', 'woocommerce' ),
'<br /><a href="https://woo.com/document/related-products-up-sells-and-cross-sells/" target="_blank" rel="noreferrer">',
'</a>'
),
),
'hideConditions' => array(
array(
'expression' => 'editedProduct.type === "external" || editedProduct.type === "grouped"',
),
),
)
)->add_block(
array(
'id' => 'product-linked-cross-sells',
'blockName' => 'woocommerce/product-linked-list-field',
'order' => 10,
'attributes' => array(
'property' => 'cross_sell_ids',
'emptyState' => array(
'image' => 'CashRegister',
'tip' => __(
'Tip: By suggesting complementary products in the cart using cross-sells, you can significantly increase the average order value.',
'woocommerce'
),
'isDismissible' => true,
),
),
)
);
}
}

View File

@@ -16,6 +16,14 @@ class WPConsentAPI {
use ScriptDebug;
/**
* Identifier of the consent category used for order attribution.
*
* @var string
*/
public static $consent_category = 'marketing';
/**
* Register the consent API.
*
@@ -59,7 +67,7 @@ class WPConsentAPI {
add_filter(
'wc_order_attribution_allow_tracking',
function() {
return function_exists( 'wp_has_consent' ) && wp_has_consent( 'marketing' );
return function_exists( 'wp_has_consent' ) && wp_has_consent( self::$consent_category );
}
);
}
@@ -80,14 +88,25 @@ class WPConsentAPI {
*/
private function enqueue_consent_api_scripts() {
wp_enqueue_script(
'wp-consent-api-integration-js',
'wp-consent-api-integration',
plugins_url(
"assets/js/frontend/wp-consent-api-integration{$this->get_script_suffix()}.js",
WC_PLUGIN_FILE
),
array( 'jquery', 'wp-consent-api' ),
array( 'wp-consent-api', 'wc-order-attribution' ),
Constants::get_constant( 'WC_VERSION' ),
true
);
// Add data for the script above. `wp_enqueue_script` API does not allow data attributes,
// so we need a separate script tag and pollute the global scope.
wp_add_inline_script(
'wp-consent-api-integration',
sprintf(
'window.wc_order_attribution.params.consentCategory = %s;',
wp_json_encode( self::$consent_category )
),
'before'
);
}
}

View File

@@ -72,50 +72,6 @@ class OrderAttributionBlocksController implements RegisterHooksInterface {
}
$this->extend_api();
// Bail early on admin requests to avoid asset registration.
if ( is_admin() ) {
return;
}
add_action(
'init',
function() {
$this->register_assets();
}
);
add_action(
'wp_enqueue_scripts',
function() {
$this->enqueue_scripts();
}
);
}
/**
* Register scripts.
*/
private function register_assets() {
wp_register_script(
'wc-order-attribution-blocks',
plugins_url(
"assets/js/frontend/order-attribution-blocks{$this->get_script_suffix()}.js",
WC_PLUGIN_FILE
),
array( 'wc-order-attribution', 'wp-data', 'wc-blocks-checkout' ),
Constants::get_constant( 'WC_VERSION' ),
true
);
}
/**
* Enqueue the Order Attribution script.
*
* @return void
*/
private function enqueue_scripts() {
wp_enqueue_script( 'wc-order-attribution-blocks' );
}
/**
@@ -164,8 +120,8 @@ class OrderAttributionBlocksController implements RegisterHooksInterface {
*/
private function get_schema_callback() {
return function() {
$schema = array();
$fields = $this->order_attribution_controller->get_fields();
$schema = array();
$field_names = $this->order_attribution_controller->get_field_names();
$validate_callback = function( $value ) {
if ( ! is_string( $value ) && null !== $value ) {
@@ -186,12 +142,12 @@ class OrderAttributionBlocksController implements RegisterHooksInterface {
return sanitize_text_field( $value );
};
foreach ( $fields as $field ) {
$schema[ $field ] = array(
foreach ( $field_names as $field_name ) {
$schema[ $field_name ] = array(
'description' => sprintf(
/* translators: %s is the field name */
__( 'Order attribution field: %s', 'woocommerce' ),
esc_html( $field )
esc_html( $field_name )
),
'type' => array( 'string', 'null' ),
'context' => array(),

View File

@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Internal\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Integrations\WPConsentAPI;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
@@ -26,9 +27,16 @@ class OrderAttributionController implements RegisterHooksInterface {
use ScriptDebug;
use OrderAttributionMeta {
get_prefixed_field as public;
get_prefixed_field_name as public;
}
/**
* The WPConsentAPI integration instance.
*
* @var WPConsentAPI
*/
private $consent;
/**
* The FeatureController instance.
*
@@ -59,11 +67,13 @@ class OrderAttributionController implements RegisterHooksInterface {
*
* @param LegacyProxy $proxy The legacy proxy.
* @param FeaturesController $controller The feature controller.
* @param WPConsentAPI $consent The WPConsentAPI integration.
* @param WC_Logger_Interface $logger The logger object. If not provided, it will be obtained from the proxy.
*/
final public function init( LegacyProxy $proxy, FeaturesController $controller, ?WC_Logger_Interface $logger = null ) {
final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent, ?WC_Logger_Interface $logger = null ) {
$this->proxy = $proxy;
$this->feature_controller = $controller;
$this->consent = $consent;
$this->logger = $logger ?? $proxy->call_function( 'wc_get_logger' );
$this->set_fields_and_prefix();
}
@@ -84,6 +94,9 @@ class OrderAttributionController implements RegisterHooksInterface {
return;
}
// Register WPConsentAPI integration.
$this->consent->register();
add_action(
'wp_enqueue_scripts',
function() {
@@ -98,13 +111,13 @@ class OrderAttributionController implements RegisterHooksInterface {
}
);
// Include our hidden fields on order notes and registration form.
$source_form_fields = function() {
$this->source_form_fields();
// Include our hidden `<input>` elements on order notes and registration form.
$source_form_elements = function() {
$this->source_form_elements();
};
add_action( 'woocommerce_after_order_notes', $source_form_fields );
add_action( 'woocommerce_register_form', $source_form_fields );
add_action( 'woocommerce_after_order_notes', $source_form_elements );
add_action( 'woocommerce_register_form', $source_form_elements );
// Update order based on submitted fields.
add_action(
@@ -112,7 +125,7 @@ class OrderAttributionController implements RegisterHooksInterface {
function( $order ) {
// Nonce check is handled by WooCommerce before woocommerce_checkout_order_created hook.
// phpcs:ignore WordPress.Security.NonceVerification
$params = $this->get_unprefixed_fields( $_POST );
$params = $this->get_unprefixed_field_values( $_POST );
/**
* Run an action to save order attribution data.
*
@@ -175,18 +188,18 @@ class OrderAttributionController implements RegisterHooksInterface {
*/
private function maybe_set_admin_source( WC_Order $order ) {
if ( function_exists( 'is_admin' ) && is_admin() ) {
$order->add_meta_data( $this->get_meta_prefixed_field( 'type' ), 'admin' );
$order->add_meta_data( $this->get_meta_prefixed_field_name( 'source_type' ), 'admin' );
$order->save();
}
}
/**
* Get all of the fields.
* Get all of the field names.
*
* @return array
*/
public function get_fields(): array {
return $this->fields;
public function get_field_names(): array {
return $this->field_names;
}
/**
@@ -213,6 +226,9 @@ class OrderAttributionController implements RegisterHooksInterface {
wp_enqueue_script(
'wc-order-attribution',
plugins_url( "assets/js/frontend/order-attribution{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
// Technically we do depend on 'wp-data', 'wc-blocks-checkout' for blocks checkout,
// but as implementing conditional dependency on the server-side would be too complex,
// we resolve this condition at the client-side.
array( 'sourcebuster-js' ),
Constants::get_constant( 'WC_VERSION' ),
true
@@ -254,8 +270,9 @@ class OrderAttributionController implements RegisterHooksInterface {
'session' => $session_length,
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'prefix' => $this->field_prefix,
'allowTracking' => $allow_tracking,
'allowTracking' => 'yes' === $allow_tracking,
),
'fields' => $this->fields,
);
wp_localize_script( 'wc-order-attribution', 'wc_order_attribution', $namespace );
@@ -308,8 +325,8 @@ class OrderAttributionController implements RegisterHooksInterface {
* @return void
*/
private function output_origin_column( WC_Order $order ) {
$source_type = $order->get_meta( $this->get_meta_prefixed_field( 'type' ) );
$source = $order->get_meta( $this->get_meta_prefixed_field( 'utm_source' ) );
$source_type = $order->get_meta( $this->get_meta_prefixed_field_name( 'source_type' ) );
$source = $order->get_meta( $this->get_meta_prefixed_field_name( 'utm_source' ) );
$origin = $this->get_origin_label( $source_type, $source );
if ( empty( $origin ) ) {
$origin = __( 'Unknown', 'woocommerce' );
@@ -318,11 +335,12 @@ class OrderAttributionController implements RegisterHooksInterface {
}
/**
* Add attribution hidden input fields for checkout & customer register froms.
* Add `<input type="hidden">` elements for source fields.
* Used for checkout & customer register froms.
*/
private function source_form_fields() {
foreach ( $this->fields as $field ) {
printf( '<input type="hidden" name="%s" value="" />', esc_attr( $this->get_prefixed_field( $field ) ) );
private function source_form_elements() {
foreach ( $this->field_names as $field_name ) {
printf( '<input type="hidden" name="%s" value="" />', esc_attr( $this->get_prefixed_field_name( $field_name ) ) );
}
}
@@ -336,8 +354,8 @@ class OrderAttributionController implements RegisterHooksInterface {
private function set_customer_source_data( WC_Customer $customer ) {
// Nonce check is handled before user_register hook.
// phpcs:ignore WordPress.Security.NonceVerification
foreach ( $this->get_source_values( $this->get_unprefixed_fields( $_POST ) ) as $key => $value ) {
$customer->add_meta_data( $this->get_meta_prefixed_field( $key ), $value );
foreach ( $this->get_source_values( $this->get_unprefixed_field_values( $_POST ) ) as $key => $value ) {
$customer->add_meta_data( $this->get_meta_prefixed_field_name( $key ), $value );
}
$customer->save_meta_data();
@@ -352,8 +370,12 @@ class OrderAttributionController implements RegisterHooksInterface {
* @return void
*/
private function set_order_source_data( array $source_data, WC_Order $order ) {
// If all the values are empty, bail.
if ( empty( array_filter( $source_data ) ) ) {
return;
}
foreach ( $source_data as $key => $value ) {
$order->add_meta_data( $this->get_meta_prefixed_field( $key ), $value );
$order->add_meta_data( $this->get_meta_prefixed_field_name( $key ), $value );
}
$order->save_meta_data();
@@ -394,28 +416,31 @@ class OrderAttributionController implements RegisterHooksInterface {
* @return void
*/
private function send_order_tracks( array $source_data, WC_Order $order ) {
$origin_label = $this->get_origin_label(
$source_data['type'] ?? '',
$origin_label = $this->get_origin_label(
$source_data['source_type'] ?? '',
$source_data['utm_source'] ?? '',
false
);
$customer_identifier = $order->get_customer_id() ? $order->get_customer_id() : $order->get_billing_email();
$customer_info = $this->get_customer_history( $customer_identifier );
$tracks_data = array(
'order_id' => $order->get_id(),
'type' => $source_data['type'] ?? '',
'medium' => $source_data['utm_medium'] ?? '',
'source' => $source_data['utm_source'] ?? '',
'device_type' => strtolower( $source_data['device_type'] ?? '(unknown)' ),
'origin_label' => strtolower( $origin_label ),
'session_pages' => $source_data['session_pages'] ?? 0,
'session_count' => $source_data['session_count'] ?? 0,
'order_total' => $order->get_total(),
// Add 1 to include the current order (which is currently still Pending when the event is sent).
'customer_order_count' => $customer_info['order_count'] + 1,
'customer_registered' => $order->get_customer_id() ? 'yes' : 'no',
$tracks_data = array(
'order_id' => $order->get_id(),
'source_type' => $source_data['source_type'] ?? '',
'medium' => $source_data['utm_medium'] ?? '',
'source' => $source_data['utm_source'] ?? '',
'device_type' => strtolower( $source_data['device_type'] ?? 'unknown' ),
'origin_label' => strtolower( $origin_label ),
'session_pages' => $source_data['session_pages'] ?? 0,
'session_count' => $source_data['session_count'] ?? 0,
'order_total' => $order->get_total(),
'customer_registered' => $order->get_customer_id() ? 'yes' : 'no',
);
$this->proxy->call_static(
WC_Tracks::class,
'record_event',
'order_attribution',
$tracks_data
);
$this->proxy->call_static( WC_Tracks::class, 'record_event', 'order_attribution', $tracks_data );
}
/**

View File

@@ -215,7 +215,7 @@ class DataRegenerator {
)
);
if ( ! $product_ids ) {
if ( ! is_array( $product_ids ) || empty( $product_ids ) ) {
return false;
}

View File

@@ -73,7 +73,7 @@ class Filterer {
}
$attribute_ids_for_and_filtering = array();
$clauses = 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' );
@@ -206,6 +206,7 @@ class Filterer {
$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 = array();
$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'] = "

View File

@@ -4,7 +4,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Traits;
use Automattic\WooCommerce\Vendor\Detection\MobileDetect;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Exception;
use WC_Meta_Data;
use WC_Order;
@@ -19,31 +18,43 @@ use WP_Post;
*/
trait OrderAttributionMeta {
/** @var string[] */
/**
* The default fields and their sourcebuster accesors,
* to show in the source data metabox.
*
* @var string[]
* */
private $default_fields = array(
// main fields.
'type',
'url',
'source_type' => 'current.typ',
'referrer' => 'current_add.rf',
// utm fields.
'utm_campaign',
'utm_source',
'utm_medium',
'utm_content',
'utm_id',
'utm_term',
'utm_campaign' => 'current.cmp',
'utm_source' => 'current.src',
'utm_medium' => 'current.mdm',
'utm_content' => 'current.cnt',
'utm_id' => 'current.id',
'utm_term' => 'current.trm',
// additional fields.
'session_entry',
'session_start_time',
'session_pages',
'session_count',
'user_agent',
'session_entry' => 'current_add.ep',
'session_start_time' => 'current_add.fd',
'session_pages' => 'session.pgs',
'session_count' => 'udata.vst',
'user_agent' => 'udata.uag',
);
/** @var array */
private $fields = array();
/**
* Cached `array_keys( $fields )`.
*
* @var array
* */
private $field_names = array();
/** @var string */
private $field_prefix = '';
@@ -67,7 +78,7 @@ trait OrderAttributionMeta {
}
/**
* Set the meta fields and the field prefix.
* Set the fields and the field prefix.
*
* @return void
*/
@@ -79,7 +90,8 @@ trait OrderAttributionMeta {
*
* @param string[] $fields The fields to show.
*/
$this->fields = (array) apply_filters( 'wc_order_attribution_tracking_fields', $this->default_fields );
$this->fields = (array) apply_filters( 'wc_order_attribution_tracking_fields', $this->default_fields );
$this->field_names = array_keys( $this->fields );
$this->set_field_prefix();
}
@@ -119,11 +131,11 @@ trait OrderAttributionMeta {
*/
private function filter_meta_data( array $meta ): array {
$return = array();
$prefix = $this->get_meta_prefixed_field( '' );
$prefix = $this->get_meta_prefixed_field_name( '' );
foreach ( $meta as $item ) {
if ( str_starts_with( $item->key, $prefix ) ) {
$return[ $this->unprefix_meta_field( $item->key ) ] = $item->value;
$return[ $this->unprefix_meta_field_name( $item->key ) ] = $item->value;
}
}
@@ -133,7 +145,7 @@ trait OrderAttributionMeta {
}
// Determine the origin based on source type and referrer.
$source_type = $return['type'] ?? '';
$source_type = $return['source_type'] ?? '';
$source = $return['utm_source'] ?? '';
$return['origin'] = $this->get_origin_label( $source_type, $source, true );
@@ -143,50 +155,34 @@ trait OrderAttributionMeta {
/**
* Get the field name with the appropriate prefix.
*
* @param string $field Field name.
* @param string $name Field name.
*
* @return string The prefixed field name.
*/
private function get_prefixed_field( $field ): string {
return "{$this->field_prefix}{$field}";
private function get_prefixed_field_name( $name ): string {
return "{$this->field_prefix}{$name}";
}
/**
* Get the field name with the meta prefix.
*
* @param string $field The field name.
* @param string $name The field name.
*
* @return string The prefixed field name.
*/
private function get_meta_prefixed_field( string $field ): string {
// Map some of the fields to the correct meta name.
if ( 'type' === $field ) {
$field = 'source_type';
} elseif ( 'url' === $field ) {
$field = 'referrer';
}
return "_{$this->get_prefixed_field( $field )}";
private function get_meta_prefixed_field_name( string $name ): string {
return "_{$this->get_prefixed_field_name( $name )}";
}
/**
* Remove the meta prefix from the field name.
*
* @param string $field The prefixed field.
* @param string $name The prefixed fieldname .
*
* @return string
*/
private function unprefix_meta_field( string $field ): string {
$return = str_replace( "_{$this->field_prefix}", '', $field );
// Map some of the fields to the correct meta name.
if ( 'source_type' === $return ) {
$return = 'type';
} elseif ( 'referrer' === $return ) {
$return = 'url';
}
return $return;
private function unprefix_meta_field_name( string $name ): string {
return str_replace( "_{$this->field_prefix}", '', $name );
}
/**
@@ -218,19 +214,19 @@ trait OrderAttributionMeta {
/**
* Map posted, prefixed values to fields.
* Map posted, prefixed values to field values.
* Used for the classic forms.
*
* @param array $raw_values The raw values from the POST form.
*
* @return array
*/
private function get_unprefixed_fields( array $raw_values = array() ): array {
private function get_unprefixed_field_values( array $raw_values = array() ): array {
$values = array();
// Look through each field in POST data.
foreach ( $this->fields as $field ) {
$values[ $field ] = $raw_values[ $this->get_prefixed_field( $field ) ] ?? '(none)';
foreach ( $this->field_names as $field_name ) {
$values[ $field_name ] = $raw_values[ $this->get_prefixed_field_name( $field_name ) ] ?? '(none)';
}
return $values;
@@ -247,13 +243,13 @@ trait OrderAttributionMeta {
$values = array();
// Look through each field in given data.
foreach ( $this->fields as $field ) {
$value = sanitize_text_field( wp_unslash( $raw_values[ $field ] ) );
foreach ( $this->field_names as $field_name ) {
$value = sanitize_text_field( wp_unslash( $raw_values[ $field_name ] ) );
if ( '(none)' === $value ) {
continue;
}
$values[ $field ] = $value;
$values[ $field_name ] = $value;
}
// Set the device type if possible using the user agent.
@@ -310,7 +306,9 @@ trait OrderAttributionMeta {
default:
$label = '';
$source = __( 'Unknown', 'woocommerce' );
$source = $translated ?
__( 'Unknown', 'woocommerce' )
: 'Unknown';
break;
}
@@ -359,13 +357,13 @@ trait OrderAttributionMeta {
/**
* Get the description for the order attribution field.
*
* @param string $field The field name.
* @param string $field_name The field name.
*
* @return string
*/
private function get_field_description( string $field ): string {
private function get_field_description( string $field_name ): string {
/* translators: %s is the field name */
$description = sprintf( __( 'Order attribution field: %s', 'woocommerce' ), $field );
$description = sprintf( __( 'Order attribution field: %s', 'woocommerce' ), $field_name );
/**
* Filter the description for the order attribution field.
@@ -373,56 +371,8 @@ trait OrderAttributionMeta {
* @since 8.5.0
*
* @param string $description The description for the order attribution field.
* @param string $field The field name.
* @param string $field_name The field name.
*/
return (string) apply_filters( 'wc_order_attribution_field_description', $description, $field );
}
/**
* Get the order history for the customer (data matches Customers report).
*
* @param mixed $customer_identifier The customer ID or billing email.
*
* @return array Order count, total spend, and average spend per order.
*/
private function get_customer_history( $customer_identifier ): array {
/*
* Exclude the statuses that aren't valid for the Customers report.
* 'checkout-draft' is the checkout block's draft order status. `any` is added by V2 Orders REST.
* @see /Automattic/WooCommerce/Admin/API/Report/DataStore::get_excluded_report_order_statuses()
*/
$all_order_statuses = ReportsController::get_order_statuses();
$excluded_statuses = array( 'pending', 'failed', 'cancelled', 'auto-draft', 'trash', 'checkout-draft', 'any' );
// Get the valid customer orders.
$args = array(
'limit' => - 1,
'return' => 'objects',
'status' => array_diff( $all_order_statuses, $excluded_statuses ),
'type' => 'shop_order',
);
// If the customer_identifier is a valid ID, use it. Otherwise, use the billing email.
if ( is_numeric( $customer_identifier ) && $customer_identifier > 0 ) {
$args['customer_id'] = $customer_identifier;
} else {
$args['billing_email'] = $customer_identifier;
$args['customer_id'] = 0;
}
$orders = wc_get_orders( $args );
// Populate the order_count and total_spent variables with the valid orders.
$order_count = count( $orders );
$total_spent = 0;
foreach ( $orders as $order ) {
$total_spent += $order->get_total() - $order->get_total_refunded();
}
return array(
'order_count' => $order_count,
'total_spent' => $total_spent,
'average_spent' => $order_count ? $total_spent / $order_count : 0,
);
return (string) apply_filters( 'wc_order_attribution_field_description', $description, $field_name );
}
}

View File

@@ -0,0 +1,508 @@
<?php
namespace Automattic\WooCommerce\Internal\TransientFiles;
use \DateTime;
use \Exception;
use \InvalidArgumentException;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\TimeUtil;
/**
* Transient files engine class.
*
* This class contains methods that allow creating files that have an expiration date.
*
* A transient file is created by invoking the create_transient_file method, which accepts the file contents
* and the expiration date as arguments. Transient file names are composed by concatenating the expiration date
* encoded in hexadecimal (3 digits for the year, 1 for the month and 2 for the day) and a random string
* of hexadecimal digits.
*
* Transient files are stored in a directory whose default route is
* wp-content/uploads/woocommerce_transient_files/yyyy-mm-dd, where "yyyy-mm-dd" is the expiration date
* (year, month and day). The base route (minus the expiration date part) can be changed via a dedicated hook.
*
* Transient files that haven't expired (the expiration date is today or in the future) can be obtained remotely
* via a dedicated URL, <server root>/wc/file/transient/<file name>. This URL is public (no authentication is required).
* The content type of the response will always be "text/html".
*
* Cleanup of expired files is handled by the delete_expired_files method, which can be invoked manually
* but there's a dedicated scheduled action that will invoke it that can be started and stopped via a dedicated tool
* available in the WooCommerce tools page. The action runs once per day but this can be customized
* via a dedicated hook.
*/
class TransientFilesEngine implements RegisterHooksInterface {
use AccessiblePrivateMethods;
private const CLEANUP_ACTION_NAME = 'woocommerce_expired_transient_files_cleanup';
private const CLEANUP_ACTION_GROUP = 'wc_batch_processes';
/**
* The instance of LegacyProxy to use.
*
* @var LegacyProxy
*/
private LegacyProxy $legacy_proxy;
/**
* Register hooks.
*/
public function register() {
self::add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 );
self::add_action( 'init', array( $this, 'handle_init' ), 0 );
self::add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 );
self::add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 );
}
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*
* @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use.
*/
final public function init( LegacyProxy $legacy_proxy ) {
$this->legacy_proxy = $legacy_proxy;
}
/**
* Get the base directory where transient files are stored.
*
* The default base directory is the WordPress uploads directory plus "woocommerce_transient_files". This can
* be changed by using the woocommerce_transient_files_directory filter.
*
* If the woocommerce_transient_files_directory filter is not used and the default base directory
* doesn't exist, it will be created. If the filter is used it's the responsibility of the caller
* to ensure that the custom directory exists, otherwise an exception will be thrown.
*
* The actual directory for each existing file will be the base directory plus the expiration date
* of the file formatted as 'yyyy-mm-dd'.
*
* @return string Effective base directory where transient files are stored.
* @throws Exception The custom base directory (as specified via filter) doesn't exist, or the default base directory can't be created.
*/
public function get_transient_files_directory(): string {
$upload_dir_info = $this->legacy_proxy->call_function( 'wp_upload_dir' );
$default_transient_files_directory = untrailingslashit( $upload_dir_info['basedir'] ) . '/woocommerce_transient_files';
/**
* Filters the directory where transient files are stored.
*
* Note that this is used for both creating new files (with create_file_by_rendering_template)
* and retrieving existing files (with get_file_by_*).
*
* @param string $transient_files_directory The default directory for transient files.
* @return string The actual directory to use for storing transient files.
*
* @since 8.5.0
*/
$transient_files_directory = apply_filters( 'woocommerce_transient_files_directory', $default_transient_files_directory );
$realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
if ( false === $realpathed_transient_files_directory ) {
if ( $transient_files_directory === $default_transient_files_directory ) {
if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
throw new Exception( "Can't create directory: $transient_files_directory" );
}
$realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
} else {
throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" );
}
}
return untrailingslashit( $realpathed_transient_files_directory );
}
/**
* Create a transient file.
*
* @param string $file_contents The contents of the file.
* @param string|int $expiration_date A string representing the expiration date formatted as "yyyy-mm-dd", or a number representing the expiration date as a timestamp (the time of day part will be ignored).
* @return string The name of the transient file created (without path information).
* @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
* @throws \Exception The directory to store the file doesn't exist and can't be created.
*/
public function create_transient_file( string $file_contents, $expiration_date ): string {
if ( is_numeric( $expiration_date ) ) {
$expiration_date = gmdate( 'Y-m-d', $expiration_date );
} elseif ( ! is_string( $expiration_date ) || ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) {
$expiration_date = is_scalar( $expiration_date ) ? $expiration_date : gettype( $expiration_date );
throw new InvalidArgumentException( "$expiration_date is not a valid date, expected format: YYYY-MM-DD" );
}
$expiration_date_object = DateTime::createFromFormat( 'Y-m-d', $expiration_date, TimeUtil::get_utc_date_time_zone() );
$today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );
if ( $expiration_date_object < $today_date_object ) {
throw new InvalidArgumentException( "The supplied expiration date, $expiration_date, is in the past" );
}
$filename = bin2hex( $this->legacy_proxy->call_function( 'random_bytes', 16 ) );
$transient_files_directory = $this->get_transient_files_directory();
$transient_files_directory .= '/' . $expiration_date_object->format( 'Y-m-d' );
if ( ! $this->legacy_proxy->call_function( 'is_dir', $transient_files_directory ) ) {
if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
throw new Exception( "Can't create directory: $transient_files_directory" );
}
}
$filepath = $transient_files_directory . '/' . $filename;
WP_Filesystem();
$wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' );
if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) {
throw new Exception( "Can't create file: $filepath" );
}
return sprintf(
'%03x%01x%02x%s',
$expiration_date_object->format( 'Y' ),
$expiration_date_object->format( 'm' ),
$expiration_date_object->format( 'd' ),
$filename
);
}
/**
* Get the full physical path of a transient file given its name.
*
* @param string $filename The name of the transient file to locate.
* @return string|null The full physical path of the file, or null if the files doesn't exist.
*/
public function get_transient_file_path( string $filename ): ?string {
if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) {
return null;
}
$expiration_date = sprintf(
'%04d-%02d-%02d',
hexdec( substr( $filename, 0, 3 ) ),
hexdec( substr( $filename, 3, 1 ) ),
hexdec( substr( $filename, 4, 2 ) )
);
if ( ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) {
return null;
}
$file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 );
return is_file( $file_path ) ? $file_path : null;
}
/**
* Verify if a file has expired, given its full physical file path.
*
* Given a file name returned by 'create_transient_file', the procedure to check if it has expired is as follows:
*
* 1. Use 'get_transient_file_path' to obtain the full file path.
* 2. If the above returns null, the file doesn't exist anymore (likely it expired and was deleted by the cleanup process).
* 3. Otherwise, use 'file_has_expired' passing the obtained full file path.
*
* @param string $file_path The full file path to check.
* @return bool True if the file has expired, false otherwise.
* @throws \Exception Thrown by DateTime if a wrong file path is passed.
*/
public function file_has_expired( string $file_path ): bool {
$dirname = dirname( $file_path );
$expiration_date = basename( $dirname );
$expiration_date_object = new DateTime( $expiration_date, TimeUtil::get_utc_date_time_zone() );
$today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );
return $expiration_date_object < $today_date_object;
}
/**
* Delete an existing transient file.
*
* @param string $filename The name of the file to delete.
* @return bool True if the file has been deleted, false otherwise (the file didn't exist).
*/
public function delete_transient_file( string $filename ): bool {
$file_path = $this->get_transient_file_path( $filename );
if ( is_null( $file_path ) ) {
return false;
}
$dirname = dirname( $file_path );
wp_delete_file( $file_path );
$this->delete_directory_if_not_empty( $dirname );
return true;
}
/**
* Delete expired transient files from the filesystem.
*
* @param int $limit Maximum number of files to delete.
* @return array "deleted_count" with the number of files actually deleted, "files_remain" that will be true if there are still files left to delete.
* @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist.
*/
public function delete_expired_files( int $limit = 1000 ): array {
$expiration_date_gmt = $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' );
$base_dir = $this->get_transient_files_directory();
$subdirs = glob( $base_dir . '/[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]', GLOB_ONLYDIR );
if ( false === $subdirs ) {
throw new Exception( "Error when getting the list of subdirectories of $base_dir" );
}
$subdirs = array_map( fn( $name ) => substr( $name, strlen( $name ) - 10, 10 ), $subdirs );
$expired_subdirs = array_filter( $subdirs, fn( $name ) => $name < $expiration_date_gmt );
asort( $subdirs ); // We want to delete files starting with the oldest expiration month.
$remaining_limit = $limit;
$limit_reached = false;
foreach ( $expired_subdirs as $subdir ) {
$full_dir_path = $base_dir . '/' . $subdir;
$files_to_delete = glob( $full_dir_path . '/*' );
if ( count( $files_to_delete ) > $remaining_limit ) {
$limit_reached = true;
$files_to_delete = array_slice( $files_to_delete, 0, $remaining_limit );
}
array_map( 'wp_delete_file', $files_to_delete );
$remaining_limit -= count( $files_to_delete );
$this->delete_directory_if_not_empty( $full_dir_path );
if ( $limit_reached ) {
break;
}
}
return array(
'deleted_count' => $limit - $remaining_limit,
'files_remain' => $limit_reached,
);
}
/**
* Is the expired files cleanup action currently scheduled?
*
* @return bool True if the expired files cleanup action is currently scheduled, false otherwise.
*/
public function expired_files_cleanup_is_scheduled(): bool {
return as_has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
}
/**
* Schedule an action that will do one round of expired files cleanup.
* The action is scheduled to run immediately. If a previous pending action exists, it's unscheduled first.
*/
public function schedule_expired_files_cleanup(): void {
$this->unschedule_expired_files_cleanup();
as_schedule_single_action( time() + 1, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
}
/**
* Remove the scheduled action that does the expired files cleanup, if it's scheduled.
*/
public function unschedule_expired_files_cleanup(): void {
if ( $this->expired_files_cleanup_is_scheduled() ) {
as_unschedule_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
}
}
/**
* Run the expired files cleanup action and schedule a new one.
*
* If files are actually deleted then we assume that more files are pending deletion and schedule the next
* action to run immediately. Otherwise (nothing was deleted) we schedule the next action for one day later
* (but this can be changed via the 'woocommerce_delete_expired_transient_files_interval' filter).
*
* If the actual deletion process fails the next action is scheduled anyway for one day later
* or for the interval given by the filter.
*
* NOTE: If the default interval is changed to something different from DAY_IN_SECONDS, please adjust the
* "every 24h" text in add_debug_tools_entries too.
*/
private function handle_expired_files_cleanup_action(): void {
$new_interval = null;
try {
$result = $this->delete_expired_files();
if ( $result['deleted_count'] > 0 ) {
$new_interval = 1;
}
} finally {
if ( is_null( $new_interval ) ) {
/**
* Filter to alter the interval between the actions that delete expired transient files.
*
* @param int $interval The default time before the next action run, in seconds.
* @return int The time to actually wait before the next action run, in seconds.
*
* @since 8.5.0
*/
$new_interval = apply_filters( 'woocommerce_delete_expired_transient_files_interval', DAY_IN_SECONDS );
}
$next_time = $this->legacy_proxy->call_function( 'time' ) + $new_interval;
$this->legacy_proxy->call_function( 'as_schedule_single_action', $next_time, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
}
}
/**
* Add the tools to (re)schedule and un-schedule the expired files cleanup actions in the WooCommerce debug tools page.
*
* @param array $tools_array Original debug tools array.
* @return array Updated debug tools array
*/
private function add_debug_tools_entries( array $tools_array ): array {
$cleanup_is_scheduled = $this->expired_files_cleanup_is_scheduled();
$tools_array['schedule_expired_transient_files_cleanup'] = array(
'name' => $cleanup_is_scheduled ?
__( 'Re-schedule expired transient files cleanup', 'woocommerce' ) :
__( 'Schedule expired transient files cleanup', 'woocommerce' ),
'desc' => $cleanup_is_scheduled ?
__( 'Remove the currently scheduled action to delete expired transient files, then schedule it again for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ) :
__( 'Schedule the action to delete expired transient files for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ),
'button' => $cleanup_is_scheduled ?
__( 'Re-schedule', 'woocommerce' ) :
__( 'Schedule', 'woocommerce' ),
'requires_refresh' => true,
'callback' => array( $this, 'schedule_expired_files_cleanup' ),
);
if ( $cleanup_is_scheduled ) {
$tools_array['unschedule_expired_transient_files_cleanup'] = array(
'name' => __( 'Un-schedule expired transient files cleanup', 'woocommerce' ),
'desc' => __( "Remove the currently scheduled action to delete expired transient files. Expired files won't be automatically deleted until the 'Schedule expired transient files cleanup' tool is run again.", 'woocommerce' ),
'button' => __( 'Un-schedule', 'woocommerce' ),
'requires_refresh' => true,
'callback' => array( $this, 'unschedule_expired_files_cleanup' ),
);
}
return $tools_array;
}
/**
* Delete a directory if it isn't empty.
*
* @param string $directory Full directory path.
*/
private function delete_directory_if_not_empty( string $directory ) {
if ( ! ( new \FilesystemIterator( $directory ) )->valid() ) {
rmdir( $directory );
}
}
/**
* Handle the "init" action, add rewrite rules for the "wc/file" endpoint.
*/
private function handle_init() {
add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' );
add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' );
add_rewrite_endpoint( 'wc/file/transient', EP_ALL );
}
/**
* Handle the "query_vars" action, add the "wc-transient-file-name" variable for the "wc/file/transient" endpoint.
*
* @param array $vars The original query variables.
* @return array The updated query variables.
*/
private function handle_query_vars( $vars ) {
$vars[] = 'wc-transient-file-name';
return $vars;
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing, WordPress.WP.AlternativeFunctions
/**
* Handle the "parse_request" action for the "wc/file/transient" endpoint.
*
* If the request is not for "/wc/file/transient/<filename>" or "index.php?wc-transient-file-name=filename",
* it returns without doing anything. Otherwise, it will serve the contents of the file with the provided name
* if it exists, is public and has not expired; or will return a "Not found" status otherwise.
*
* The file will be served with a content type header of "text/html".
*/
private function handle_parse_request() {
global $wp;
// phpcs:ignore WordPress.Security
$query_arg = wp_unslash( $_GET['wc-transient-file-name'] ?? null );
if ( ! is_null( $query_arg ) ) {
$wp->query_vars['wc-transient-file-name'] = $query_arg;
}
if ( is_null( $wp->query_vars['wc-transient-file-name'] ?? null ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? null ) ) {
status_header( 405 );
exit();
}
$this->serve_file_contents( $wp->query_vars['wc-transient-file-name'] );
}
/**
* Core method to serve the contents of a transient file.
*
* @param string $file_name Transient file id or filename.
*/
private function serve_file_contents( string $file_name ) {
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
try {
$file_path = $this->get_transient_file_path( $file_name );
if ( is_null( $file_path ) ) {
$legacy_proxy->call_function( 'status_header', 404 );
$legacy_proxy->exit();
}
if ( $this->file_has_expired( $file_path ) ) {
$legacy_proxy->call_function( 'status_header', 404 );
$legacy_proxy->exit();
}
$file_length = filesize( $file_path );
if ( false === $file_length ) {
throw new Exception( "Can't retrieve file size: $file_path" );
}
$file_handle = fopen( $file_path, 'r' );
} catch ( Exception $ex ) {
$error_message = "Error serving transient file $file_name: {$ex->getMessage()}";
wc_get_logger()->error( $error_message );
$legacy_proxy->call_function( 'status_header', 500 );
$legacy_proxy->exit();
}
$legacy_proxy->call_function( 'status_header', 200 );
$legacy_proxy->call_function( 'header', 'Content-Type: text/html' );
$legacy_proxy->call_function( 'header', "Content-Length: $file_length" );
try {
while ( ! feof( $file_handle ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo fread( $file_handle, 1024 );
}
/**
* Action that fires after a transient file has been successfully served, right before terminating the request.
*
* @param array $transient_file_info Information about the served file, as returned by get_file_by_name.
* @param bool $is_json_rest_api_request True if the request came from the JSON API endpoint, false if it came from the authenticated endpoint.
*
* @since 8.5.0
*/
do_action( 'woocommerce_transient_file_contents_served', $file_name );
} catch ( Exception $e ) {
wc_get_logger()->error( "Error serving transient file $file_name: {$e->getMessage()}" );
// We can't change the response status code at this point.
} finally {
fclose( $file_handle );
$legacy_proxy->exit();
}
}
}

View File

@@ -25,4 +25,85 @@ class Users {
return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
}
/**
* Check if the email is valid.
*
* @param int $order_id Order ID.
* @param string $supplied_email Supplied email.
* @param string $context Context in which we are checking the email.
* @return bool
*/
public static function should_user_verify_order_email( $order_id, $supplied_email = null, $context = 'view' ) {
$order = wc_get_order( $order_id );
$billing_email = $order->get_billing_email();
$customer_id = $order->get_customer_id();
// If we do not have a billing email for the order (could happen in the order is created manually, or if the
// requirement for this has been removed from the checkout flow), email verification does not make sense.
if ( empty( $billing_email ) ) {
return false;
}
// No verification step is needed if the user is logged in and is already associated with the order.
if ( $customer_id && get_current_user_id() === $customer_id ) {
return false;
}
/**
* Controls the grace period within which we do not require any sort of email verification step before rendering
* the 'order received' or 'order pay' pages.
*
* To eliminate the grace period, set to zero (or to a negative value). Note that this filter is not invoked
* at all if email verification is deemed to be unnecessary (in other words, it cannot be used to force
* verification in *all* cases).
*
* @since 8.0.0
*
* @param int $grace_period Time in seconds after an order is placed before email verification may be required.
* @param WC_Order $this The order for which this grace period is being assessed.
* @param string $context Indicates the context in which we might verify the email address. Typically 'order-pay' or 'order-received'.
*/
$verification_grace_period = (int) apply_filters( 'woocommerce_order_email_verification_grace_period', 10 * MINUTE_IN_SECONDS, $order, $context );
$date_created = $order->get_date_created();
// We do not need to verify the email address if we are within the grace period immediately following order creation.
if (
is_a( $date_created, \WC_DateTime::class, true )
&& time() - $date_created->getTimestamp() <= $verification_grace_period
) {
return false;
}
$session = wc()->session;
$session_email = '';
if ( is_a( $session, \WC_Session::class ) ) {
$customer = $session->get( 'customer' );
$session_email = is_array( $customer ) && isset( $customer['email'] ) ? $customer['email'] : '';
}
// Email verification is required if the user cannot be identified, or if they supplied an email address but the nonce check failed.
$can_view_orders = current_user_can( 'read_private_shop_orders' );
$session_email_match = $session_email === $billing_email;
$supplied_email_match = $supplied_email === $billing_email;
$email_verification_required = ! $session_email_match && ! $supplied_email_match && ! $can_view_orders;
/**
* Provides an opportunity to override the (potential) requirement for shoppers to verify their email address
* before we show information such as the order summary, or order payment page.
*
* Note that this hook is not always triggered, therefore it is (for example) unsuitable as a way of forcing
* email verification across all order confirmation/order payment scenarios. Instead, the filter primarily
* exists as a way to *remove* the email verification step.
*
* @since 7.9.0
*
* @param bool $email_verification_required If email verification is required.
* @param WC_Order $order The relevant order.
* @param string $context The context under which we are performing this check.
*/
return (bool) apply_filters( 'woocommerce_order_email_verification_required', $email_verification_required, $order, $context );
}
}