Merged in feature/280-dev-dev01 (pull request #21)
auto-patch 280-dev-dev01-2024-01-19T16_41_58 * auto-patch 280-dev-dev01-2024-01-19T16_41_58
This commit is contained in:
@@ -126,7 +126,7 @@ class Homescreen {
|
||||
if (
|
||||
( 'US' === $country_code && $is_jetpack_installed )
|
||||
||
|
||||
( ! in_array( $country_code, array( 'CA', 'AU', 'GB', 'ES', 'IT', 'DE', 'FR', 'MX', 'CO', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ) )
|
||||
( ! in_array( $country_code, array( 'CA', 'AU', 'NZ', 'SG', 'HK', 'GB', 'ES', 'IT', 'DE', 'FR', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ) )
|
||||
||
|
||||
( 'US' === $country_code && false === $is_jetpack_installed && false === $is_wcs_installed )
|
||||
) {
|
||||
|
||||
@@ -92,36 +92,9 @@ class Loader {
|
||||
*/
|
||||
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
|
||||
|
||||
add_action( 'admin_init', array( __CLASS__, 'deactivate_wc_admin_plugin' ) );
|
||||
|
||||
add_action( 'load-themes.php', array( __CLASS__, 'add_appearance_theme_view_tracks_event' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* If WooCommerce Admin is installed and activated, it will attempt to deactivate and show a notice.
|
||||
*/
|
||||
public static function deactivate_wc_admin_plugin() {
|
||||
$plugin_path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
|
||||
if ( is_plugin_active( $plugin_path ) ) {
|
||||
$path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
|
||||
deactivate_plugins( $path );
|
||||
$notice_action = is_network_admin() ? 'network_admin_notices' : 'admin_notices';
|
||||
add_action(
|
||||
$notice_action,
|
||||
function() {
|
||||
echo '<div class="error"><p>';
|
||||
printf(
|
||||
/* translators: %s: is referring to the plugin's name. */
|
||||
esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ),
|
||||
'<code>WooCommerce Admin</code>',
|
||||
'<code>WooCommerce</code>'
|
||||
);
|
||||
echo '</p></div>';
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns breadcrumbs for the current page.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use WP_Filesystem_Direct;
|
||||
|
||||
/**
|
||||
@@ -35,9 +36,9 @@ class File {
|
||||
/**
|
||||
* The date the file was created, as a Unix timestamp, derived from the filename.
|
||||
*
|
||||
* @var int|false
|
||||
* @var int
|
||||
*/
|
||||
protected $created = false;
|
||||
protected $created = 0;
|
||||
|
||||
/**
|
||||
* The hash property of the file, derived from the filename.
|
||||
@@ -59,13 +60,16 @@ class File {
|
||||
* @param string $path The absolute path of the file.
|
||||
*/
|
||||
public function __construct( $path ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
|
||||
global $wp_filesystem;
|
||||
|
||||
if ( ! $wp_filesystem instanceof WP_Filesystem_Direct ) {
|
||||
WP_Filesystem();
|
||||
}
|
||||
|
||||
$this->path = $path;
|
||||
$this->parse_filename();
|
||||
$this->ingest_path();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,51 +83,155 @@ class File {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the log filename to derive certain properties of the file.
|
||||
* Parse a path to a log file to determine if it uses the standard filename structure and various properties.
|
||||
*
|
||||
* This makes assumptions about the structure of the log file's name. Using `-` to separate the name into segments,
|
||||
* if there are at least 5 segments, it assumes that the last segment is the hash, and the three segments before
|
||||
* that make up the date when the file was created in YYYY-MM-DD format. Any segments left after that are the
|
||||
* "source" that generated the log entries. If the filename doesn't have enough segments, it falls back to the
|
||||
* source and the hash both being the entire filename, and using the inode change time as the creation date.
|
||||
* if there are at least 5 segments, it assumes that the last segment is the hash, and the three segments before
|
||||
* that make up the date when the file was created in YYYY-MM-DD format. Any segments left after that are the
|
||||
* "source" that generated the log entries. If the filename doesn't have enough segments, it falls back to the
|
||||
* source and the hash both being the entire filename, and using the inode change time as the creation date.
|
||||
*
|
||||
* Example:
|
||||
* my-custom-plugin.2-2025-01-01-a1b2c3d4e5f.log
|
||||
* | | | |
|
||||
* 'my-custom-plugin' | '2025-01-01' |
|
||||
* (source) | (created) |
|
||||
* '2' 'a1b2c3d4e5f'
|
||||
* (rotation) (hash)
|
||||
* Example:
|
||||
* my-custom-plugin.2-2025-01-01-a1b2c3d4e5f.log
|
||||
* | | | |
|
||||
* 'my-custom-plugin' | '2025-01-01' |
|
||||
* (source) | (created) |
|
||||
* '2' 'a1b2c3d4e5f'
|
||||
* (rotation) (hash)
|
||||
*
|
||||
* @param string $path The full path of the log file.
|
||||
*
|
||||
* @return array {
|
||||
* @type string $dirname The directory structure containing the file. See pathinfo().
|
||||
* @type string $basename The filename with extension. See pathinfo().
|
||||
* @type string $extension The file extension. See pathinfo().
|
||||
* @type string $filename The filename without extension. See pathinfo().
|
||||
* @type string $source The source of the log entries contained in the file.
|
||||
* @type int|null $rotation The 0-based incremental rotation marker, if the file has been rotated.
|
||||
* Should only be a single digit.
|
||||
* @type int $created The date the file was created, as a Unix timestamp.
|
||||
* @type string $hash The hash suffix of the filename that protects from direct access.
|
||||
* @type string $file_id The public ID of the log file (filename without the hash).
|
||||
* }
|
||||
*/
|
||||
public static function parse_path( string $path ): array {
|
||||
$defaults = array(
|
||||
'dirname' => '',
|
||||
'basename' => '',
|
||||
'extension' => '',
|
||||
'filename' => '',
|
||||
'source' => '',
|
||||
'rotation' => null,
|
||||
'created' => 0,
|
||||
'hash' => '',
|
||||
'file_id' => '',
|
||||
);
|
||||
|
||||
$parsed = array_merge( $defaults, pathinfo( $path ) );
|
||||
|
||||
$segments = explode( '-', $parsed['filename'] );
|
||||
$timestamp = strtotime( implode( '-', array_slice( $segments, -4, 3 ) ) );
|
||||
|
||||
if ( count( $segments ) >= 5 && false !== $timestamp ) {
|
||||
$parsed['source'] = implode( '-', array_slice( $segments, 0, -4 ) );
|
||||
$parsed['created'] = $timestamp;
|
||||
$parsed['hash'] = array_slice( $segments, -1 )[0];
|
||||
} else {
|
||||
$parsed['source'] = implode( '-', $segments );
|
||||
}
|
||||
|
||||
$rotation_marker = strrpos( $parsed['source'], '.', -1 );
|
||||
if ( false !== $rotation_marker ) {
|
||||
$rotation = substr( $parsed['source'], -1 );
|
||||
if ( is_numeric( $rotation ) ) {
|
||||
$parsed['rotation'] = intval( $rotation );
|
||||
}
|
||||
|
||||
$parsed['source'] = substr( $parsed['source'], 0, $rotation_marker );
|
||||
}
|
||||
|
||||
$parsed['file_id'] = static::generate_file_id(
|
||||
$parsed['source'],
|
||||
$parsed['rotation'],
|
||||
$parsed['created']
|
||||
);
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a public ID for a log file based on its properties.
|
||||
*
|
||||
* The file ID is the basename of the file without the hash part. It allows us to identify a file without revealing
|
||||
* its full name in the filesystem, so that it's difficult to access the file directly with an HTTP request.
|
||||
*
|
||||
* @param string $source The source of the log entries contained in the file.
|
||||
* @param int|null $rotation Optional. The 0-based incremental rotation marker, if the file has been rotated.
|
||||
* Should only be a single digit.
|
||||
* @param int $created Optional. The date the file was created, as a Unix timestamp.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function generate_file_id( string $source, ?int $rotation = null, int $created = 0 ): string {
|
||||
$file_id = static::sanitize_source( $source );
|
||||
|
||||
if ( ! is_null( $rotation ) ) {
|
||||
$file_id .= '.' . $rotation;
|
||||
}
|
||||
|
||||
if ( $created > 0 ) {
|
||||
$file_id .= '-' . gmdate( 'Y-m-d', $created );
|
||||
}
|
||||
|
||||
return $file_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash to use as the suffix on a log filename.
|
||||
*
|
||||
* @param string $file_id A file ID (file basename without the hash).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function generate_hash( string $file_id ): string {
|
||||
$key = Constants::get_constant( 'AUTH_SALT' ) ?? 'wc-logs';
|
||||
|
||||
return hash_hmac( 'md5', $file_id, $key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the source property of a log file.
|
||||
*
|
||||
* @param string $source The source of the log entries contained in the file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize_source( string $source ): string {
|
||||
return sanitize_file_name( $source );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the log file path and assign various properties to this class instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parse_filename(): void {
|
||||
$info = pathinfo( $this->path );
|
||||
$filename = $info['filename'];
|
||||
$segments = explode( '-', $filename );
|
||||
protected function ingest_path(): void {
|
||||
$parsed_path = static::parse_path( $this->path );
|
||||
$this->source = $parsed_path['source'];
|
||||
$this->rotation = $parsed_path['rotation'];
|
||||
$this->created = $parsed_path['created'];
|
||||
$this->hash = $parsed_path['hash'];
|
||||
}
|
||||
|
||||
if ( count( $segments ) >= 5 ) {
|
||||
$this->source = implode( '-', array_slice( $segments, 0, -4 ) );
|
||||
$this->created = strtotime( implode( '-', array_slice( $segments, -4, 3 ) ) );
|
||||
$this->hash = array_slice( $segments, -1 )[0];
|
||||
} else {
|
||||
$this->source = implode( '-', $segments );
|
||||
$this->created = filectime( $this->path );
|
||||
$this->hash = $this->source;
|
||||
}
|
||||
|
||||
$rotation_marker = strrpos( $this->source, '.', -1 );
|
||||
if ( false !== $rotation_marker ) {
|
||||
$rotation = substr( $this->source, -1 );
|
||||
if ( is_numeric( $rotation ) ) {
|
||||
$this->rotation = intval( $rotation );
|
||||
}
|
||||
|
||||
$this->source = substr( $this->source, 0, $rotation_marker );
|
||||
if ( count( $segments ) < 5 ) {
|
||||
$this->hash = $this->source;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Check if the filename structure is in the expected format.
|
||||
*
|
||||
* @see parse_path().
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_standard_filename(): bool {
|
||||
return ! ! $this->get_hash();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,11 +261,15 @@ class File {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a read-only stream file this file.
|
||||
* Open a read-only stream for this file.
|
||||
*
|
||||
* @return resource|false
|
||||
*/
|
||||
public function get_stream() {
|
||||
if ( ! $this->is_readable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! is_resource( $this->stream ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
|
||||
$this->stream = fopen( $this->path, 'rb' );
|
||||
@@ -166,6 +278,28 @@ class File {
|
||||
return $this->stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream for this file.
|
||||
*
|
||||
* The stream will also close automatically when the class instance destructs, but this can be useful for
|
||||
* avoiding having a large number of streams open simultaneously.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function close_stream(): bool {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
|
||||
return fclose( $this->stream );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full absolute path of the file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_path(): string {
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file, with extension, but without full path.
|
||||
*
|
||||
@@ -205,21 +339,19 @@ class File {
|
||||
/**
|
||||
* Get the file's public ID.
|
||||
*
|
||||
* The file ID is the basename of the file without the hash part. It allows us to identify a file without revealing
|
||||
* its full name in the filesystem, so that it's difficult to access the file directly with an HTTP request.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_file_id(): string {
|
||||
$file_id = $this->get_source();
|
||||
|
||||
if ( ! is_null( $this->get_rotation() ) ) {
|
||||
$file_id .= '.' . $this->get_rotation();
|
||||
$created = 0;
|
||||
if ( $this->has_standard_filename() ) {
|
||||
$created = $this->get_created_timestamp();
|
||||
}
|
||||
|
||||
if ( $this->get_source() !== $this->get_hash() ) {
|
||||
$file_id .= '-' . gmdate( 'Y-m-d', $this->get_created_timestamp() );
|
||||
}
|
||||
$file_id = static::generate_file_id(
|
||||
$this->get_source(),
|
||||
$this->get_rotation(),
|
||||
$created
|
||||
);
|
||||
|
||||
return $file_id;
|
||||
}
|
||||
@@ -227,9 +359,13 @@ class File {
|
||||
/**
|
||||
* Get the file's created property.
|
||||
*
|
||||
* @return int|false
|
||||
* @return int
|
||||
*/
|
||||
public function get_created_timestamp() {
|
||||
public function get_created_timestamp(): int {
|
||||
if ( ! $this->created && $this->is_readable() ) {
|
||||
$this->created = filectime( $this->path );
|
||||
}
|
||||
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
@@ -263,6 +399,107 @@ class File {
|
||||
return $wp_filesystem->size( $this->path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and set permissions on the file.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function create(): bool {
|
||||
global $wp_filesystem;
|
||||
|
||||
$created = $wp_filesystem->touch( $this->path );
|
||||
$modded = $wp_filesystem->chmod( $this->path );
|
||||
|
||||
return $created && $modded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to the file, appending it to the end.
|
||||
*
|
||||
* @param string $text The content to add to the file.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function write( string $text ): bool {
|
||||
if ( ! $this->is_writable() ) {
|
||||
$created = $this->create();
|
||||
|
||||
if ( ! $created || ! $this->is_writable() ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure content ends with a line ending.
|
||||
$eol_pos = strrpos( $text, PHP_EOL );
|
||||
if ( false === $eol_pos || strlen( $text ) !== $eol_pos + 1 ) {
|
||||
$text .= PHP_EOL;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
|
||||
$resource = fopen( $this->path, 'ab' );
|
||||
|
||||
mbstring_binary_safe_encoding();
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite -- No suitable alternative.
|
||||
$bytes_written = fwrite( $resource, $text );
|
||||
reset_mbstring_encoding();
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
|
||||
fclose( $resource );
|
||||
|
||||
if ( strlen( $text ) !== $bytes_written ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename this file with an incremented rotation number.
|
||||
*
|
||||
* @return bool True if the file was successfully rotated.
|
||||
*/
|
||||
public function rotate(): bool {
|
||||
if ( ! $this->is_writable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wp_filesystem;
|
||||
|
||||
$created = 0;
|
||||
if ( $this->has_standard_filename() ) {
|
||||
$created = $this->get_created_timestamp();
|
||||
}
|
||||
|
||||
if ( is_null( $this->get_rotation() ) ) {
|
||||
$new_rotation = 0;
|
||||
} else {
|
||||
$new_rotation = $this->get_rotation() + 1;
|
||||
}
|
||||
|
||||
$new_file_id = static::generate_file_id( $this->get_source(), $new_rotation, $created );
|
||||
|
||||
$search = array( $this->get_file_id() );
|
||||
$replace = array( $new_file_id );
|
||||
if ( $this->has_standard_filename() ) {
|
||||
$search[] = $this->get_hash();
|
||||
$replace[] = static::generate_hash( $new_file_id );
|
||||
}
|
||||
|
||||
$old_filename = $this->get_basename();
|
||||
$new_filename = str_replace( $search, $replace, $old_filename );
|
||||
$new_path = str_replace( $old_filename, $new_filename, $this->path );
|
||||
|
||||
$moved = $wp_filesystem->move( $this->path, $new_path, true );
|
||||
if ( ! $moved ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->path = $new_path;
|
||||
$this->ingest_path();
|
||||
|
||||
return $this->is_readable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the file from the filesystem.
|
||||
*
|
||||
|
||||
@@ -4,25 +4,77 @@ declare( strict_types = 1 );
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use PclZip;
|
||||
use WC_Cache_Helper;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* FileController class.
|
||||
*/
|
||||
class FileController {
|
||||
/**
|
||||
* The maximum number of rotations for a file before they start getting overwritten.
|
||||
*
|
||||
* This number should not go above 10, or it will cause issues with the glob patterns.
|
||||
*
|
||||
* const int
|
||||
*/
|
||||
private const MAX_FILE_ROTATIONS = 10;
|
||||
|
||||
/**
|
||||
* Default values for arguments for the get_files method.
|
||||
*
|
||||
* @const array
|
||||
*/
|
||||
public const DEFAULTS_GET_FILES = array(
|
||||
'offset' => 0,
|
||||
'order' => 'desc',
|
||||
'orderby' => 'modified',
|
||||
'per_page' => 20,
|
||||
'source' => '',
|
||||
'date_end' => 0,
|
||||
'date_filter' => '',
|
||||
'date_start' => 0,
|
||||
'offset' => 0,
|
||||
'order' => 'desc',
|
||||
'orderby' => 'modified',
|
||||
'per_page' => 20,
|
||||
'source' => '',
|
||||
);
|
||||
|
||||
/**
|
||||
* Default values for arguments for the search_within_files method.
|
||||
*
|
||||
* @const array
|
||||
*/
|
||||
public const DEFAULTS_SEARCH_WITHIN_FILES = array(
|
||||
'offset' => 0,
|
||||
'per_page' => 50,
|
||||
);
|
||||
|
||||
/**
|
||||
* The maximum number of files that can be searched at one time.
|
||||
*
|
||||
* @const int
|
||||
*/
|
||||
public const SEARCH_MAX_FILES = 100;
|
||||
|
||||
/**
|
||||
* The maximum number of search results that can be returned at one time.
|
||||
*
|
||||
* @const int
|
||||
*/
|
||||
public const SEARCH_MAX_RESULTS = 200;
|
||||
|
||||
/**
|
||||
* The cache group name to use for caching operations.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
private const CACHE_GROUP = 'log-files';
|
||||
|
||||
/**
|
||||
* A cache key for storing and retrieving the results of the last logs search.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
private const SEARCH_CACHE_KEY = 'logs_previous_search';
|
||||
|
||||
/**
|
||||
* The absolute path to the log directory.
|
||||
*
|
||||
@@ -37,17 +89,125 @@ class FileController {
|
||||
$this->log_directory = trailingslashit( realpath( Constants::get_constant( 'WC_LOG_DIR' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file size limit that determines when to rotate a file.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function get_file_size_limit(): int {
|
||||
$default = 5 * MB_IN_BYTES;
|
||||
|
||||
/**
|
||||
* Filter the threshold size of a log file at which point it will get rotated.
|
||||
*
|
||||
* @since 3.4.0
|
||||
*
|
||||
* @param int $file_size_limit The file size limit in bytes.
|
||||
*/
|
||||
$file_size_limit = apply_filters( 'woocommerce_log_file_size_limit', $default );
|
||||
|
||||
if ( ! is_int( $file_size_limit ) || $file_size_limit < 1 ) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $file_size_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry to the appropriate file, after rotating the file if necessary.
|
||||
*
|
||||
* @param string $source The source property of the log entry, which determines which file to write to.
|
||||
* @param string $text The contents of the log entry to add to a file.
|
||||
* @param int|null $time Optional. The time of the log entry as a Unix timestamp. Defaults to the current time.
|
||||
*
|
||||
* @return bool True if the contents were successfully written to the file.
|
||||
*/
|
||||
public function write_to_file( string $source, string $text, ?int $time = null ): bool {
|
||||
if ( is_null( $time ) ) {
|
||||
$time = time();
|
||||
}
|
||||
|
||||
$file_id = File::generate_file_id( $source, null, $time );
|
||||
$file = $this->get_file_by_id( $file_id );
|
||||
|
||||
if ( $file instanceof File && $file->get_file_size() >= $this->get_file_size_limit() ) {
|
||||
$rotated = $this->rotate_file( $file->get_file_id() );
|
||||
|
||||
if ( $rotated ) {
|
||||
$file = null;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $file instanceof File ) {
|
||||
$new_path = $this->log_directory . $this->generate_filename( $source, $time );
|
||||
$file = new File( $new_path );
|
||||
}
|
||||
|
||||
return $file->write( $text );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full name of a file based on source and date values.
|
||||
*
|
||||
* @param string $source The source property of a log entry, which determines the filename.
|
||||
* @param int $time The time of the log entry as a Unix timestamp.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generate_filename( string $source, int $time ): string {
|
||||
$file_id = File::generate_file_id( $source, null, $time );
|
||||
$hash = File::generate_hash( $file_id );
|
||||
|
||||
return "$file_id-$hash.log";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the rotations of a file and increment them, so that they overwrite the previous file with that rotation.
|
||||
*
|
||||
* @param string $file_id A file ID (file basename without the hash).
|
||||
*
|
||||
* @return bool True if the file and all its rotations were successfully rotated.
|
||||
*/
|
||||
private function rotate_file( $file_id ): bool {
|
||||
$rotations = $this->get_file_rotations( $file_id );
|
||||
|
||||
if ( is_wp_error( $rotations ) || ! isset( $rotations['current'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$max_rotation_marker = self::MAX_FILE_ROTATIONS - 1;
|
||||
|
||||
// Don't rotate a file with the maximum rotation.
|
||||
unset( $rotations[ $max_rotation_marker ] );
|
||||
|
||||
$results = array();
|
||||
// Rotate starting with oldest first and working backwards.
|
||||
for ( $i = $max_rotation_marker; $i >= 0; $i -- ) {
|
||||
if ( isset( $rotations[ $i ] ) ) {
|
||||
$results[] = $rotations[ $i ]->rotate();
|
||||
}
|
||||
}
|
||||
$results[] = $rotations['current']->rotate();
|
||||
|
||||
return ! in_array( false, $results, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of log files.
|
||||
*
|
||||
* @param array $args {
|
||||
* Optional. Arguments to filter and sort the files that are returned.
|
||||
*
|
||||
* @type int $offset Omit this number of files from the beginning of the list. Works with $per_page to do pagination.
|
||||
* @type string $order The sort direction. 'asc' or 'desc'. Defaults to 'desc'.
|
||||
* @type string $orderby The property to sort the list by. 'created', 'modified', 'source', 'size'. Defaults to 'modified'.
|
||||
* @type int $per_page The number of files to include in the list. Works with $offset to do pagination.
|
||||
* @type string $source Only include files from this source.
|
||||
* @type int $date_end The end of the date range to filter by, as a Unix timestamp.
|
||||
* @type string $date_filter Filter files by one of the date props. 'created' or 'modified'.
|
||||
* @type int $date_start The beginning of the date range to filter by, as a Unix timestamp.
|
||||
* @type int $offset Omit this number of files from the beginning of the list. Works with $per_page to do pagination.
|
||||
* @type string $order The sort direction. 'asc' or 'desc'. Defaults to 'desc'.
|
||||
* @type string $orderby The property to sort the list by. 'created', 'modified', 'source', 'size'. Defaults to 'modified'.
|
||||
* @type int $per_page The number of files to include in the list. Works with $offset to do pagination.
|
||||
* @type string $source Only include files from this source.
|
||||
* }
|
||||
* @param bool $count_only Optional. True to return a total count of the files.
|
||||
*
|
||||
@@ -66,11 +226,30 @@ class FileController {
|
||||
);
|
||||
}
|
||||
|
||||
if ( true === $count_only ) {
|
||||
return count( $paths );
|
||||
$files = $this->convert_paths_to_objects( $paths );
|
||||
|
||||
if ( $args['date_filter'] && $args['date_start'] && $args['date_end'] ) {
|
||||
switch ( $args['date_filter'] ) {
|
||||
case 'created':
|
||||
$files = array_filter(
|
||||
$files,
|
||||
fn( $file ) => $file->get_created_timestamp() >= $args['date_start']
|
||||
&& $file->get_created_timestamp() <= $args['date_end']
|
||||
);
|
||||
break;
|
||||
case 'modified':
|
||||
$files = array_filter(
|
||||
$files,
|
||||
fn( $file ) => $file->get_modified_timestamp() >= $args['date_start']
|
||||
&& $file->get_modified_timestamp() <= $args['date_end']
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$files = $this->convert_paths_to_objects( $paths );
|
||||
if ( true === $count_only ) {
|
||||
return count( $files );
|
||||
}
|
||||
|
||||
$multi_sorter = function( $sort_sets, $order_sets ) {
|
||||
$comparison = 0;
|
||||
@@ -156,14 +335,19 @@ class FileController {
|
||||
$paths = array();
|
||||
|
||||
foreach ( $file_ids as $file_id ) {
|
||||
$glob = glob( $this->log_directory . $file_id . '*.log' );
|
||||
// Look for the standard filename format first, which includes a hash.
|
||||
$glob = glob( $this->log_directory . $file_id . '-*.log' );
|
||||
|
||||
if ( ! $glob ) {
|
||||
$glob = glob( $this->log_directory . $file_id . '.log' );
|
||||
}
|
||||
|
||||
if ( is_array( $glob ) ) {
|
||||
$paths = array_merge( $paths, $glob );
|
||||
}
|
||||
}
|
||||
|
||||
$files = $this->convert_paths_to_objects( $paths );
|
||||
$files = $this->convert_paths_to_objects( array_unique( $paths ) );
|
||||
|
||||
return $files;
|
||||
}
|
||||
@@ -185,6 +369,13 @@ class FileController {
|
||||
);
|
||||
}
|
||||
|
||||
if ( count( $result ) > 1 ) {
|
||||
return new WP_Error(
|
||||
'wc_log_file_error',
|
||||
esc_html__( 'Multiple files match this ID.', 'woocommerce' )
|
||||
);
|
||||
}
|
||||
|
||||
return reset( $result );
|
||||
}
|
||||
|
||||
@@ -207,19 +398,32 @@ class FileController {
|
||||
$rotations = array();
|
||||
|
||||
$source = $file->get_source();
|
||||
$created = gmdate( 'Y-m-d', $file->get_created_timestamp() );
|
||||
$created = 0;
|
||||
if ( $file->has_standard_filename() ) {
|
||||
$created = $file->get_created_timestamp();
|
||||
}
|
||||
|
||||
if ( is_null( $file->get_rotation() ) ) {
|
||||
$current['current'] = $file;
|
||||
} else {
|
||||
$current_file_id = $source . '-' . $created;
|
||||
$current_file_id = File::generate_file_id( $source, null, $created );
|
||||
$result = $this->get_file_by_id( $current_file_id );
|
||||
if ( ! is_wp_error( $result ) ) {
|
||||
$current['current'] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
$rotation_pattern = $this->log_directory . $source . '.[0123456789]-' . $created . '*.log';
|
||||
$rotations_pattern = sprintf(
|
||||
'.[%s]',
|
||||
implode(
|
||||
'',
|
||||
range( 0, self::MAX_FILE_ROTATIONS - 1 )
|
||||
)
|
||||
);
|
||||
|
||||
$created_pattern = $created ? '-' . gmdate( 'Y-m-d', $created ) . '-' : '';
|
||||
|
||||
$rotation_pattern = $this->log_directory . $source . $rotations_pattern . $created_pattern . '*.log';
|
||||
$rotation_paths = glob( $rotation_pattern );
|
||||
$rotation_files = $this->convert_paths_to_objects( $rotation_paths );
|
||||
foreach ( $rotation_files as $rotation_file ) {
|
||||
@@ -282,7 +486,7 @@ class FileController {
|
||||
*
|
||||
* @param array $file_ids An array of file IDs (file basename without the hash).
|
||||
*
|
||||
* @return int
|
||||
* @return int The number of files that were deleted.
|
||||
*/
|
||||
public function delete_files( array $file_ids ): int {
|
||||
$deleted = 0;
|
||||
@@ -290,7 +494,7 @@ class FileController {
|
||||
$files = $this->get_files_by_id( $file_ids );
|
||||
foreach ( $files as $file ) {
|
||||
$result = false;
|
||||
if ( $file->is_readable() ) {
|
||||
if ( $file->is_writable() ) {
|
||||
$result = $file->delete();
|
||||
}
|
||||
|
||||
@@ -299,17 +503,173 @@ class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
if ( $deleted > 0 ) {
|
||||
$this->invalidate_cache();
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the source property of a log file.
|
||||
* Stream a single file to the browser without zipping it first.
|
||||
*
|
||||
* @param string $source The source property of a log file.
|
||||
* @param string $file_id A file ID (file basename without the hash).
|
||||
*
|
||||
* @return string
|
||||
* @return WP_Error|void Only returns something if there is an error.
|
||||
*/
|
||||
public function sanitize_source( string $source ): string {
|
||||
return sanitize_file_name( $source );
|
||||
public function export_single_file( $file_id ) {
|
||||
$file = $this->get_file_by_id( $file_id );
|
||||
|
||||
if ( is_wp_error( $file ) ) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$file_name = $file->get_file_id() . '.log';
|
||||
$exporter = new FileExporter( $file->get_path(), $file_name );
|
||||
|
||||
return $exporter->emit_file();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a zip archive of log files and stream it to the browser.
|
||||
*
|
||||
* @param array $file_ids An array of file IDs (file basename without the hash).
|
||||
*
|
||||
* @return WP_Error|void Only returns something if there is an error.
|
||||
*/
|
||||
public function export_multiple_files( array $file_ids ) {
|
||||
$files = $this->get_files_by_id( $file_ids );
|
||||
|
||||
if ( count( $files ) < 1 ) {
|
||||
return new WP_Error(
|
||||
'wc_logs_invalid_file',
|
||||
__( 'Could not access the specified files.', 'woocommerce' )
|
||||
);
|
||||
}
|
||||
|
||||
$temp_dir = get_temp_dir();
|
||||
|
||||
if ( ! is_dir( $temp_dir ) || ! wp_is_writable( $temp_dir ) ) {
|
||||
return new WP_Error(
|
||||
'wc_logs_invalid_directory',
|
||||
__( 'Could not write to the temp directory. Try downloading files one at a time instead.', 'woocommerce' )
|
||||
);
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
|
||||
|
||||
$path = trailingslashit( $temp_dir ) . 'woocommerce_logs_' . gmdate( 'Y-m-d_H-i-s' ) . '.zip';
|
||||
$file_paths = array_map(
|
||||
fn( $file ) => $file->get_path(),
|
||||
$files
|
||||
);
|
||||
$archive = new PclZip( $path );
|
||||
|
||||
$archive->create( $file_paths, PCLZIP_OPT_REMOVE_ALL_PATH );
|
||||
|
||||
$exporter = new FileExporter( $path );
|
||||
|
||||
return $exporter->emit_file();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search within a set of log files for a particular string.
|
||||
*
|
||||
* @param string $search The string to search for.
|
||||
* @param array $args Optional. Arguments for pagination of search results.
|
||||
* @param array $file_args Optional. Arguments to filter and sort the files that are returned. See get_files().
|
||||
* @param bool $count_only Optional. True to return a total count of the matches.
|
||||
*
|
||||
* @return array|int|WP_Error When matches are found, each array item is an associative array that includes the
|
||||
* file ID, line number, and the matched string with HTML markup around the matched parts.
|
||||
*/
|
||||
public function search_within_files( string $search, array $args = array(), array $file_args = array(), bool $count_only = false ) {
|
||||
if ( '' === $search ) {
|
||||
return $count_only ? 0 : array();
|
||||
}
|
||||
|
||||
$search = esc_html( $search );
|
||||
|
||||
$args = wp_parse_args( $args, self::DEFAULTS_SEARCH_WITHIN_FILES );
|
||||
|
||||
$file_args = array_merge(
|
||||
$file_args,
|
||||
array(
|
||||
'offset' => 0,
|
||||
'per_page' => self::SEARCH_MAX_FILES,
|
||||
)
|
||||
);
|
||||
|
||||
$cache_key = WC_Cache_Helper::get_prefixed_key( self::SEARCH_CACHE_KEY, self::CACHE_GROUP );
|
||||
$query = wp_json_encode( array( $search, $args, $file_args ) );
|
||||
$cache = wp_cache_get( $cache_key );
|
||||
$is_cached = isset( $cache['query'], $cache['results'] ) && $query === $cache['query'];
|
||||
|
||||
if ( true === $is_cached ) {
|
||||
$matched_lines = $cache['results'];
|
||||
} else {
|
||||
$files = $this->get_files( $file_args );
|
||||
if ( is_wp_error( $files ) ) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Max string size * SEARCH_MAX_RESULTS = ~1MB largest possible cache entry.
|
||||
$max_string_size = 5 * KB_IN_BYTES;
|
||||
|
||||
$matched_lines = array();
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
$stream = $file->get_stream();
|
||||
$line_number = 1;
|
||||
|
||||
while ( ! feof( $stream ) ) {
|
||||
$line = fgets( $stream, $max_string_size );
|
||||
if ( ! is_string( $line ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized_line = esc_html( trim( $line ) );
|
||||
if ( false !== stripos( $sanitized_line, $search ) ) {
|
||||
$matched_lines[] = array(
|
||||
'file_id' => $file->get_file_id(),
|
||||
'line_number' => $line_number,
|
||||
'line' => $sanitized_line,
|
||||
);
|
||||
}
|
||||
|
||||
if ( count( $matched_lines ) >= self::SEARCH_MAX_RESULTS ) {
|
||||
$file->close_stream();
|
||||
break 2;
|
||||
}
|
||||
|
||||
if ( false !== strstr( $line, PHP_EOL ) ) {
|
||||
$line_number ++;
|
||||
}
|
||||
}
|
||||
|
||||
$file->close_stream();
|
||||
}
|
||||
|
||||
$to_cache = array(
|
||||
'query' => $query,
|
||||
'results' => $matched_lines,
|
||||
);
|
||||
wp_cache_set( $cache_key, $to_cache, self::CACHE_GROUP, DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
if ( true === $count_only ) {
|
||||
return count( $matched_lines );
|
||||
}
|
||||
|
||||
return array_slice( $matched_lines, $args['offset'], $args['per_page'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache group related to log file data.
|
||||
*
|
||||
* @return bool True on successfully invalidating the cache.
|
||||
*/
|
||||
public function invalidate_cache(): bool {
|
||||
return WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
|
||||
|
||||
use WP_Error;
|
||||
use WP_Filesystem_Direct;
|
||||
|
||||
/**
|
||||
* FileExport class.
|
||||
*/
|
||||
class FileExporter {
|
||||
/**
|
||||
* The number of bytes per read while streaming the file.
|
||||
*
|
||||
* @const int
|
||||
*/
|
||||
private const CHUNK_SIZE = 4 * KB_IN_BYTES;
|
||||
|
||||
/**
|
||||
* The absolute path of the file.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $path;
|
||||
|
||||
/**
|
||||
* A name of the file to send to the browser rather than the filename part of the path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $alternate_filename;
|
||||
|
||||
/**
|
||||
* Class FileExporter.
|
||||
*
|
||||
* @param string $path The absolute path of the file.
|
||||
* @param string $alternate_filename Optional. The name of the file to send to the browser rather than the filename
|
||||
* part of the path.
|
||||
*/
|
||||
public function __construct( string $path, string $alternate_filename = '' ) {
|
||||
global $wp_filesystem;
|
||||
if ( ! $wp_filesystem instanceof WP_Filesystem_Direct ) {
|
||||
WP_Filesystem();
|
||||
}
|
||||
|
||||
$this->path = $path;
|
||||
$this->alternate_filename = $alternate_filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure PHP and stream the file to the browser.
|
||||
*
|
||||
* @return WP_Error|void Only returns something if there is an error.
|
||||
*/
|
||||
public function emit_file() {
|
||||
global $wp_filesystem;
|
||||
if ( ! $wp_filesystem->is_file( $this->path ) || ! $wp_filesystem->is_readable( $this->path ) ) {
|
||||
return new WP_Error(
|
||||
'wc_logs_invalid_file',
|
||||
__( 'Could not access file.', 'woocommerce' )
|
||||
);
|
||||
}
|
||||
|
||||
// These configuration tweaks are copied from WC_CSV_Exporter::send_headers().
|
||||
// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
if ( function_exists( 'gc_enable' ) ) {
|
||||
gc_enable(); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.gc_enableFound
|
||||
}
|
||||
if ( function_exists( 'apache_setenv' ) ) {
|
||||
@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
|
||||
}
|
||||
@ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
|
||||
@ini_set( 'output_buffering', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
|
||||
@ini_set( 'output_handler', '' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
|
||||
ignore_user_abort( true );
|
||||
wc_set_time_limit();
|
||||
wc_nocache_headers();
|
||||
// phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
|
||||
$this->send_headers();
|
||||
$this->send_contents();
|
||||
|
||||
die;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send HTTP headers at the beginning of a file.
|
||||
*
|
||||
* Modeled on WC_CSV_Exporter::send_headers().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function send_headers(): void {
|
||||
header( 'Content-Type: text/plain; charset=utf-8' );
|
||||
header( 'Content-Disposition: attachment; filename=' . $this->get_filename() );
|
||||
header( 'Pragma: no-cache' );
|
||||
header( 'Expires: 0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the contents of the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function send_contents(): void {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
|
||||
$stream = fopen( $this->path, 'rb' );
|
||||
|
||||
while ( is_resource( $stream ) && ! feof( $stream ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fread -- No suitable alternative.
|
||||
$chunk = fread( $stream, self::CHUNK_SIZE );
|
||||
|
||||
if ( is_string( $chunk ) ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Outputting to file.
|
||||
echo $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
|
||||
fclose( $stream );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file that will be sent to the browser.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_filename(): string {
|
||||
if ( $this->alternate_filename ) {
|
||||
return $this->alternate_filename;
|
||||
}
|
||||
|
||||
return basename( $this->path );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
|
||||
|
||||
use WP_List_Table;
|
||||
|
||||
/**
|
||||
* FileListTable class.
|
||||
*/
|
||||
class FileListTable extends WP_List_Table {
|
||||
/**
|
||||
* The user option key for saving the preferred number of files displayed per page.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_file_list_per_page';
|
||||
|
||||
/**
|
||||
* Instance of FileController.
|
||||
*
|
||||
* @var FileController
|
||||
*/
|
||||
private $file_controller;
|
||||
|
||||
/**
|
||||
* Instance of PageController.
|
||||
*
|
||||
* @var PageController
|
||||
*/
|
||||
private $page_controller;
|
||||
|
||||
/**
|
||||
* FileListTable class.
|
||||
*
|
||||
* @param FileController $file_controller Instance of FileController.
|
||||
* @param PageController $page_controller Instance of PageController.
|
||||
*/
|
||||
public function __construct( FileController $file_controller, PageController $page_controller ) {
|
||||
$this->file_controller = $file_controller;
|
||||
$this->page_controller = $page_controller;
|
||||
|
||||
parent::__construct(
|
||||
array(
|
||||
'singular' => 'log-file',
|
||||
'plural' => 'log-files',
|
||||
'ajax' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message when there are no items.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function no_items(): void {
|
||||
esc_html_e( 'No log files found.', 'woocommerce' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of bulk actions available for this table.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_bulk_actions(): array {
|
||||
return array(
|
||||
'export' => esc_html__( 'Download', 'woocommerce' ),
|
||||
'delete' => esc_html__( 'Delete permanently', 'woocommerce' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the existing log sources for the filter dropdown.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_sources_list(): array {
|
||||
$sources = $this->file_controller->get_file_sources();
|
||||
if ( is_wp_error( $sources ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
sort( $sources );
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays extra controls between bulk actions and pagination.
|
||||
*
|
||||
* @param string $which The location of the tablenav being rendered. 'top' or 'bottom'.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function extra_tablenav( $which ): void {
|
||||
$all_sources = $this->get_sources_list();
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
|
||||
$current_source = File::sanitize_source( wp_unslash( $_GET['source'] ?? '' ) );
|
||||
|
||||
?>
|
||||
<div class="alignleft actions">
|
||||
<?php if ( 'top' === $which ) : ?>
|
||||
<label for="filter-by-source" class="screen-reader-text"><?php esc_html_e( 'Filter by log source', 'woocommerce' ); ?></label>
|
||||
<select name="source" id="filter-by-source">
|
||||
<option<?php selected( $current_source, '' ); ?> value=""><?php esc_html_e( 'All sources', 'woocommerce' ); ?></option>
|
||||
<?php foreach ( $all_sources as $source ) : ?>
|
||||
<option<?php selected( $current_source, $source ); ?> value="<?php echo esc_attr( $source ); ?>">
|
||||
<?php echo esc_html( $source ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php
|
||||
submit_button(
|
||||
__( 'Filter', 'woocommerce' ),
|
||||
'',
|
||||
'filter_action',
|
||||
false,
|
||||
array(
|
||||
'id' => 'logs-filter-submit',
|
||||
)
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the column header info.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prepare_column_headers(): void {
|
||||
$this->_column_headers = array(
|
||||
$this->get_columns(),
|
||||
get_hidden_columns( $this->screen ),
|
||||
$this->get_sortable_columns(),
|
||||
$this->get_primary_column(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the list of items for displaying.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prepare_items(): void {
|
||||
$per_page = $this->get_items_per_page(
|
||||
self::PER_PAGE_USER_OPTION_KEY,
|
||||
$this->get_per_page_default()
|
||||
);
|
||||
|
||||
$defaults = array(
|
||||
'per_page' => $per_page,
|
||||
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
|
||||
);
|
||||
$file_args = wp_parse_args(
|
||||
$this->page_controller->get_query_params( array( 'order', 'orderby', 'source' ) ),
|
||||
$defaults
|
||||
);
|
||||
|
||||
$total_items = $this->file_controller->get_files( $file_args, true );
|
||||
if ( is_wp_error( $total_items ) ) {
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||
esc_html( $total_items->get_error_message() )
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$total_pages = ceil( $total_items / $per_page );
|
||||
$items = $this->file_controller->get_files( $file_args );
|
||||
|
||||
$this->items = $items;
|
||||
|
||||
$this->set_pagination_args(
|
||||
array(
|
||||
'per_page' => $per_page,
|
||||
'total_items' => $total_items,
|
||||
'total_pages' => $total_pages,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of columns.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_columns(): array {
|
||||
$columns = array(
|
||||
'cb' => '<input type="checkbox" />',
|
||||
'source' => esc_html__( 'Source', 'woocommerce' ),
|
||||
'created' => esc_html__( 'Date created', 'woocommerce' ),
|
||||
'modified' => esc_html__( 'Date modified', 'woocommerce' ),
|
||||
'size' => esc_html__( 'File size', 'woocommerce' ),
|
||||
);
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of sortable columns.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_sortable_columns(): array {
|
||||
$sortable = array(
|
||||
'source' => array( 'source' ),
|
||||
'created' => array( 'created' ),
|
||||
'modified' => array( 'modified', true ),
|
||||
'size' => array( 'size' ),
|
||||
);
|
||||
|
||||
return $sortable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the checkbox column.
|
||||
*
|
||||
* @param File $item The current log file being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_cb( $item ): string {
|
||||
ob_start();
|
||||
?>
|
||||
<input
|
||||
id="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>"
|
||||
type="checkbox"
|
||||
name="file_id[]"
|
||||
value="<?php echo esc_attr( $item->get_file_id() ); ?>"
|
||||
/>
|
||||
<label for="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>">
|
||||
<span class="screen-reader-text">
|
||||
<?php
|
||||
printf(
|
||||
// translators: 1. a date, 2. a slug-style name for a file.
|
||||
esc_html__( 'Select the %1$s log file for %2$s', 'woocommerce' ),
|
||||
esc_html( gmdate( get_option( 'date_format' ), $item->get_created_timestamp() ) ),
|
||||
esc_html( $item->get_source() )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</label>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the source column.
|
||||
*
|
||||
* @param File $item The current log file being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_source( $item ): string {
|
||||
$log_file = $item->get_file_id();
|
||||
$single_file_url = add_query_arg(
|
||||
array(
|
||||
'view' => 'single_file',
|
||||
'file_id' => $log_file,
|
||||
),
|
||||
$this->page_controller->get_logs_tab_url()
|
||||
);
|
||||
$rotation = '';
|
||||
if ( ! is_null( $item->get_rotation() ) ) {
|
||||
$rotation = sprintf(
|
||||
' – <span class="post-state">%d</span>',
|
||||
$item->get_rotation()
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<a class="row-title" href="%1$s">%2$s</a>%3$s',
|
||||
esc_url( $single_file_url ),
|
||||
esc_html( $item->get_source() ),
|
||||
$rotation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the created column.
|
||||
*
|
||||
* @param File $item The current log file being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_created( $item ): string {
|
||||
$timestamp = $item->get_created_timestamp();
|
||||
|
||||
return gmdate( 'Y-m-d', $timestamp );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the modified column.
|
||||
*
|
||||
* @param File $item The current log file being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_modified( $item ): string {
|
||||
$timestamp = $item->get_modified_timestamp();
|
||||
|
||||
return gmdate( 'Y-m-d H:i:s', $timestamp );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the size column.
|
||||
*
|
||||
* @param File $item The current log file being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_size( $item ): string {
|
||||
$size = $item->get_file_size();
|
||||
|
||||
return size_format( $size );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the default value for the per_page arg.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_per_page_default(): int {
|
||||
return $this->file_controller::DEFAULTS_GET_FILES['per_page'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\PageController;
|
||||
|
||||
use WP_List_Table;
|
||||
|
||||
/**
|
||||
* SearchListTable class.
|
||||
*/
|
||||
class SearchListTable extends WP_List_Table {
|
||||
/**
|
||||
* The user option key for saving the preferred number of search results displayed per page.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_search_results_per_page';
|
||||
|
||||
/**
|
||||
* Instance of FileController.
|
||||
*
|
||||
* @var FileController
|
||||
*/
|
||||
private $file_controller;
|
||||
|
||||
/**
|
||||
* Instance of PageController.
|
||||
*
|
||||
* @var PageController
|
||||
*/
|
||||
private $page_controller;
|
||||
|
||||
/**
|
||||
* SearchListTable class.
|
||||
*
|
||||
* @param FileController $file_controller Instance of FileController.
|
||||
* @param PageController $page_controller Instance of PageController.
|
||||
*/
|
||||
public function __construct( FileController $file_controller, PageController $page_controller ) {
|
||||
$this->file_controller = $file_controller;
|
||||
$this->page_controller = $page_controller;
|
||||
|
||||
parent::__construct(
|
||||
array(
|
||||
'singular' => 'wc-logs-search-result',
|
||||
'plural' => 'wc-logs-search-results',
|
||||
'ajax' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message when there are no items.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function no_items(): void {
|
||||
esc_html_e( 'No search results.', 'woocommerce' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the column header info.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prepare_column_headers(): void {
|
||||
$this->_column_headers = array(
|
||||
$this->get_columns(),
|
||||
array(),
|
||||
array(),
|
||||
$this->get_primary_column(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the list of items for displaying.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prepare_items(): void {
|
||||
$per_page = $this->get_items_per_page(
|
||||
self::PER_PAGE_USER_OPTION_KEY,
|
||||
$this->get_per_page_default()
|
||||
);
|
||||
|
||||
$args = array(
|
||||
'per_page' => $per_page,
|
||||
'offset' => ( $this->get_pagenum() - 1 ) * $per_page,
|
||||
);
|
||||
|
||||
$file_args = $this->page_controller->get_query_params(
|
||||
array( 'date_end', 'date_filter', 'date_start', 'order', 'orderby', 'search', 'source' )
|
||||
);
|
||||
$search = $file_args['search'];
|
||||
unset( $file_args['search'] );
|
||||
|
||||
$total_items = $this->file_controller->search_within_files( $search, $args, $file_args, true );
|
||||
if ( is_wp_error( $total_items ) ) {
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||
esc_html( $total_items->get_error_message() )
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $total_items >= $this->file_controller::SEARCH_MAX_RESULTS ) {
|
||||
printf(
|
||||
'<div class="notice notice-info"><p>%s</p></div>',
|
||||
sprintf(
|
||||
// translators: %s is a number.
|
||||
esc_html__( 'The number of search results has reached the limit of %s. Try refining your search.', 'woocommerce' ),
|
||||
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_RESULTS ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$total_pages = ceil( $total_items / $per_page );
|
||||
$results = $this->file_controller->search_within_files( $search, $args, $file_args );
|
||||
$this->items = $results;
|
||||
|
||||
$this->set_pagination_args(
|
||||
array(
|
||||
'per_page' => $per_page,
|
||||
'total_items' => $total_items,
|
||||
'total_pages' => $total_pages,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of columns.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_columns(): array {
|
||||
$columns = array(
|
||||
'file_id' => esc_html__( 'File', 'woocommerce' ),
|
||||
'line_number' => esc_html__( 'Line #', 'woocommerce' ),
|
||||
'line' => esc_html__( 'Matched Line', 'woocommerce' ),
|
||||
);
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the file_id column.
|
||||
*
|
||||
* @param array $item The current search result being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_file_id( array $item ): string {
|
||||
// Add a word break after the rotation number, if it exists.
|
||||
$file_id = preg_replace( '/\.([0-9])+\-/', '.\1<wbr>-', $item['file_id'] );
|
||||
|
||||
return wp_kses( $file_id, array( 'wbr' => array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the line_number column.
|
||||
*
|
||||
* @param array $item The current search result being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_line_number( array $item ): string {
|
||||
$match_url = add_query_arg(
|
||||
array(
|
||||
'view' => 'single_file',
|
||||
'file_id' => $item['file_id'],
|
||||
),
|
||||
$this->page_controller->get_logs_tab_url() . '#L' . absint( $item['line_number'] )
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'<a href="%1$s">%2$s</a>',
|
||||
esc_url( $match_url ),
|
||||
sprintf(
|
||||
// translators: %s is a line number in a file.
|
||||
esc_html__( 'Line %s', 'woocommerce' ),
|
||||
number_format_i18n( absint( $item['line_number'] ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the line column.
|
||||
*
|
||||
* @param array $item The current search result being rendered.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function column_line( array $item ): string {
|
||||
$params = $this->page_controller->get_query_params( array( 'search' ) );
|
||||
$line = $item['line'];
|
||||
|
||||
// Highlight matches within the line.
|
||||
$pattern = preg_quote( $params['search'], '/' );
|
||||
preg_match_all( "/$pattern/i", $line, $matches, PREG_OFFSET_CAPTURE );
|
||||
if ( is_array( $matches[0] ) && count( $matches[0] ) >= 1 ) {
|
||||
$length_change = 0;
|
||||
|
||||
foreach ( $matches[0] as $match ) {
|
||||
$replace = '<span class="search-match">' . $match[0] . '</span>';
|
||||
$offset = $match[1] + $length_change;
|
||||
$orig_length = strlen( $match[0] );
|
||||
$replace_length = strlen( $replace );
|
||||
|
||||
$line = substr_replace( $line, $replace, $offset, $orig_length );
|
||||
|
||||
$length_change += $replace_length - $orig_length;
|
||||
}
|
||||
}
|
||||
|
||||
return wp_kses_post( $line );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the default value for the per_page arg.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_per_page_default(): int {
|
||||
return $this->file_controller::DEFAULTS_SEARCH_WITHIN_FILES['per_page'];
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,229 @@
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging;
|
||||
|
||||
use WC_Log_Handler_File;
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
|
||||
use Automattic\WooCommerce\Proxies\LegacyProxy;
|
||||
use WC_Log_Handler;
|
||||
|
||||
/**
|
||||
* LogHandlerFileV2 class.
|
||||
*/
|
||||
class LogHandlerFileV2 extends WC_Log_Handler_File {}
|
||||
class LogHandlerFileV2 extends WC_Log_Handler {
|
||||
/**
|
||||
* Instance of the FileController class.
|
||||
*
|
||||
* @var FileController
|
||||
*/
|
||||
private $file_controller;
|
||||
|
||||
/**
|
||||
* LogHandlerFileV2 class.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->file_controller = wc_get_container()->get( FileController::class );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a log entry.
|
||||
*
|
||||
* @param int $timestamp Log timestamp.
|
||||
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
|
||||
* @param string $message Log message.
|
||||
* @param array $context {
|
||||
* Optional. Additional information for log handlers. Any data can be added here, but there are some array
|
||||
* keys that have special behavior.
|
||||
*
|
||||
* @type string $source Determines which log file to write to. Must be at least 3 characters in length.
|
||||
* @type bool $backtrace True to include a backtrace that shows where the logging function got called.
|
||||
* }
|
||||
*
|
||||
* @return bool False if value was not handled and true if value was handled.
|
||||
*/
|
||||
public function handle( $timestamp, $level, $message, $context ) {
|
||||
if ( isset( $context['source'] ) && is_string( $context['source'] ) && strlen( $context['source'] ) >= 3 ) {
|
||||
$source = sanitize_title( trim( $context['source'] ) );
|
||||
} else {
|
||||
$source = $this->determine_source();
|
||||
}
|
||||
|
||||
$entry = static::format_entry( $timestamp, $level, $message, $context );
|
||||
|
||||
$written = $this->file_controller->write_to_file( $source, $entry, $timestamp );
|
||||
|
||||
if ( $written ) {
|
||||
$this->file_controller->invalidate_cache();
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a log entry text from level, timestamp, and message.
|
||||
*
|
||||
* @param int $timestamp Log timestamp.
|
||||
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
|
||||
* @param string $message Log message.
|
||||
* @param array $context Additional information for log handlers.
|
||||
*
|
||||
* @return string Formatted log entry.
|
||||
*/
|
||||
protected static function format_entry( $timestamp, $level, $message, $context ) {
|
||||
$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";
|
||||
}
|
||||
|
||||
$entry = "$time_string $level_string $formatted_message";
|
||||
|
||||
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
|
||||
/** This filter is documented in includes/abstracts/abstract-wc-log-handler.php */
|
||||
return apply_filters(
|
||||
'woocommerce_format_log_entry',
|
||||
$entry,
|
||||
array(
|
||||
'timestamp' => $timestamp,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
)
|
||||
);
|
||||
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
|
||||
}
|
||||
|
||||
/**
|
||||
* Figures out a source string to use for a log entry based on where the log method was called from.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function determine_source(): string {
|
||||
$source_roots = array(
|
||||
'mu-plugin' => trailingslashit( Constants::get_constant( 'WPMU_PLUGIN_DIR' ) ),
|
||||
'plugin' => trailingslashit( Constants::get_constant( 'WP_PLUGIN_DIR' ) ),
|
||||
'theme' => trailingslashit( get_theme_root() ),
|
||||
);
|
||||
|
||||
$source = '';
|
||||
$backtrace = static::get_backtrace();
|
||||
|
||||
foreach ( $backtrace as $frame ) {
|
||||
foreach ( $source_roots as $type => $path ) {
|
||||
if ( 0 === strpos( $frame['file'], $path ) ) {
|
||||
$relative_path = trim( substr( $frame['file'], strlen( $path ) ), DIRECTORY_SEPARATOR );
|
||||
|
||||
if ( 'mu-plugin' === $type ) {
|
||||
$info = pathinfo( $relative_path );
|
||||
|
||||
if ( '.' === $info['dirname'] ) {
|
||||
$source = "$type-" . $info['filename'];
|
||||
} else {
|
||||
$source = "$type-" . $info['dirname'];
|
||||
}
|
||||
|
||||
break 2;
|
||||
}
|
||||
|
||||
$segments = explode( DIRECTORY_SEPARATOR, $relative_path );
|
||||
if ( is_array( $segments ) ) {
|
||||
$source = "$type-" . reset( $segments );
|
||||
}
|
||||
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $source ) {
|
||||
$source = 'log';
|
||||
}
|
||||
|
||||
return sanitize_title( $source );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all logs older than a specified timestamp.
|
||||
*
|
||||
* @param int $timestamp All files created before this timestamp will be deleted.
|
||||
*
|
||||
* @return int The number of files that were deleted.
|
||||
*/
|
||||
public function delete_logs_before_timestamp( int $timestamp = 0 ): int {
|
||||
if ( ! $timestamp ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$files = $this->file_controller->get_files(
|
||||
array(
|
||||
'date_filter' => 'created',
|
||||
'date_start' => 1,
|
||||
'date_end' => $timestamp,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $files ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$file_ids = array_map(
|
||||
fn( $file ) => $file->get_file_id(),
|
||||
$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
|
||||
|
||||
if ( $deleted > 0 ) {
|
||||
$this->handle(
|
||||
time(),
|
||||
'info',
|
||||
sprintf(
|
||||
'%s %s',
|
||||
sprintf(
|
||||
esc_html(
|
||||
// translators: %s is a number of log files.
|
||||
_n(
|
||||
'%s expired log file was deleted.',
|
||||
'%s expired log files were deleted.',
|
||||
$deleted,
|
||||
'woocommerce'
|
||||
)
|
||||
),
|
||||
number_format_i18n( $deleted )
|
||||
),
|
||||
sprintf(
|
||||
esc_html(
|
||||
// translators: %s is a number of days.
|
||||
_n(
|
||||
'The retention period for log files is %s day.',
|
||||
'The retention period for log files is %s days.',
|
||||
$retention_days,
|
||||
'woocommerce'
|
||||
)
|
||||
),
|
||||
number_format_i18n( $retention_days )
|
||||
)
|
||||
),
|
||||
array(
|
||||
'source' => 'wc_logger',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ declare( strict_types = 1 );
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Logging;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ FileController, ListTable };
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
|
||||
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController, FileListTable, SearchListTable };
|
||||
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
|
||||
use WC_Admin_Status;
|
||||
use WC_Log_Handler_File, WC_Log_Handler_DB;
|
||||
use WC_Log_Levels;
|
||||
use WP_List_Table;
|
||||
|
||||
/**
|
||||
* PageController class.
|
||||
@@ -24,9 +27,9 @@ class PageController {
|
||||
private $file_controller;
|
||||
|
||||
/**
|
||||
* Instance of ListTable.
|
||||
* Instance of FileListTable or SearchListTable.
|
||||
*
|
||||
* @var ListTable
|
||||
* @var FileListTable|SearchListTable
|
||||
*/
|
||||
private $list_table;
|
||||
|
||||
@@ -81,7 +84,7 @@ class PageController {
|
||||
$handler = Constants::get_constant( 'WC_LOG_HANDLER' );
|
||||
|
||||
if ( is_null( $handler ) || ! class_exists( $handler ) ) {
|
||||
$handler = \WC_Log_Handler_File::class;
|
||||
$handler = WC_Log_Handler_File::class;
|
||||
}
|
||||
|
||||
return $handler;
|
||||
@@ -97,8 +100,7 @@ class PageController {
|
||||
|
||||
switch ( $handler ) {
|
||||
case LogHandlerFileV2::class:
|
||||
$params = $this->get_query_params();
|
||||
$this->render_filev2( $params );
|
||||
$this->render_filev2();
|
||||
break;
|
||||
case 'WC_Log_Handler_DB':
|
||||
WC_Admin_Status::status_logs_db();
|
||||
@@ -112,20 +114,21 @@ class PageController {
|
||||
/**
|
||||
* Render the views for the FileV2 log handler.
|
||||
*
|
||||
* @param array $params Args for rendering the views.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_filev2( array $params = array() ): void {
|
||||
$view = $params['view'] ?? '';
|
||||
private function render_filev2(): void {
|
||||
$params = $this->get_query_params( array( 'view' ) );
|
||||
|
||||
switch ( $view ) {
|
||||
switch ( $params['view'] ) {
|
||||
case 'list_files':
|
||||
default:
|
||||
$this->render_file_list_page( $params );
|
||||
$this->render_list_files_view();
|
||||
break;
|
||||
case 'search_results':
|
||||
$this->render_search_results_view();
|
||||
break;
|
||||
case 'single_file':
|
||||
$this->render_single_file_page( $params );
|
||||
$this->render_single_file_view();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -133,18 +136,21 @@ class PageController {
|
||||
/**
|
||||
* Render the file list view.
|
||||
*
|
||||
* @param array $params Args for rendering the view.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_file_list_page( array $params = array() ): void {
|
||||
$defaults = $this->get_query_param_defaults();
|
||||
private function render_list_files_view(): void {
|
||||
$params = $this->get_query_params( array( 'order', 'orderby', 'source', 'view' ) );
|
||||
$defaults = $this->get_query_param_defaults();
|
||||
$list_table = $this->get_list_table( $params['view'] );
|
||||
|
||||
$list_table->prepare_items();
|
||||
|
||||
?>
|
||||
<header id="logs-header" class="wc-logs-header">
|
||||
<h2>
|
||||
<?php esc_html_e( 'Browse log files', 'woocommerce' ); ?>
|
||||
</h2>
|
||||
<?php $this->render_search_field(); ?>
|
||||
</header>
|
||||
<form id="logs-list-table-form" method="get">
|
||||
<input type="hidden" name="page" value="wc-status" />
|
||||
@@ -158,8 +164,7 @@ class PageController {
|
||||
/>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php $this->get_list_table()->prepare_items(); ?>
|
||||
<?php $this->get_list_table()->display(); ?>
|
||||
<?php $list_table->display(); ?>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
@@ -167,12 +172,11 @@ class PageController {
|
||||
/**
|
||||
* Render the single file view.
|
||||
*
|
||||
* @param array $params Args for rendering the view.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_single_file_page( array $params ): void {
|
||||
$file = $this->file_controller->get_file_by_id( $params['file_id'] );
|
||||
private function render_single_file_view(): void {
|
||||
$params = $this->get_query_params( array( 'file_id', 'view' ) );
|
||||
$file = $this->file_controller->get_file_by_id( $params['file_id'] );
|
||||
|
||||
if ( is_wp_error( $file ) ) {
|
||||
?>
|
||||
@@ -194,22 +198,28 @@ class PageController {
|
||||
$rotations = $this->file_controller->get_file_rotations( $file->get_file_id() );
|
||||
$rotation_url_base = add_query_arg( 'view', 'single_file', $this->get_logs_tab_url() );
|
||||
|
||||
$delete_url = add_query_arg(
|
||||
$download_url = add_query_arg(
|
||||
array(
|
||||
'action' => 'export',
|
||||
'file_id' => array( $file->get_file_id() ),
|
||||
),
|
||||
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
|
||||
);
|
||||
$delete_url = add_query_arg(
|
||||
array(
|
||||
'action' => 'delete',
|
||||
'file_id' => array( $file->get_file_id() ),
|
||||
),
|
||||
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
|
||||
);
|
||||
|
||||
$stream = $file->get_stream();
|
||||
$line_number = 1;
|
||||
|
||||
$delete_confirmation_js = sprintf(
|
||||
"return window.confirm( '%s' )",
|
||||
esc_js( __( 'Delete this log file permanently?', 'woocommerce' ) )
|
||||
);
|
||||
|
||||
$stream = $file->get_stream();
|
||||
$line_number = 1;
|
||||
|
||||
?>
|
||||
<header id="logs-header" class="wc-logs-header">
|
||||
<h2>
|
||||
@@ -253,6 +263,14 @@ class PageController {
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
<div class="wc-logs-single-file-actions">
|
||||
<?php
|
||||
// Download button.
|
||||
printf(
|
||||
'<a href="%1$s" class="button button-secondary">%2$s</a>',
|
||||
esc_url( $download_url ),
|
||||
esc_html__( 'Download', 'woocommerce' )
|
||||
);
|
||||
?>
|
||||
<?php
|
||||
// Delete button.
|
||||
printf(
|
||||
@@ -279,6 +297,26 @@ class PageController {
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the search results view.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_search_results_view(): void {
|
||||
$params = $this->get_query_params( array( 'order', 'orderby', 'search', 'source', 'view' ) );
|
||||
$list_table = $this->get_list_table( $params['view'] );
|
||||
|
||||
$list_table->prepare_items();
|
||||
|
||||
?>
|
||||
<header id="logs-header" class="wc-logs-header">
|
||||
<h2><?php esc_html_e( 'Search results', 'woocommerce' ); ?></h2>
|
||||
<?php $this->render_search_field(); ?>
|
||||
</header>
|
||||
<?php $list_table->display(); ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default values for URL query params for FileV2 views.
|
||||
*
|
||||
@@ -289,6 +327,7 @@ class PageController {
|
||||
'file_id' => '',
|
||||
'order' => $this->file_controller::DEFAULTS_GET_FILES['order'],
|
||||
'orderby' => $this->file_controller::DEFAULTS_GET_FILES['orderby'],
|
||||
'search' => '',
|
||||
'source' => $this->file_controller::DEFAULTS_GET_FILES['source'],
|
||||
'view' => 'list_files',
|
||||
);
|
||||
@@ -297,9 +336,11 @@ class PageController {
|
||||
/**
|
||||
* Get and validate URL query params for FileV2 views.
|
||||
*
|
||||
* @param array $param_keys Optional. The names of the params you want to get.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_query_params(): array {
|
||||
public function get_query_params( array $param_keys = array() ): array {
|
||||
$defaults = $this->get_query_param_defaults();
|
||||
$params = filter_input_array(
|
||||
INPUT_GET,
|
||||
@@ -307,7 +348,7 @@ class PageController {
|
||||
'file_id' => array(
|
||||
'filter' => FILTER_CALLBACK,
|
||||
'options' => function( $file_id ) {
|
||||
return sanitize_file_name( $file_id );
|
||||
return sanitize_file_name( wp_unslash( $file_id ) );
|
||||
},
|
||||
),
|
||||
'order' => array(
|
||||
@@ -324,16 +365,22 @@ class PageController {
|
||||
'default' => $defaults['orderby'],
|
||||
),
|
||||
),
|
||||
'search' => array(
|
||||
'filter' => FILTER_CALLBACK,
|
||||
'options' => function( $search ) {
|
||||
return esc_html( wp_unslash( $search ) );
|
||||
},
|
||||
),
|
||||
'source' => array(
|
||||
'filter' => FILTER_CALLBACK,
|
||||
'options' => function( $source ) {
|
||||
return $this->file_controller->sanitize_source( wp_unslash( $source ) );
|
||||
return File::sanitize_source( wp_unslash( $source ) );
|
||||
},
|
||||
),
|
||||
'view' => array(
|
||||
'filter' => FILTER_VALIDATE_REGEXP,
|
||||
'options' => array(
|
||||
'regexp' => '/^(list_files|single_file)$/',
|
||||
'regexp' => '/^(list_files|single_file|search_results)$/',
|
||||
'default' => $defaults['view'],
|
||||
),
|
||||
),
|
||||
@@ -342,20 +389,33 @@ class PageController {
|
||||
);
|
||||
$params = wp_parse_args( $params, $defaults );
|
||||
|
||||
if ( count( $param_keys ) > 0 ) {
|
||||
$params = array_intersect_key( $params, array_flip( $param_keys ) );
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and cache an instance of the list table.
|
||||
*
|
||||
* @return ListTable
|
||||
* @param string $view The current view, which determines which list table class to get.
|
||||
*
|
||||
* @return FileListTable|SearchListTable
|
||||
*/
|
||||
private function get_list_table(): ListTable {
|
||||
if ( $this->list_table instanceof ListTable ) {
|
||||
private function get_list_table( string $view ) {
|
||||
if ( $this->list_table instanceof WP_List_Table ) {
|
||||
return $this->list_table;
|
||||
}
|
||||
|
||||
$this->list_table = new ListTable( $this->file_controller, $this );
|
||||
switch ( $view ) {
|
||||
case 'list_files':
|
||||
$this->list_table = new FileListTable( $this->file_controller, $this );
|
||||
break;
|
||||
case 'search_results':
|
||||
$this->list_table = new SearchListTable( $this->file_controller, $this );
|
||||
break;
|
||||
}
|
||||
|
||||
return $this->list_table;
|
||||
}
|
||||
@@ -366,17 +426,30 @@ class PageController {
|
||||
* @return void
|
||||
*/
|
||||
private function setup_screen_options(): void {
|
||||
$params = $this->get_query_params();
|
||||
$params = $this->get_query_params( array( 'view' ) );
|
||||
$handler = $this->get_default_handler();
|
||||
$list_table = null;
|
||||
|
||||
if ( 'list_files' === $params['view'] ) {
|
||||
// Ensure list table columns are initialized early enough to enable column hiding.
|
||||
$this->get_list_table()->prepare_column_headers();
|
||||
switch ( $handler ) {
|
||||
case LogHandlerFileV2::class:
|
||||
if ( in_array( $params['view'], array( 'list_files', 'search_results' ), true ) ) {
|
||||
$list_table = $this->get_list_table( $params['view'] );
|
||||
}
|
||||
break;
|
||||
case 'WC_Log_Handler_DB':
|
||||
$list_table = WC_Admin_Status::get_db_log_list_table();
|
||||
break;
|
||||
}
|
||||
|
||||
if ( $list_table instanceof WP_List_Table ) {
|
||||
// Ensure list table columns are initialized early enough to enable column hiding, if available.
|
||||
$list_table->prepare_column_headers();
|
||||
|
||||
add_screen_option(
|
||||
'per_page',
|
||||
array(
|
||||
'default' => 20,
|
||||
'option' => ListTable::PER_PAGE_USER_OPTION_KEY,
|
||||
'default' => $list_table->get_per_page_default(),
|
||||
'option' => $list_table::PER_PAGE_USER_OPTION_KEY,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -388,13 +461,19 @@ class PageController {
|
||||
* @return void
|
||||
*/
|
||||
private function handle_list_table_bulk_actions(): void {
|
||||
// Bail if we're not using the file handler.
|
||||
if ( LogHandlerFileV2::class !== $this->get_default_handler() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = $this->get_query_params( array( 'file_id', 'view' ) );
|
||||
|
||||
// Bail if this is not the list table view.
|
||||
$params = $this->get_query_params();
|
||||
if ( 'list_files' !== $params['view'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$action = $this->get_list_table()->current_action();
|
||||
$action = $this->get_list_table( $params['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();
|
||||
@@ -409,16 +488,7 @@ class PageController {
|
||||
$sendback = remove_query_arg( array( 'deleted' ), wp_get_referer() );
|
||||
|
||||
// Multiple file_id[] params will be filtered separately, but assigned to $files as an array.
|
||||
$file_ids = filter_input(
|
||||
INPUT_GET,
|
||||
'file_id',
|
||||
FILTER_CALLBACK,
|
||||
array(
|
||||
'options' => function( $file ) {
|
||||
return sanitize_file_name( wp_unslash( $file ) );
|
||||
},
|
||||
)
|
||||
);
|
||||
$file_ids = $params['file_id'];
|
||||
|
||||
if ( ! is_array( $file_ids ) || count( $file_ids ) < 1 ) {
|
||||
wp_safe_redirect( $sendback );
|
||||
@@ -426,6 +496,17 @@ class PageController {
|
||||
}
|
||||
|
||||
switch ( $action ) {
|
||||
case 'export':
|
||||
if ( 1 === count( $file_ids ) ) {
|
||||
$export_error = $this->file_controller->export_single_file( reset( $file_ids ) );
|
||||
} else {
|
||||
$export_error = $this->file_controller->export_multiple_files( $file_ids );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $export_error ) ) {
|
||||
wp_die( wp_kses_post( $export_error ) );
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
$deleted = $this->file_controller->delete_files( $file_ids );
|
||||
$sendback = add_query_arg( 'deleted', $deleted, $sendback );
|
||||
@@ -475,28 +556,31 @@ class PageController {
|
||||
/**
|
||||
* Format a log file line.
|
||||
*
|
||||
* @param string $text The unformatted log file line.
|
||||
* @param string $line The unformatted log file line.
|
||||
* @param int $line_number The line number.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function format_line( string $text, int $line_number ): string {
|
||||
private function format_line( string $line, int $line_number ): string {
|
||||
$severity_levels = WC_Log_Levels::get_all_severity_levels();
|
||||
$classes = array( 'line' );
|
||||
|
||||
$text = esc_html( trim( $text ) );
|
||||
if ( empty( $text ) ) {
|
||||
$text = ' ';
|
||||
$line = esc_html( trim( $line ) );
|
||||
if ( empty( $line ) ) {
|
||||
$line = ' ';
|
||||
}
|
||||
|
||||
$segments = explode( ' ', $text, 3 );
|
||||
$segments = explode( ' ', $line, 3 );
|
||||
$has_timestamp = false;
|
||||
$has_level = false;
|
||||
|
||||
if ( isset( $segments[0] ) && false !== strtotime( $segments[0] ) ) {
|
||||
$classes[] = 'log-entry';
|
||||
$segments[0] = sprintf(
|
||||
$classes[] = 'log-entry';
|
||||
$segments[0] = sprintf(
|
||||
'<span class="log-timestamp">%s</span>',
|
||||
$segments[0]
|
||||
);
|
||||
$has_timestamp = true;
|
||||
}
|
||||
|
||||
if ( isset( $segments[1] ) && in_array( strtolower( $segments[1] ), $severity_levels, true ) ) {
|
||||
@@ -505,10 +589,32 @@ class PageController {
|
||||
esc_attr( 'log-level log-level--' . strtolower( $segments[1] ) ),
|
||||
esc_html( $segments[1] )
|
||||
);
|
||||
$has_level = true;
|
||||
}
|
||||
|
||||
if ( isset( $segments[2] ) && $has_timestamp && $has_level ) {
|
||||
$message_chunks = explode( 'CONTEXT:', $segments[2], 2 );
|
||||
if ( isset( $message_chunks[1] ) ) {
|
||||
try {
|
||||
$maybe_json = stripslashes( html_entity_decode( trim( $message_chunks[1] ) ) );
|
||||
$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>',
|
||||
esc_html__( 'Additional context', 'woocommerce' ),
|
||||
wp_json_encode( $context, JSON_PRETTY_PRINT )
|
||||
);
|
||||
|
||||
$segments[2] = implode( ' ', $message_chunks );
|
||||
$classes[] = 'has-context';
|
||||
} catch ( \JsonException $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// It's not valid JSON so don't do anything with it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $segments ) > 1 ) {
|
||||
$text = implode( ' ', $segments );
|
||||
$line = implode( ' ', $segments );
|
||||
}
|
||||
|
||||
$classes = implode( ' ', $classes );
|
||||
@@ -523,8 +629,65 @@ class PageController {
|
||||
),
|
||||
sprintf(
|
||||
'<span class="line-content">%s</span>',
|
||||
wp_kses_post( $text )
|
||||
wp_kses_post( $line )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a form for searching within log files.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_search_field(): void {
|
||||
$params = $this->get_query_params( array( 'date_end', 'date_filter', 'date_start', 'search', 'source' ) );
|
||||
$defaults = $this->get_query_param_defaults();
|
||||
$file_count = $this->file_controller->get_files( $params, true );
|
||||
|
||||
if ( $file_count > 0 ) {
|
||||
?>
|
||||
<form id="logs-search" class="wc-logs-search" method="get">
|
||||
<fieldset class="wc-logs-search-fieldset">
|
||||
<input type="hidden" name="page" value="wc-status" />
|
||||
<input type="hidden" name="tab" value="logs" />
|
||||
<input type="hidden" name="view" value="search_results" />
|
||||
<?php foreach ( $params as $key => $value ) : ?>
|
||||
<?php if ( $value !== $defaults[ $key ] ) : ?>
|
||||
<input
|
||||
type="hidden"
|
||||
name="<?php echo esc_attr( $key ); ?>"
|
||||
value="<?php echo esc_attr( $value ); ?>"
|
||||
/>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<label for="logs-search-field">
|
||||
<?php esc_html_e( 'Search within these files', 'woocommerce' ); ?>
|
||||
<input
|
||||
id="logs-search-field"
|
||||
class="wc-logs-search-field"
|
||||
type="text"
|
||||
name="search"
|
||||
value="<?php echo esc_attr( $params['search'] ); ?>"
|
||||
/>
|
||||
</label>
|
||||
<?php submit_button( __( 'Search', 'woocommerce' ), 'secondary', null, false ); ?>
|
||||
</fieldset>
|
||||
<?php if ( $file_count >= $this->file_controller::SEARCH_MAX_FILES ) : ?>
|
||||
<div class="wc-logs-search-notice">
|
||||
<?php
|
||||
printf(
|
||||
// translators: %s is a number.
|
||||
esc_html__(
|
||||
'⚠️ Only %s files can be searched at one time. Try filtering the file list before searching.',
|
||||
'woocommerce'
|
||||
),
|
||||
esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_FILES ) )
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
namespace Automattic\WooCommerce\Internal\Admin;
|
||||
|
||||
use Automattic\WooCommerce\Utilities\FeaturesUtil;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
|
||||
/**
|
||||
* Contains backend logic for the Marketplace feature.
|
||||
@@ -18,15 +19,18 @@ class Marketplace {
|
||||
* Class initialization, to be executed when the class is resolved by the container.
|
||||
*/
|
||||
final public function init() {
|
||||
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
|
||||
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
|
||||
|
||||
// Add a Woo Marketplace link to the plugin install action links.
|
||||
add_filter( 'install_plugins_tabs', array( $this, 'add_woo_plugin_install_action_link' ) );
|
||||
add_action( 'install_plugins_pre_woo', array( $this, 'maybe_open_woo_tab' ) );
|
||||
add_action( 'admin_print_styles-plugin-install.php', array( $this, 'add_plugins_page_styles' ) );
|
||||
if ( false === FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
|
||||
/** Feature controller instance @var FeaturesController $feature_controller */
|
||||
$feature_controller = wc_get_container()->get( FeaturesController::class );
|
||||
$feature_controller->change_feature_enable( 'marketplace', true );
|
||||
}
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
|
||||
// Add a Woo Marketplace link to the plugin install action links.
|
||||
add_filter( 'install_plugins_tabs', array( $this, 'add_woo_plugin_install_action_link' ) );
|
||||
add_action( 'install_plugins_pre_woo', array( $this, 'maybe_open_woo_tab' ) );
|
||||
add_action( 'admin_print_styles-plugin-install.php', array( $this, 'add_plugins_page_styles' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Orders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
|
||||
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 WC_Order;
|
||||
|
||||
/**
|
||||
* Class Edit.
|
||||
@@ -77,6 +81,7 @@ class Edit {
|
||||
add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
|
||||
/* Translators: %s order type name. */
|
||||
add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
|
||||
self::maybe_register_order_attribution( $screen_id, $title );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,6 +207,71 @@ class Edit {
|
||||
$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register order attribution meta boxes if the feature is enabled.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $screen_id Screen ID.
|
||||
* @param string $title Title of the page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function maybe_register_order_attribution( string $screen_id, string $title ) {
|
||||
/**
|
||||
* Features controller.
|
||||
*
|
||||
* @var FeaturesController $feature_controller
|
||||
*/
|
||||
$feature_controller = wc_get_container()->get( FeaturesController::class );
|
||||
if ( ! $feature_controller->feature_is_enabled( 'order_attribution' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order attribution meta box.
|
||||
*
|
||||
* @var OrderAttribution $order_attribution_meta_box
|
||||
*/
|
||||
$order_attribution_meta_box = wc_get_container()->get( OrderAttribution::class );
|
||||
|
||||
add_meta_box(
|
||||
'woocommerce-order-source-data',
|
||||
/* Translators: %s order type name. */
|
||||
sprintf( __( '%s attribution', 'woocommerce' ), $title ),
|
||||
function( $post_or_order ) use ( $order_attribution_meta_box ) {
|
||||
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
|
||||
if ( $order instanceof WC_Order ) {
|
||||
$order_attribution_meta_box->output( $order );
|
||||
}
|
||||
},
|
||||
$screen_id,
|
||||
'side',
|
||||
'high'
|
||||
);
|
||||
|
||||
/**
|
||||
* Customer history meta box.
|
||||
*
|
||||
* @var CustomerHistory $customer_history_meta_box
|
||||
*/
|
||||
$customer_history_meta_box = wc_get_container()->get( CustomerHistory::class );
|
||||
|
||||
add_meta_box(
|
||||
'woocommerce-customer-history',
|
||||
__( 'Customer history', 'woocommerce' ),
|
||||
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 );
|
||||
}
|
||||
},
|
||||
$screen_id,
|
||||
'side',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
|
||||
use WC_Order;
|
||||
|
||||
/**
|
||||
* Class CustomerHistory
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class CustomerHistory {
|
||||
|
||||
use OrderAttributionMeta;
|
||||
|
||||
/**
|
||||
* Output the customer history template for the order.
|
||||
*
|
||||
* @param WC_Order $order The order object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function output( WC_Order $order ): void {
|
||||
$this->display_customer_history( $order->get_customer_id(), $order->get_billing_email() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
|
||||
$args['has_customer_id'] = $has_customer_id;
|
||||
wc_get_template( 'order/customer-history.php', $args );
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
|
||||
use WC_Order;
|
||||
|
||||
/**
|
||||
* Class OrderAttribution
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class OrderAttribution {
|
||||
|
||||
use OrderAttributionMeta;
|
||||
|
||||
/**
|
||||
* OrderAttribution constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->set_fields_and_prefix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the meta data for display.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param array $meta The array of meta data to format.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function format_meta_data( array &$meta ) {
|
||||
|
||||
if ( array_key_exists( 'device_type', $meta ) ) {
|
||||
|
||||
switch ( $meta['device_type'] ) {
|
||||
case 'Mobile':
|
||||
$meta['device_type'] = __( 'Mobile', 'woocommerce' );
|
||||
break;
|
||||
case 'Tablet':
|
||||
$meta['device_type'] = __( 'Tablet', 'woocommerce' );
|
||||
break;
|
||||
case 'Desktop':
|
||||
$meta['device_type'] = __( 'Desktop', 'woocommerce' );
|
||||
break;
|
||||
|
||||
default:
|
||||
$meta['device_type'] = __( 'Unknown', 'woocommerce' );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the attribution data metabox for the order.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param WC_Order $order The order object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function output( WC_Order $order ) {
|
||||
$meta = $this->filter_meta_data( $order->get_meta_data() );
|
||||
|
||||
// If we don't have any meta to show, return.
|
||||
if ( empty( $meta ) ) {
|
||||
esc_html_e( 'No order source data available.', 'woocommerce' );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->format_meta_data( $meta );
|
||||
|
||||
$template_data = array(
|
||||
'meta' => $meta,
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
@@ -410,8 +410,9 @@ class WCAdminAssets {
|
||||
* Injects wp-shared-settings as a dependency if it's present.
|
||||
*/
|
||||
public function inject_wc_settings_dependencies() {
|
||||
$wp_scripts = wp_scripts();
|
||||
if ( wp_script_is( 'wc-settings', 'registered' ) ) {
|
||||
$handles_for_injection = [
|
||||
$handles_for_injection = array(
|
||||
'wc-admin-layout',
|
||||
'wc-csv',
|
||||
'wc-currency',
|
||||
@@ -426,11 +427,31 @@ class WCAdminAssets {
|
||||
'wc-tracks',
|
||||
'wc-block-templates',
|
||||
'wc-product-editor',
|
||||
];
|
||||
);
|
||||
foreach ( $handles_for_injection as $handle ) {
|
||||
$script = wp_scripts()->query( $handle, 'registered' );
|
||||
$script = $wp_scripts->query( $handle, 'registered' );
|
||||
if ( $script instanceof _WP_Dependency ) {
|
||||
$script->deps[] = 'wc-settings';
|
||||
$wp_scripts->add_data( $handle, 'group', 1 );
|
||||
}
|
||||
}
|
||||
foreach ( $wp_scripts->registered as $handle => $script ) {
|
||||
// scripts that are loaded in the footer has extra->group = 1.
|
||||
if ( array_intersect( $handles_for_injection, $script->deps ) && ! isset( $script->extra['group'] ) ) {
|
||||
// Append the script to footer.
|
||||
$wp_scripts->add_data( $handle, 'group', 1 );
|
||||
// Show a warning.
|
||||
$error_handle = 'wc-settings-dep-in-header';
|
||||
$used_deps = implode( ', ', array_intersect( $handles_for_injection, $script->deps ) );
|
||||
$error_message = "Scripts that have a dependency on [$used_deps] must be loaded in the footer, {$handle} was registered to load in the header, but has been switched to load in the footer instead. See https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5059";
|
||||
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter,WordPress.WP.EnqueuedResourceParameters.MissingVersion
|
||||
wp_register_script( $error_handle, '' );
|
||||
wp_enqueue_script( $error_handle );
|
||||
wp_add_inline_script(
|
||||
$error_handle,
|
||||
sprintf( 'console.warn( "%s" );', $error_message )
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ class DataSynchronizer implements BatchProcessorInterface {
|
||||
*/
|
||||
private $error_logger;
|
||||
|
||||
/**
|
||||
* The instance of the LegacyProxy object to use.
|
||||
*
|
||||
* @var LegacyProxy
|
||||
*/
|
||||
private $legacy_proxy;
|
||||
|
||||
/**
|
||||
* The order cache controller.
|
||||
*
|
||||
@@ -103,6 +110,7 @@ class DataSynchronizer implements BatchProcessorInterface {
|
||||
self::add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
|
||||
self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
|
||||
self::add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );
|
||||
self::add_action( 'wp_scheduled_delete', array( $this, 'delete_trashed_orders' ), 9 );
|
||||
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
|
||||
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 2 );
|
||||
self::add_filter( 'deleted_option', array( $this, 'process_deleted_option' ), 999 );
|
||||
@@ -136,6 +144,7 @@ class DataSynchronizer implements BatchProcessorInterface {
|
||||
$this->data_store = $data_store;
|
||||
$this->database_util = $database_util;
|
||||
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
|
||||
$this->legacy_proxy = $legacy_proxy;
|
||||
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
|
||||
$this->order_cache_controller = $order_cache_controller;
|
||||
$this->batch_processing_controller = $batch_processing_controller;
|
||||
@@ -966,6 +975,41 @@ ORDER BY orders.id ASC
|
||||
do_action( 'woocommerce_scheduled_auto_draft_delete' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deletion of trashed orders after `EMPTY_TRASH_DAYS` as defined by WordPress.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function delete_trashed_orders() {
|
||||
if ( ! $this->custom_orders_table_is_authoritative() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delete_timestamp = $this->legacy_proxy->call_function( 'time' ) - ( DAY_IN_SECONDS * EMPTY_TRASH_DAYS );
|
||||
$args = array(
|
||||
'status' => 'trash',
|
||||
'limit' => self::ORDERS_SYNC_BATCH_SIZE,
|
||||
'date_modified' => '<' . $delete_timestamp,
|
||||
);
|
||||
|
||||
$orders = wc_get_orders( $args );
|
||||
if ( ! $orders || ! is_array( $orders ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $orders as $order ) {
|
||||
if ( $order->get_status() !== 'trash' ) {
|
||||
continue;
|
||||
}
|
||||
if ( $order->get_date_modified()->getTimestamp() >= $delete_timestamp ) {
|
||||
continue;
|
||||
}
|
||||
$order->delete( true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the 'woocommerce_feature_description_tip' filter.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
/**
|
||||
* LegacyDataHandler class file.
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* This class provides functionality to clean up post data from the posts table when HPOS is authoritative.
|
||||
*/
|
||||
class LegacyDataHandler {
|
||||
|
||||
/**
|
||||
* Instance of the HPOS datastore.
|
||||
*
|
||||
* @var OrdersTableDataStore
|
||||
*/
|
||||
private OrdersTableDataStore $data_store;
|
||||
|
||||
/**
|
||||
* Instance of the DataSynchronizer class.
|
||||
*
|
||||
* @var DataSynchronizer
|
||||
*/
|
||||
private DataSynchronizer $data_synchronizer;
|
||||
|
||||
/**
|
||||
* Class initialization, invoked by the DI container.
|
||||
*
|
||||
* @param OrdersTableDataStore $data_store HPOS datastore instance to use.
|
||||
* @param DataSynchronizer $data_synchronizer DataSynchronizer instance to use.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer ) {
|
||||
$this->data_store = $data_store;
|
||||
$this->data_synchronizer = $data_synchronizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of orders for which legacy post data can be removed.
|
||||
*
|
||||
* @param array $order_ids If provided, total is computed only among IDs in this array, which can be either individual IDs or ranges like "100-200".
|
||||
* @return int Number of orders.
|
||||
*/
|
||||
public function count_orders_for_cleanup( $order_ids = array() ) : int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( $this->build_sql_query_for_cleanup( $order_ids, 'count' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of orders for which legacy post data can be removed.
|
||||
*
|
||||
* @param array $order_ids If provided, result is a subset of the order IDs in this array, which can contain either individual order IDs or ranges like "100-200".
|
||||
* @param int $limit Limit the number of results.
|
||||
* @return array[int] Order IDs.
|
||||
*/
|
||||
public function get_orders_for_cleanup( $order_ids = array(), int $limit = 0 ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'absint',
|
||||
$wpdb->get_col( $this->build_sql_query_for_cleanup( $order_ids, 'ids', $limit ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a SQL statement to either count or obtain IDs for orders in need of cleanup.
|
||||
*
|
||||
* @param array $order_ids If provided, the query will only include orders in this set of order IDs or ID ranges (like "10-100").
|
||||
* @param string $result Use 'count' to build a query that returns a count. Otherwise, the query will return order IDs.
|
||||
* @param integer $limit If provided, the query will be limited to this number of results. Does not apply when $result is 'count'.
|
||||
* @return string SQL query.
|
||||
*/
|
||||
private function build_sql_query_for_cleanup( array $order_ids = array(), string $result = 'ids', int $limit = 0 ): string {
|
||||
global $wpdb;
|
||||
|
||||
$sql_where = '';
|
||||
|
||||
if ( $order_ids ) {
|
||||
// Expand ranges in $order_ids as needed to build the WHERE clause.
|
||||
$where_ids = array();
|
||||
$where_ranges = array();
|
||||
|
||||
foreach ( $order_ids as &$arg ) {
|
||||
if ( is_numeric( $arg ) ) {
|
||||
$where_ids[] = absint( $arg );
|
||||
} elseif ( preg_match( '/^(\d+)-(\d+)$/', $arg, $matches ) ) {
|
||||
$where_ranges[] = $wpdb->prepare( "({$wpdb->posts}.ID >= %d AND {$wpdb->posts}.ID <= %d)", absint( $matches[1] ), absint( $matches[2] ) );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $where_ids ) {
|
||||
$where_ranges[] = "{$wpdb->posts}.ID IN (" . implode( ',', $where_ids ) . ')';
|
||||
}
|
||||
|
||||
if ( ! $where_ranges ) {
|
||||
$sql_where .= '1=0';
|
||||
} else {
|
||||
$sql_where .= '(' . implode( ' OR ', $where_ranges ) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
$sql_where .= $sql_where ? ' AND ' : '';
|
||||
|
||||
// Post type handling.
|
||||
$sql_where .= '(';
|
||||
$sql_where .= "{$wpdb->posts}.post_type IN ('" . implode( "', '", esc_sql( wc_get_order_types( 'cot-migration' ) ) ) . "')";
|
||||
$sql_where .= $wpdb->prepare(
|
||||
" OR (post_type = %s AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = {$wpdb->posts}.ID))",
|
||||
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE
|
||||
);
|
||||
$sql_where .= ')';
|
||||
|
||||
// Exclude 'auto-draft' since those go away on their own.
|
||||
$sql_where .= $wpdb->prepare( " AND {$wpdb->posts}.post_status != %s", 'auto-draft' );
|
||||
|
||||
if ( 'count' === $result ) {
|
||||
$sql_fields = 'COUNT(*)';
|
||||
$sql_limit = '';
|
||||
} else {
|
||||
$sql_fields = 'ID';
|
||||
$sql_limit = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
|
||||
}
|
||||
|
||||
return "SELECT {$sql_fields} FROM {$wpdb->posts} WHERE {$sql_where} {$sql_limit}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a cleanup of post data for a given order and also converts the post to the placeholder type in the backup table.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
* @param bool $skip_checks Whether to skip the checks that happen before the order is cleaned up.
|
||||
* @return void
|
||||
* @throws \Exception When an error occurs.
|
||||
*/
|
||||
public function cleanup_post_data( int $order_id, bool $skip_checks = false ): void {
|
||||
global $wpdb;
|
||||
|
||||
$order = wc_get_order( $order_id );
|
||||
if ( ! $order ) {
|
||||
// translators: %d is an order ID.
|
||||
throw new \Exception( sprintf( __( '%d is not a valid order ID.', 'woocommerce' ), $order_id ) );
|
||||
}
|
||||
|
||||
if ( ! $skip_checks && ! $this->is_order_newer_than_post( $order ) ) {
|
||||
throw new \Exception( sprintf( __( 'Data in posts table appears to be more recent than in HPOS tables.', 'woocommerce' ) ) );
|
||||
}
|
||||
|
||||
$meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d", $order->get_id() ) );
|
||||
foreach ( $meta_ids as $meta_id ) {
|
||||
delete_metadata_by_mid( 'post', $meta_id );
|
||||
}
|
||||
|
||||
// wp_update_post() changes the post modified date, so we do this manually.
|
||||
// Also, we suspect using wp_update_post() could lead to integrations mistakenly updating the entity.
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->posts} SET post_type = %s, post_status = %s WHERE ID = %d",
|
||||
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE,
|
||||
'draft',
|
||||
$order->get_id()
|
||||
)
|
||||
);
|
||||
|
||||
clean_post_cache( $order->get_id() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an HPOS-backed order is newer than the corresponding post.
|
||||
*
|
||||
* @param int|\WC_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 ) );
|
||||
|
||||
if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
|
||||
throw new \Exception( __( 'Order is not an HPOS order.', 'woocommerce' ) );
|
||||
}
|
||||
|
||||
$post = get_post( $order->get_id() );
|
||||
if ( ! $post || $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post->post_type ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$order_modified_gmt = $order->get_date_modified() ?? $order->get_date_created();
|
||||
$order_modified_gmt = $order_modified_gmt ? $order_modified_gmt->getTimestamp() : 0;
|
||||
$post_modified_gmt = $post->post_modified_gmt ?? $post->post_date_gmt;
|
||||
$post_modified_gmt = ( $post_modified_gmt && '0000-00-00 00:00:00' !== $post_modified_gmt ) ? wc_string_to_timestamp( $post_modified_gmt ) : 0;
|
||||
|
||||
return $order_modified_gmt >= $post_modified_gmt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
|
||||
/**
|
||||
* Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
|
||||
*
|
||||
* @var array.
|
||||
* @var array
|
||||
*/
|
||||
private static $reading_order_ids = array();
|
||||
|
||||
@@ -609,6 +609,15 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
|
||||
}
|
||||
}
|
||||
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
|
||||
|
||||
/**
|
||||
* Fired when the backing post record for an HPOS order is backfilled after an order update.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param \WC_Order $order The order object.
|
||||
*/
|
||||
do_action( 'woocommerce_hpos_post_record_backfilled', $order );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -969,10 +978,24 @@ WHERE
|
||||
/**
|
||||
* Get unpaid orders last updated before the specified date.
|
||||
*
|
||||
* @param int $date Timestamp.
|
||||
* @return array
|
||||
* @param int $date This timestamp is expected in the timezone in WordPress settings for legacy reason, even though it's not a good practice.
|
||||
*
|
||||
* @return array Array of order IDs.
|
||||
*/
|
||||
public function get_unpaid_orders( $date ) {
|
||||
$timezone_offset = wc_timezone_offset();
|
||||
$gmt_timestamp = $date - $timezone_offset;
|
||||
return $this->get_unpaid_orders_gmt( absint( $gmt_timestamp ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unpaid orders last updated before the specified GMT date.
|
||||
*
|
||||
* @param int $gmt_timestamp GMT timestamp.
|
||||
*
|
||||
* @return array Array of order IDs.
|
||||
*/
|
||||
public function get_unpaid_orders_gmt( $gmt_timestamp ) {
|
||||
global $wpdb;
|
||||
|
||||
$orders_table = self::get_orders_table_name();
|
||||
@@ -986,7 +1009,7 @@ WHERE
|
||||
AND {$orders_table}.status = %s
|
||||
AND {$orders_table}.date_updated_gmt < %s",
|
||||
'wc-pending',
|
||||
gmdate( 'Y-m-d H:i:s', absint( $date ) )
|
||||
gmdate( 'Y-m-d H:i:s', absint( $gmt_timestamp ) )
|
||||
)
|
||||
);
|
||||
// phpcs:enable
|
||||
@@ -1414,17 +1437,6 @@ WHERE
|
||||
return $diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log difference between post and COT data for an order.
|
||||
*
|
||||
* @param array $diff Difference between post and COT data.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function log_diff( array $diff ): void {
|
||||
$this->error_logger->notice( 'Diff found: ' . wp_json_encode( $diff, JSON_PRETTY_PRINT ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate post record from a given order object.
|
||||
*
|
||||
@@ -1434,12 +1446,22 @@ WHERE
|
||||
* @return void
|
||||
*/
|
||||
private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
|
||||
$this->migrate_meta_data_from_post_order( $order, $post_order );
|
||||
$diff = $this->migrate_meta_data_from_post_order( $order, $post_order );
|
||||
$post_order_base_data = $post_order->get_base_data();
|
||||
foreach ( $post_order_base_data as $key => $value ) {
|
||||
$this->set_order_prop( $order, $key, $value );
|
||||
}
|
||||
$this->persist_updates( $order, false );
|
||||
|
||||
/**
|
||||
* Fired when an HPOS order is updated from its corresponding post record on read due to a difference in the data.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param \WC_Order $order The order object.
|
||||
* @param array $diff Difference between HPOS data and post data.
|
||||
*/
|
||||
do_action( 'woocommerce_hpos_post_record_migrated_on_read', $order, $diff );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,9 @@ use Automattic\WooCommerce\Vendor\League\Container\ServiceProvider\AbstractServi
|
||||
* - The `add_with_auto_arguments` method that allows to register classes without having to specify the injection method arguments.
|
||||
* - The `share_with_auto_arguments` method, sibling of the above.
|
||||
* - Convenience `add` and `share` methods that are just proxies for the same methods in `$this->getContainer()`.
|
||||
*
|
||||
* Note that `AbstractInterfaceServiceProvider` likely serves as a better base class for service providers
|
||||
* tasked with registering classes that implement interfaces.
|
||||
*/
|
||||
abstract class AbstractServiceProvider extends BaseServiceProvider {
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
|
||||
|
||||
/**
|
||||
* Extends AbstractServiceProvider to register services and automatically tag them based on their implemented interfaces.
|
||||
* By using the `add_with_implements_tags` and `share_with_implements_tags` methods, it becomes possible to retrieve
|
||||
* all the services that implement a given interface with a single `get` call.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
abstract class AbstractInterfaceServiceProvider extends AbstractServiceProvider {
|
||||
|
||||
/**
|
||||
* Determine whether this service provides the given alias.
|
||||
*
|
||||
* @param string $alias The alias to check.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function provides( string $alias ): bool {
|
||||
$provides = parent::provides( $alias );
|
||||
if ( $provides ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static $implements = array();
|
||||
if ( empty( $implements ) ) {
|
||||
foreach ( $this->provides as $class ) {
|
||||
$implements_more = class_implements( $class );
|
||||
if ( $implements_more ) {
|
||||
$implements = array_merge( $implements, $implements_more );
|
||||
}
|
||||
}
|
||||
|
||||
$implements = array_unique( $implements );
|
||||
}
|
||||
|
||||
return array_key_exists( $alias, $implements );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a class in the container and add tags for all the interfaces it implements.
|
||||
*
|
||||
* This also updates the `$this->provides` property with the interfaces provided by the class, and ensures
|
||||
* that the property doesn't contain duplicates.
|
||||
*
|
||||
* @param string $id Entry ID (typically a class or interface name).
|
||||
* @param mixed|null $concrete Concrete entity to register under that ID, null for automatic creation.
|
||||
* @param bool|null $shared Whether to register the class as shared (`get` always returns the same instance)
|
||||
* or not.
|
||||
*
|
||||
* @return DefinitionInterface
|
||||
*/
|
||||
protected function add_with_implements_tags( string $id, $concrete = null, bool $shared = null ): DefinitionInterface {
|
||||
$definition = $this->add( $id, $concrete, $shared );
|
||||
foreach ( class_implements( $id ) as $interface ) {
|
||||
$definition->addTag( $interface );
|
||||
}
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a shared class in the container and add tags for all the interfaces it implements.
|
||||
*
|
||||
* @param string $id Entry ID (typically a class or interface name).
|
||||
* @param mixed|null $concrete Concrete entity to register under that ID, null for automatic creation.
|
||||
*
|
||||
* @return DefinitionInterface
|
||||
*/
|
||||
protected function share_with_implements_tags( string $id, $concrete = null ): DefinitionInterface {
|
||||
return $this->add_with_implements_tags( $id, $concrete, true );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
use Automattic\WooCommerce\Internal\Integrations\WPConsentAPI;
|
||||
use Automattic\WooCommerce\Internal\Orders\OrderAttributionController;
|
||||
use Automattic\WooCommerce\Internal\Orders\OrderAttributionBlocksController;
|
||||
use Automattic\WooCommerce\Proxies\LegacyProxy;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
|
||||
use Automattic\WooCommerce\StoreApi\StoreApi;
|
||||
|
||||
/**
|
||||
* Class OrderAttributionServiceProvider
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class OrderAttributionServiceProvider extends AbstractInterfaceServiceProvider {
|
||||
|
||||
/**
|
||||
* The classes/interfaces that are serviced by this service provider.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $provides = array(
|
||||
OrderAttributionController::class,
|
||||
OrderAttributionBlocksController::class,
|
||||
WPConsentAPI::class,
|
||||
);
|
||||
|
||||
/**
|
||||
* Register the classes.
|
||||
*/
|
||||
public function register() {
|
||||
$this->share_with_implements_tags( OrderAttributionController::class )
|
||||
->addArguments(
|
||||
array(
|
||||
LegacyProxy::class,
|
||||
FeaturesController::class,
|
||||
)
|
||||
);
|
||||
$this->share_with_implements_tags( OrderAttributionBlocksController::class )
|
||||
->addArguments(
|
||||
array(
|
||||
StoreApi::container()->get( ExtendSchema::class ),
|
||||
FeaturesController::class,
|
||||
OrderAttributionController::class,
|
||||
)
|
||||
);
|
||||
$this->share_with_implements_tags( WPConsentAPI::class );
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
|
||||
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
|
||||
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\OrderAttribution;
|
||||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
|
||||
/**
|
||||
@@ -19,14 +21,17 @@ class OrderMetaBoxServiceProvider extends AbstractServiceProvider {
|
||||
* @var array
|
||||
*/
|
||||
protected $provides = array(
|
||||
CustomerHistory::class,
|
||||
CustomMetaBox::class,
|
||||
OrderAttribution::class,
|
||||
);
|
||||
|
||||
/**
|
||||
* Register the classes.
|
||||
*/
|
||||
public function register() {
|
||||
$this->share( CustomerHistory::class );
|
||||
$this->share( CustomMetaBox::class );
|
||||
$this->share( OrderAttribution::class );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore
|
||||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
@@ -42,6 +43,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
|
||||
OrdersTableRefundDataStore::class,
|
||||
OrderCache::class,
|
||||
OrderCacheController::class,
|
||||
LegacyDataHandler::class,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -79,5 +81,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
|
||||
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
$this->share( CLIRunner::class )->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class, PostsToOrdersMigrationController::class ) );
|
||||
}
|
||||
|
||||
$this->share( LegacyDataHandler::class )->addArguments( array( OrdersTableDataStore::class, DataSynchronizer::class ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
* ProductImageBySKUServiceProvider class file.
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
use Automattic\WooCommerce\Internal\ProductImage\MatchImageBySKU;
|
||||
|
||||
/**
|
||||
* Service provider for the ProductImageBySKUServiceProvider namespace.
|
||||
*/
|
||||
class ProductImageBySKUServiceProvider extends AbstractServiceProvider {
|
||||
|
||||
/**
|
||||
* The classes/interfaces that are serviced by this service provider.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $provides = array(
|
||||
MatchImageBySKU::class,
|
||||
);
|
||||
|
||||
/**
|
||||
* Register the classes.
|
||||
*/
|
||||
public function register() {
|
||||
$this->share( MatchImageBySKU::class );
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,10 @@ class FeaturesController {
|
||||
),
|
||||
'new_navigation' => array(
|
||||
'name' => __( 'Navigation', 'woocommerce' ),
|
||||
'description' => __( 'Add the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
|
||||
'description' => __(
|
||||
'Add the new WooCommerce navigation experience to the dashboard',
|
||||
'woocommerce'
|
||||
),
|
||||
'option_key' => Init::TOGGLE_OPTION_NAME,
|
||||
'is_experimental' => false,
|
||||
'disable_ui' => false,
|
||||
@@ -183,8 +186,12 @@ class FeaturesController {
|
||||
'desc_tip' => function() {
|
||||
$string = '';
|
||||
if ( version_compare( get_bloginfo( 'version' ), '6.2', '<' ) ) {
|
||||
$string = __( '⚠ This feature is compatible with WordPress version 6.2 or higher.', 'woocommerce' );
|
||||
$string = __(
|
||||
'⚠ This feature is compatible with WordPress version 6.2 or higher.',
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
|
||||
return $string;
|
||||
},
|
||||
),
|
||||
@@ -202,8 +209,21 @@ class FeaturesController {
|
||||
),
|
||||
'is_experimental' => false,
|
||||
'enabled_by_default' => true,
|
||||
'disable_ui' => true,
|
||||
'is_legacy' => true,
|
||||
),
|
||||
// Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
|
||||
// https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
|
||||
'order_attribution' => array(
|
||||
'name' => __( 'Order Attribution', 'woocommerce' ),
|
||||
'description' => __(
|
||||
'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
|
||||
'woocommerce'
|
||||
),
|
||||
'enabled_by_default' => true,
|
||||
'disable_ui' => false,
|
||||
'is_legacy' => true,
|
||||
'is_experimental' => false,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -192,7 +192,12 @@ class ProductVariationTemplate extends AbstractProductFormTemplate implements Pr
|
||||
'order' => 40,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Downloads', 'woocommerce' ),
|
||||
'description' => __( "Add any files you'd like to make available for the customer to download after purchasing, such as instructions or warranty info.", 'woocommerce' ),
|
||||
'description' => sprintf(
|
||||
/* translators: %1$s: Downloads settings link opening tag. %2$s: Downloads settings link closing tag. */
|
||||
__( 'Add any files you\'d like to make available for the customer to download after purchasing, such as instructions or warranty info. Store-wide updates can be managed in your %1$sproduct settings%2$s.', 'woocommerce' ),
|
||||
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products§ion=downloadable' ) . '" target="_blank" rel="noreferrer">',
|
||||
'</a>'
|
||||
),
|
||||
),
|
||||
)
|
||||
)->add_block(
|
||||
@@ -400,43 +405,24 @@ class ProductVariationTemplate extends AbstractProductFormTemplate implements Pr
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_inventory_quantity_conditional = $product_inventory_inner_section->add_block(
|
||||
$product_inventory_inner_section->add_block(
|
||||
array(
|
||||
'id' => 'product-variation-inventory-quantity-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 30,
|
||||
'attributes' => array(
|
||||
'mustMatch' => array(
|
||||
'manage_stock' => array( true ),
|
||||
'id' => 'product-variation-inventory-quantity',
|
||||
'blockName' => 'woocommerce/product-inventory-quantity-field',
|
||||
'order' => 10,
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.manage_stock === false',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_inventory_quantity_conditional->add_block(
|
||||
$product_inventory_section->add_block(
|
||||
array(
|
||||
'id' => 'product-variation-inventory-quantity',
|
||||
'blockName' => 'woocommerce/product-inventory-quantity-field',
|
||||
'order' => 10,
|
||||
)
|
||||
);
|
||||
$product_stock_status_conditional = $product_inventory_section->add_block(
|
||||
array(
|
||||
'id' => 'product-variation-stock-status-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 20,
|
||||
'attributes' => array(
|
||||
'mustMatch' => array(
|
||||
'manage_stock' => array( false ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_stock_status_conditional->add_block(
|
||||
array(
|
||||
'id' => 'product-variation-stock-status',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'id' => 'product-variation-stock-status',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Stock status', 'woocommerce' ),
|
||||
'property' => 'stock_status',
|
||||
'options' => array(
|
||||
@@ -454,6 +440,11 @@ class ProductVariationTemplate extends AbstractProductFormTemplate implements Pr
|
||||
),
|
||||
),
|
||||
),
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.manage_stock === true',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,11 +82,16 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
);
|
||||
$this->add_group(
|
||||
array(
|
||||
'id' => $this::GROUP_IDS['PRICING'],
|
||||
'order' => 20,
|
||||
'attributes' => array(
|
||||
'id' => $this::GROUP_IDS['PRICING'],
|
||||
'order' => 20,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Pricing', 'woocommerce' ),
|
||||
),
|
||||
'hideConditions' => Features::is_enabled( 'product-grouped' ) ? array(
|
||||
array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
),
|
||||
) : null,
|
||||
)
|
||||
);
|
||||
$this->add_group(
|
||||
@@ -98,23 +103,49 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
)
|
||||
);
|
||||
$shipping_hide_conditions = array();
|
||||
if ( Features::is_enabled( 'product-grouped' ) ) {
|
||||
$shipping_hide_conditions[] = array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
);
|
||||
}
|
||||
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
|
||||
$shipping_hide_conditions[] = array(
|
||||
'expression' => 'editedProduct.type === "external"',
|
||||
);
|
||||
}
|
||||
|
||||
$this->add_group(
|
||||
array(
|
||||
'id' => $this::GROUP_IDS['SHIPPING'],
|
||||
'order' => 40,
|
||||
'attributes' => array(
|
||||
'id' => $this::GROUP_IDS['SHIPPING'],
|
||||
'order' => 40,
|
||||
'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"',
|
||||
);
|
||||
}
|
||||
|
||||
$this->add_group(
|
||||
array(
|
||||
'id' => $this::GROUP_IDS['VARIATIONS'],
|
||||
'order' => 50,
|
||||
'attributes' => array(
|
||||
'id' => $this::GROUP_IDS['VARIATIONS'],
|
||||
'order' => 50,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Variations', 'woocommerce' ),
|
||||
),
|
||||
'hideConditions' => $variations_hide_conditions,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -169,7 +200,23 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
)
|
||||
);
|
||||
$pricing_columns = $basic_details->add_block(
|
||||
|
||||
// This is needed until hide conditions can be applied to core blocks.
|
||||
$pricing_conditional_wrapper = $basic_details->add_block(
|
||||
array(
|
||||
'id' => 'product-pricing-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 30,
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$pricing_wrapper = Features::is_enabled( 'product-grouped' ) ? $pricing_conditional_wrapper : $basic_details;
|
||||
$pricing_columns = $pricing_wrapper->add_block(
|
||||
array(
|
||||
'id' => 'product-pricing-columns',
|
||||
'blockName' => 'core/columns',
|
||||
@@ -231,7 +278,8 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
)
|
||||
);
|
||||
$description_section->add_block(
|
||||
|
||||
$description_field_block = $description_section->add_block(
|
||||
array(
|
||||
'id' => 'product-description',
|
||||
'blockName' => 'woocommerce/product-description-field',
|
||||
@@ -239,6 +287,19 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
)
|
||||
);
|
||||
|
||||
$description_field_block->add_block(
|
||||
array(
|
||||
'id' => 'product-description__content',
|
||||
'blockName' => 'woocommerce/product-summary-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'helpText' => null,
|
||||
'label' => null,
|
||||
'property' => 'description',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// External/Affiliate section.
|
||||
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
|
||||
$buy_button_section = $general_group->add_section(
|
||||
@@ -311,6 +372,36 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
);
|
||||
}
|
||||
|
||||
// Product list section.
|
||||
if ( Features::is_enabled( 'product-grouped' ) ) {
|
||||
$product_list_section = $general_group->add_section(
|
||||
array(
|
||||
'id' => 'product-list-section',
|
||||
'order' => 35,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Products in this group', 'woocommerce' ),
|
||||
'description' => __( 'Make a collection of related products, enabling customers to purchase multiple items together.', 'woocommerce' ),
|
||||
),
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.type !== "grouped"',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$product_list_section->add_block(
|
||||
array(
|
||||
'id' => 'product-list',
|
||||
'blockName' => 'woocommerce/product-list-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'property' => 'grouped_products',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Images section.
|
||||
$images_section = $general_group->add_section(
|
||||
array(
|
||||
@@ -346,7 +437,12 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
'order' => 50,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Downloads', 'woocommerce' ),
|
||||
'description' => __( "Add any files you'd like to make available for the customer to download after purchasing, such as instructions or warranty info.", 'woocommerce' ),
|
||||
'description' => sprintf(
|
||||
/* translators: %1$s: Downloads settings link opening tag. %2$s: Downloads settings link closing tag. */
|
||||
__( 'Add any files you\'d like to make available for the customer to download after purchasing, such as instructions or warranty info. Store-wide updates can be managed in your %1$sproduct settings%2$s.', 'woocommerce' ),
|
||||
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products§ion=downloadable' ) . '" target="_blank" rel="noreferrer">',
|
||||
'</a>'
|
||||
),
|
||||
),
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
@@ -689,50 +785,47 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
'</a>'
|
||||
),
|
||||
),
|
||||
'hideConditions' => Features::is_enabled( 'product-external-affiliate' ) ? array(
|
||||
'hideConditions' => Features::is_enabled( 'product-external-affiliate' ) || Features::is_enabled( 'product-grouped' ) ? array(
|
||||
array(
|
||||
'expression' => 'editedProduct.type === "external"',
|
||||
'expression' => 'editedProduct.type === "external" || editedProduct.type === "grouped"',
|
||||
),
|
||||
) : null,
|
||||
)
|
||||
);
|
||||
$product_inventory_quantity_conditional = $product_inventory_inner_section->add_block(
|
||||
$product_inventory_quantity_hide_conditions = array(
|
||||
array(
|
||||
'id' => 'product-inventory-quantity-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 30,
|
||||
'attributes' => array(
|
||||
'mustMatch' => array(
|
||||
'manage_stock' => array( true ),
|
||||
),
|
||||
),
|
||||
'expression' => 'editedProduct.manage_stock === false',
|
||||
),
|
||||
);
|
||||
if ( Features::is_enabled( 'product-grouped' ) ) {
|
||||
$product_inventory_quantity_hide_conditions[] = array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
);
|
||||
}
|
||||
$product_inventory_inner_section->add_block(
|
||||
array(
|
||||
'id' => 'product-inventory-quantity',
|
||||
'blockName' => 'woocommerce/product-inventory-quantity-field',
|
||||
'order' => 30,
|
||||
'hideConditions' => $product_inventory_quantity_hide_conditions,
|
||||
)
|
||||
);
|
||||
$product_inventory_quantity_conditional->add_block(
|
||||
$product_stock_status_hide_conditions = array(
|
||||
array(
|
||||
'id' => 'product-inventory-quantity',
|
||||
'blockName' => 'woocommerce/product-inventory-quantity-field',
|
||||
'order' => 10,
|
||||
)
|
||||
'expression' => 'editedProduct.manage_stock === true',
|
||||
),
|
||||
);
|
||||
$product_stock_status_conditional = $product_inventory_section->add_block(
|
||||
if ( Features::is_enabled( 'product-grouped' ) ) {
|
||||
$product_stock_status_hide_conditions[] = array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
);
|
||||
}
|
||||
$product_inventory_section->add_block(
|
||||
array(
|
||||
'id' => 'product-stock-status-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 20,
|
||||
'attributes' => array(
|
||||
'mustMatch' => array(
|
||||
'manage_stock' => array( false ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_stock_status_conditional->add_block(
|
||||
array(
|
||||
'id' => 'product-stock-status',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'id' => 'product-stock-status',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'title' => __( 'Stock status', 'woocommerce' ),
|
||||
'property' => 'stock_status',
|
||||
'options' => array(
|
||||
@@ -750,18 +843,24 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
),
|
||||
),
|
||||
'hideConditions' => $product_stock_status_hide_conditions,
|
||||
)
|
||||
);
|
||||
$product_inventory_advanced = $product_inventory_section->add_block(
|
||||
array(
|
||||
'id' => 'product-inventory-advanced',
|
||||
'blockName' => 'woocommerce/product-collapsible',
|
||||
'order' => 30,
|
||||
'attributes' => array(
|
||||
'id' => 'product-inventory-advanced',
|
||||
'blockName' => 'woocommerce/product-collapsible',
|
||||
'order' => 30,
|
||||
'attributes' => array(
|
||||
'toggleText' => __( 'Advanced', 'woocommerce' ),
|
||||
'initialCollapsed' => true,
|
||||
'persistRender' => true,
|
||||
),
|
||||
'hideConditions' => Features::is_enabled( 'product-grouped' ) ? array(
|
||||
array(
|
||||
'expression' => 'editedProduct.type === "grouped"',
|
||||
),
|
||||
) : null,
|
||||
)
|
||||
);
|
||||
$product_inventory_advanced_wrapper = $product_inventory_advanced->add_block(
|
||||
@@ -773,24 +872,12 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_out_of_stock_conditional = $product_inventory_advanced_wrapper->add_block(
|
||||
$product_inventory_advanced_wrapper->add_block(
|
||||
array(
|
||||
'id' => 'product-out-of-stock-conditional-wrapper',
|
||||
'blockName' => 'woocommerce/conditional',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'mustMatch' => array(
|
||||
'manage_stock' => array( true ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_out_of_stock_conditional->add_block(
|
||||
array(
|
||||
'id' => 'product-out-of-stock',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'id' => 'product-out-of-stock',
|
||||
'blockName' => 'woocommerce/product-radio-field',
|
||||
'order' => 10,
|
||||
'attributes' => array(
|
||||
'title' => __( 'When out of stock', 'woocommerce' ),
|
||||
'property' => 'backorders',
|
||||
'options' => array(
|
||||
@@ -811,13 +898,23 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
|
||||
),
|
||||
),
|
||||
),
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.manage_stock === false',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$product_out_of_stock_conditional->add_block(
|
||||
$product_inventory_advanced_wrapper->add_block(
|
||||
array(
|
||||
'id' => 'product-inventory-email',
|
||||
'blockName' => 'woocommerce/product-inventory-email-field',
|
||||
'order' => 20,
|
||||
'id' => 'product-inventory-email',
|
||||
'blockName' => 'woocommerce/product-inventory-email-field',
|
||||
'order' => 20,
|
||||
'hideConditions' => array(
|
||||
array(
|
||||
'expression' => 'editedProduct.manage_stock === false',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Integrations;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
|
||||
use WP_CONSENT_API;
|
||||
|
||||
/**
|
||||
* Class WPConsentAPI
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class WPConsentAPI {
|
||||
|
||||
use ScriptDebug;
|
||||
|
||||
/**
|
||||
* Register the consent API.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register() {
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
function() {
|
||||
$this->on_plugins_loaded();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register our hooks on plugins_loaded.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function on_plugins_loaded() {
|
||||
// Include integration to WP Consent Level API if available.
|
||||
if ( ! $this->is_wp_consent_api_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plugin = plugin_basename( WC_PLUGIN_FILE );
|
||||
add_filter( "wp_consent_api_registered_{$plugin}", '__return_true' );
|
||||
add_action(
|
||||
'wp_enqueue_scripts',
|
||||
function() {
|
||||
$this->enqueue_consent_api_scripts();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Modify the "allowTracking" flag consent if the user has consented to marketing.
|
||||
*
|
||||
* Wp-consent-api will initialize the modules on "plugins_loaded" with priority 9,
|
||||
* So this code needs to be run after that.
|
||||
*/
|
||||
add_filter(
|
||||
'wc_order_attribution_allow_tracking',
|
||||
function() {
|
||||
return function_exists( 'wp_has_consent' ) && wp_has_consent( 'marketing' );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WP Cookie Consent API is active
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_wp_consent_api_active() {
|
||||
return class_exists( WP_CONSENT_API::class );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue JS for integration with WP Consent Level API
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function enqueue_consent_api_scripts() {
|
||||
wp_enqueue_script(
|
||||
'wp-consent-api-integration-js',
|
||||
plugins_url(
|
||||
"assets/js/frontend/wp-consent-api-integration{$this->get_script_suffix()}.js",
|
||||
WC_PLUGIN_FILE
|
||||
),
|
||||
array( 'jquery', 'wp-consent-api' ),
|
||||
Constants::get_constant( 'WC_VERSION' ),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Orders;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
|
||||
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Class OrderAttributionBlocksController
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class OrderAttributionBlocksController implements RegisterHooksInterface {
|
||||
|
||||
use ScriptDebug;
|
||||
|
||||
/**
|
||||
* Instance of the features controller.
|
||||
*
|
||||
* @var FeaturesController
|
||||
*/
|
||||
private $features_controller;
|
||||
|
||||
/**
|
||||
* ExtendSchema instance.
|
||||
*
|
||||
* @var ExtendSchema
|
||||
*/
|
||||
private $extend_schema;
|
||||
|
||||
/**
|
||||
* Instance of the order attribution controller.
|
||||
*
|
||||
* @var OrderAttributionController
|
||||
*/
|
||||
private $order_attribution_controller;
|
||||
|
||||
/**
|
||||
* Bind dependencies on init.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param ExtendSchema $extend_schema ExtendSchema instance.
|
||||
* @param FeaturesController $features_controller Features controller.
|
||||
* @param OrderAttributionController $order_attribution_controller Instance of the order attribution controller.
|
||||
*/
|
||||
final public function init(
|
||||
ExtendSchema $extend_schema,
|
||||
FeaturesController $features_controller,
|
||||
OrderAttributionController $order_attribution_controller
|
||||
) {
|
||||
$this->extend_schema = $extend_schema;
|
||||
$this->features_controller = $features_controller;
|
||||
$this->order_attribution_controller = $order_attribution_controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into WP.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register() {
|
||||
// Bail if the feature is not enabled.
|
||||
if ( ! $this->features_controller->feature_is_enabled( 'order_attribution' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the Store API.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function extend_api() {
|
||||
$this->extend_schema->register_endpoint_data(
|
||||
array(
|
||||
'endpoint' => CheckoutSchema::IDENTIFIER,
|
||||
'namespace' => 'woocommerce/order-attribution',
|
||||
'schema_callback' => $this->get_schema_callback(),
|
||||
)
|
||||
);
|
||||
// Update order based on extended data.
|
||||
add_action(
|
||||
'woocommerce_store_api_checkout_update_order_from_request',
|
||||
function ( $order, $request ) {
|
||||
$extensions = $request->get_param( 'extensions' );
|
||||
$params = $extensions['woocommerce/order-attribution'] ?? array();
|
||||
|
||||
if ( empty( $params ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an action to save order attribution data.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param WC_Order $order The order object.
|
||||
* @param array $params Unprefixed order attribution data.
|
||||
*/
|
||||
do_action( 'woocommerce_order_save_attribution_data', $order, $params );
|
||||
},
|
||||
10,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the schema callback.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
private function get_schema_callback() {
|
||||
return function() {
|
||||
$schema = array();
|
||||
$fields = $this->order_attribution_controller->get_fields();
|
||||
|
||||
$validate_callback = function( $value ) {
|
||||
if ( ! is_string( $value ) && null !== $value ) {
|
||||
return new WP_Error(
|
||||
'api-error',
|
||||
sprintf(
|
||||
/* translators: %s is the property type */
|
||||
esc_html__( 'Value of type %s was posted to the order attribution callback', 'woocommerce' ),
|
||||
gettype( $value )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$sanitize_callback = function( $value ) {
|
||||
return sanitize_text_field( $value );
|
||||
};
|
||||
|
||||
foreach ( $fields as $field ) {
|
||||
$schema[ $field ] = array(
|
||||
'description' => sprintf(
|
||||
/* translators: %s is the field name */
|
||||
__( 'Order attribution field: %s', 'woocommerce' ),
|
||||
esc_html( $field )
|
||||
),
|
||||
'type' => array( 'string', 'null' ),
|
||||
'context' => array(),
|
||||
'arg_options' => array(
|
||||
'validate_callback' => $validate_callback,
|
||||
'sanitize_callback' => $sanitize_callback,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $schema;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Orders;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
|
||||
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
|
||||
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
|
||||
use Automattic\WooCommerce\Proxies\LegacyProxy;
|
||||
use Automattic\WooCommerce\Utilities\OrderUtil;
|
||||
use Exception;
|
||||
use WC_Customer;
|
||||
use WC_Log_Levels;
|
||||
use WC_Logger_Interface;
|
||||
use WC_Order;
|
||||
use WC_Tracks;
|
||||
|
||||
/**
|
||||
* Class OrderAttributionController
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
class OrderAttributionController implements RegisterHooksInterface {
|
||||
|
||||
use ScriptDebug;
|
||||
use OrderAttributionMeta {
|
||||
get_prefixed_field as public;
|
||||
}
|
||||
|
||||
/**
|
||||
* The FeatureController instance.
|
||||
*
|
||||
* @var FeaturesController
|
||||
*/
|
||||
private $feature_controller;
|
||||
|
||||
/**
|
||||
* WooCommerce logger class instance.
|
||||
*
|
||||
* @var WC_Logger_Interface
|
||||
*/
|
||||
private $logger;
|
||||
|
||||
/**
|
||||
* The LegacyProxy instance.
|
||||
*
|
||||
* @var LegacyProxy
|
||||
*/
|
||||
private $proxy;
|
||||
|
||||
/**
|
||||
* Initialization method.
|
||||
*
|
||||
* Takes the place of the constructor within WooCommerce Dependency injection.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param LegacyProxy $proxy The legacy proxy.
|
||||
* @param FeaturesController $controller The feature controller.
|
||||
* @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 ) {
|
||||
$this->proxy = $proxy;
|
||||
$this->feature_controller = $controller;
|
||||
$this->logger = $logger ?? $proxy->call_function( 'wc_get_logger' );
|
||||
$this->set_fields_and_prefix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this class instance to the appropriate hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register() {
|
||||
// Don't run during install.
|
||||
if ( Constants::get_constant( 'WC_INSTALLING' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail if the feature is not enabled.
|
||||
if ( ! $this->feature_controller->feature_is_enabled( 'order_attribution' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action(
|
||||
'wp_enqueue_scripts',
|
||||
function() {
|
||||
$this->enqueue_scripts_and_styles();
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'admin_enqueue_scripts',
|
||||
function() {
|
||||
$this->enqueue_admin_scripts_and_styles();
|
||||
}
|
||||
);
|
||||
|
||||
// Include our hidden fields on order notes and registration form.
|
||||
$source_form_fields = function() {
|
||||
$this->source_form_fields();
|
||||
};
|
||||
|
||||
add_action( 'woocommerce_after_order_notes', $source_form_fields );
|
||||
add_action( 'woocommerce_register_form', $source_form_fields );
|
||||
|
||||
// Update order based on submitted fields.
|
||||
add_action(
|
||||
'woocommerce_checkout_order_created',
|
||||
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 );
|
||||
/**
|
||||
* Run an action to save order attribution data.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param WC_Order $order The order object.
|
||||
* @param array $params Unprefixed order attribution data.
|
||||
*/
|
||||
do_action( 'woocommerce_order_save_attribution_data', $order, $params );
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'woocommerce_order_save_attribution_data',
|
||||
function( $order, $data ) {
|
||||
$source_data = $this->get_source_values( $data );
|
||||
$this->send_order_tracks( $source_data, $order );
|
||||
$this->set_order_source_data( $source_data, $order );
|
||||
},
|
||||
10,
|
||||
2
|
||||
);
|
||||
|
||||
add_action(
|
||||
'user_register',
|
||||
function( $customer_id ) {
|
||||
try {
|
||||
$customer = new WC_Customer( $customer_id );
|
||||
$this->set_customer_source_data( $customer );
|
||||
} catch ( Exception $e ) {
|
||||
$this->log( $e->getMessage(), __METHOD__, WC_Log_Levels::ERROR );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add origin data to the order table.
|
||||
add_action(
|
||||
'admin_init',
|
||||
function() {
|
||||
$this->register_order_origin_column();
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'woocommerce_new_order',
|
||||
function( $order_id, $order ) {
|
||||
$this->maybe_set_admin_source( $order );
|
||||
},
|
||||
2,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the order is created in the admin, set the source type and origin to admin/Web admin.
|
||||
*
|
||||
* @param WC_Order $order The recently created order object.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
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->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the fields.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_fields(): array {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the prefix for the fields.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_prefix(): string {
|
||||
return $this->field_prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripts & styles for custom source tracking and cart tracking.
|
||||
*/
|
||||
private function enqueue_scripts_and_styles() {
|
||||
wp_enqueue_script(
|
||||
'sourcebuster-js',
|
||||
plugins_url( "assets/js/sourcebuster/sourcebuster{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
|
||||
array(),
|
||||
Constants::get_constant( 'WC_VERSION' ),
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'wc-order-attribution',
|
||||
plugins_url( "assets/js/frontend/order-attribution{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
|
||||
array( 'sourcebuster-js' ),
|
||||
Constants::get_constant( 'WC_VERSION' ),
|
||||
true
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter the lifetime of the cookie used for source tracking.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param float $lifetime The lifetime of the Sourcebuster cookies in months.
|
||||
*
|
||||
* The default value forces Sourcebuster into making the cookies valid for the current session only.
|
||||
*/
|
||||
$lifetime = (float) apply_filters( 'wc_order_attribution_cookie_lifetime_months', 0.00001 );
|
||||
|
||||
/**
|
||||
* Filter the session length for source tracking.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param int $session_length The session length in minutes.
|
||||
*/
|
||||
$session_length = (int) apply_filters( 'wc_order_attribution_session_length_minutes', 30 );
|
||||
|
||||
/**
|
||||
* Filter to allow tracking.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param bool $allow_tracking True to allow tracking, false to disable.
|
||||
*/
|
||||
$allow_tracking = wc_bool_to_string( apply_filters( 'wc_order_attribution_allow_tracking', true ) );
|
||||
|
||||
// Create Order Attribution JS namespace with parameters.
|
||||
$namespace = array(
|
||||
'params' => array(
|
||||
'lifetime' => $lifetime,
|
||||
'session' => $session_length,
|
||||
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||
'prefix' => $this->field_prefix,
|
||||
'allowTracking' => $allow_tracking,
|
||||
),
|
||||
);
|
||||
|
||||
wp_localize_script( 'wc-order-attribution', 'wc_order_attribution', $namespace );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the stylesheet for admin pages.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function enqueue_admin_scripts_and_styles() {
|
||||
$screen = get_current_screen();
|
||||
if ( $screen->id !== $this->get_order_screen_id() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter
|
||||
wp_enqueue_script(
|
||||
'woocommerce-order-attribution-admin-js',
|
||||
plugins_url( "assets/js/admin/order-attribution-admin{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
|
||||
array( 'jquery' ),
|
||||
Constants::get_constant( 'WC_VERSION' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the origin column in the orders table.
|
||||
*
|
||||
* @param int $order_id The order ID.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function display_origin_column( $order_id ): void {
|
||||
try {
|
||||
// Ensure we've got a valid order.
|
||||
$order = $this->get_hpos_order_object( $order_id );
|
||||
$this->output_origin_column( $order );
|
||||
} catch ( Exception $e ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the translated origin label for the Origin column in the orders table.
|
||||
*
|
||||
* Default to "Unknown" if no origin is set.
|
||||
*
|
||||
* @param WC_Order $order The order object.
|
||||
*
|
||||
* @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' ) );
|
||||
$origin = $this->get_origin_label( $source_type, $source );
|
||||
if ( empty( $origin ) ) {
|
||||
$origin = __( 'Unknown', 'woocommerce' );
|
||||
}
|
||||
echo esc_html( $origin );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add attribution hidden input fields 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 ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save source data for a Customer object.
|
||||
*
|
||||
* @param WC_Customer $customer The customer object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
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 );
|
||||
}
|
||||
|
||||
$customer->save_meta_data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save source data for an Order object.
|
||||
*
|
||||
* @param array $source_data The source data.
|
||||
* @param WC_Order $order The order object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function set_order_source_data( array $source_data, WC_Order $order ) {
|
||||
foreach ( $source_data as $key => $value ) {
|
||||
$order->add_meta_data( $this->get_meta_prefixed_field( $key ), $value );
|
||||
}
|
||||
|
||||
$order->save_meta_data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message as a debug log entry.
|
||||
*
|
||||
* @param string $message The message to log.
|
||||
* @param string $method The method that is logging the message.
|
||||
* @param string $level The log level.
|
||||
*/
|
||||
private function log( string $message, string $method, string $level = WC_Log_Levels::DEBUG ) {
|
||||
/**
|
||||
* Filter to enable debug mode.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $enabled 'yes' to enable debug mode, 'no' to disable.
|
||||
*/
|
||||
if ( 'yes' !== apply_filters( 'wc_order_attribution_debug_mode_enabled', 'no' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->log(
|
||||
$level,
|
||||
sprintf( '%s %s', $method, $message ),
|
||||
array( 'source' => 'woocommerce-order-attribution' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order source data to Tracks.
|
||||
*
|
||||
* @param array $source_data The source data.
|
||||
* @param WC_Order $order The order object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function send_order_tracks( array $source_data, WC_Order $order ) {
|
||||
$origin_label = $this->get_origin_label(
|
||||
$source_data['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',
|
||||
);
|
||||
$this->proxy->call_static( WC_Tracks::class, 'record_event', 'order_attribution', $tracks_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the screen ID for the orders page.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_order_screen_id(): string {
|
||||
return OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the origin column in the orders table.
|
||||
*
|
||||
* This accounts for the differences in hooks based on whether HPOS is enabled or not.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_order_origin_column() {
|
||||
$screen_id = $this->get_order_screen_id();
|
||||
|
||||
$add_column = function( $columns ) {
|
||||
$columns['origin'] = esc_html__( 'Origin', 'woocommerce' );
|
||||
|
||||
return $columns;
|
||||
};
|
||||
// HPOS and non-HPOS use different hooks.
|
||||
add_filter( "manage_{$screen_id}_columns", $add_column );
|
||||
add_filter( "manage_edit-{$screen_id}_columns", $add_column );
|
||||
|
||||
$display_column = function( $column_name, $order_id ) {
|
||||
if ( 'origin' !== $column_name ) {
|
||||
return;
|
||||
}
|
||||
$this->display_origin_column( $order_id );
|
||||
};
|
||||
// HPOS and non-HPOS use different hooks.
|
||||
add_action( "manage_{$screen_id}_custom_column", $display_column, 10, 2 );
|
||||
add_action( "manage_{$screen_id}_posts_custom_column", $display_column, 10, 2 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* MatchImageBySKU class file.
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\ProductImage;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Class for the product image matching by SKU.
|
||||
*/
|
||||
class MatchImageBySKU {
|
||||
|
||||
use AccessiblePrivateMethods;
|
||||
|
||||
/**
|
||||
* The name of the setting for this feature.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $setting_name = 'woocommerce_product_match_featured_image_by_sku';
|
||||
|
||||
/**
|
||||
* MatchImageBySKU constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->init_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the hooks used by the class.
|
||||
*/
|
||||
private function init_hooks() {
|
||||
self::add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_image_sku_setting' ), 110, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this feature enabled.
|
||||
*
|
||||
* @since 8.3.0
|
||||
* @return bool
|
||||
*/
|
||||
public function is_enabled() {
|
||||
return wc_string_to_bool( get_option( $this->setting_name ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'woocommerce_get_settings_products', adds the settings related to the product image SKU matching table.
|
||||
*
|
||||
* @param array $settings Original settings configuration array.
|
||||
* @param string $section_id Settings section identifier.
|
||||
* @return array New settings configuration array.
|
||||
*/
|
||||
private function add_product_image_sku_setting( array $settings, string $section_id ): array {
|
||||
if ( 'advanced' !== $section_id ) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
$settings[] = array(
|
||||
'title' => __( 'Product image matching by SKU', 'woocommerce' ),
|
||||
'type' => 'title',
|
||||
);
|
||||
|
||||
$settings[] = array(
|
||||
'title' => __( 'Match images', 'woocommerce' ),
|
||||
'desc' => __( 'Set product featured image when uploaded image file name matches product SKU.', 'woocommerce' ),
|
||||
'id' => $this->setting_name,
|
||||
'default' => 'no',
|
||||
'type' => 'checkbox',
|
||||
'checkboxgroup' => 'start',
|
||||
);
|
||||
|
||||
$settings[] = array( 'type' => 'sectionend' );
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal;
|
||||
|
||||
/**
|
||||
* Interface RegisterHooksInterface
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
interface RegisterHooksInterface {
|
||||
|
||||
/**
|
||||
* Register this class instance to the appropriate hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register();
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
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;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Trait OrderAttributionMeta
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* phpcs:disable Generic.Commenting.DocComment.MissingShort
|
||||
*/
|
||||
trait OrderAttributionMeta {
|
||||
|
||||
/** @var string[] */
|
||||
private $default_fields = array(
|
||||
// main fields.
|
||||
'type',
|
||||
'url',
|
||||
|
||||
// utm fields.
|
||||
'utm_campaign',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_content',
|
||||
'utm_id',
|
||||
'utm_term',
|
||||
|
||||
// additional fields.
|
||||
'session_entry',
|
||||
'session_start_time',
|
||||
'session_pages',
|
||||
'session_count',
|
||||
'user_agent',
|
||||
);
|
||||
|
||||
/** @var array */
|
||||
private $fields = array();
|
||||
|
||||
/** @var string */
|
||||
private $field_prefix = '';
|
||||
|
||||
/**
|
||||
* Get the device type based on the other meta fields.
|
||||
*
|
||||
* @param array $values The meta values.
|
||||
*
|
||||
* @return string The device type.
|
||||
*/
|
||||
protected function get_device_type( array $values ): string {
|
||||
$detector = new MobileDetect( array(), $values['user_agent'] );
|
||||
|
||||
if ( $detector->isMobile() ) {
|
||||
return 'Mobile';
|
||||
} elseif ( $detector->isTablet() ) {
|
||||
return 'Tablet';
|
||||
} else {
|
||||
return 'Desktop';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the meta fields and the field prefix.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function set_fields_and_prefix() {
|
||||
/**
|
||||
* Filter the fields to show in the source data metabox.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string[] $fields The fields to show.
|
||||
*/
|
||||
$this->fields = (array) apply_filters( 'wc_order_attribution_tracking_fields', $this->default_fields );
|
||||
$this->set_field_prefix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the meta prefix for our fields.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function set_field_prefix(): void {
|
||||
/**
|
||||
* Filter the prefix for the meta fields.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $prefix The prefix for the meta fields.
|
||||
*/
|
||||
$prefix = (string) apply_filters(
|
||||
'wc_order_attribution_tracking_field_prefix',
|
||||
'wc_order_attribution_'
|
||||
);
|
||||
|
||||
// Remove leading and trailing underscores.
|
||||
$prefix = trim( $prefix, '_' );
|
||||
|
||||
// Ensure the prefix ends with _, and set the prefix.
|
||||
$this->field_prefix = "{$prefix}_";
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an order's meta data to only the keys that we care about.
|
||||
*
|
||||
* Sets the origin value based on the source type.
|
||||
*
|
||||
* @param WC_Meta_Data[] $meta The meta data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function filter_meta_data( array $meta ): array {
|
||||
$return = array();
|
||||
$prefix = $this->get_meta_prefixed_field( '' );
|
||||
|
||||
foreach ( $meta as $item ) {
|
||||
if ( str_starts_with( $item->key, $prefix ) ) {
|
||||
$return[ $this->unprefix_meta_field( $item->key ) ] = $item->value;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the device type from the user agent.
|
||||
if ( ! array_key_exists( 'device_type', $return ) && array_key_exists( 'user_agent', $return ) ) {
|
||||
$return['device_type'] = $this->get_device_type( $return );
|
||||
}
|
||||
|
||||
// Determine the origin based on source type and referrer.
|
||||
$source_type = $return['type'] ?? '';
|
||||
$source = $return['utm_source'] ?? '';
|
||||
$return['origin'] = $this->get_origin_label( $source_type, $source, true );
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field name with the appropriate prefix.
|
||||
*
|
||||
* @param string $field Field name.
|
||||
*
|
||||
* @return string The prefixed field name.
|
||||
*/
|
||||
private function get_prefixed_field( $field ): string {
|
||||
return "{$this->field_prefix}{$field}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field name with the meta prefix.
|
||||
*
|
||||
* @param string $field 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 )}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the meta prefix from the field name.
|
||||
*
|
||||
* @param string $field The prefixed field.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the order object with HPOS compatibility.
|
||||
*
|
||||
* @param WC_Order|WP_Post|int $post_or_order The post ID or object.
|
||||
*
|
||||
* @return WC_Order The order object
|
||||
* @throws Exception When the order isn't found.
|
||||
*/
|
||||
private function get_hpos_order_object( $post_or_order ) {
|
||||
// If we've already got an order object, just return it.
|
||||
if ( $post_or_order instanceof WC_Order ) {
|
||||
return $post_or_order;
|
||||
}
|
||||
|
||||
// If we have a post ID, get the post object.
|
||||
if ( is_numeric( $post_or_order ) ) {
|
||||
$post_or_order = wc_get_order( $post_or_order );
|
||||
}
|
||||
|
||||
// Throw an exception if we don't have an order object.
|
||||
if ( ! $post_or_order instanceof WC_Order ) {
|
||||
throw new Exception( __( 'Order not found.', 'woocommerce' ) );
|
||||
}
|
||||
|
||||
return $post_or_order;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Map posted, prefixed values to fields.
|
||||
* 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 {
|
||||
$values = array();
|
||||
|
||||
// Look through each field in POST data.
|
||||
foreach ( $this->fields as $field ) {
|
||||
$values[ $field ] = $raw_values[ $this->get_prefixed_field( $field ) ] ?? '(none)';
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map submitted values to meta values.
|
||||
*
|
||||
* @param array $raw_values The raw (unprefixed) values from the submitted data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_source_values( array $raw_values = array() ): array {
|
||||
$values = array();
|
||||
|
||||
// Look through each field in given data.
|
||||
foreach ( $this->fields as $field ) {
|
||||
$value = sanitize_text_field( wp_unslash( $raw_values[ $field ] ) );
|
||||
if ( '(none)' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[ $field ] = $value;
|
||||
}
|
||||
|
||||
// Set the device type if possible using the user agent.
|
||||
if ( array_key_exists( 'user_agent', $values ) && ! empty( $values['user_agent'] ) ) {
|
||||
$values['device_type'] = $this->get_device_type( $values );
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label for the Order origin with placeholder where appropriate. Can be
|
||||
* translated (for DB / display) or untranslated (for Tracks).
|
||||
*
|
||||
* @param string $source_type The source type.
|
||||
* @param string $source The source.
|
||||
* @param bool $translated Whether the label should be translated.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_origin_label( string $source_type, string $source, bool $translated = true ): string {
|
||||
// Set up the label based on the source type.
|
||||
switch ( $source_type ) {
|
||||
case 'utm':
|
||||
$label = $translated ?
|
||||
/* translators: %s is the source value */
|
||||
__( 'Source: %s', 'woocommerce' )
|
||||
: 'Source: %s';
|
||||
break;
|
||||
case 'organic':
|
||||
$label = $translated ?
|
||||
/* translators: %s is the source value */
|
||||
__( 'Organic: %s', 'woocommerce' )
|
||||
: 'Organic: %s';
|
||||
break;
|
||||
case 'referral':
|
||||
$label = $translated ?
|
||||
/* translators: %s is the source value */
|
||||
__( 'Referral: %s', 'woocommerce' )
|
||||
: 'Referral: %s';
|
||||
break;
|
||||
case 'typein':
|
||||
$label = '';
|
||||
$source = $translated ?
|
||||
__( 'Direct', 'woocommerce' )
|
||||
: 'Direct';
|
||||
break;
|
||||
case 'admin':
|
||||
$label = '';
|
||||
$source = $translated ?
|
||||
__( 'Web admin', 'woocommerce' )
|
||||
: 'Web admin';
|
||||
break;
|
||||
|
||||
default:
|
||||
$label = '';
|
||||
$source = __( 'Unknown', 'woocommerce' );
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the formatted source for the order origin.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $formatted_source The formatted source.
|
||||
* @param string $source The source.
|
||||
*/
|
||||
$formatted_source = apply_filters(
|
||||
'wc_order_attribution_origin_formatted_source',
|
||||
ucfirst( trim( $source, '()' ) ),
|
||||
$source
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter the label for the order origin.
|
||||
*
|
||||
* This label should have a %s placeholder for the formatted source to be inserted
|
||||
* via sprintf().
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $label The label for the order origin.
|
||||
* @param string $source_type The source type.
|
||||
* @param string $source The source.
|
||||
* @param string $formatted_source The formatted source.
|
||||
*/
|
||||
$label = (string) apply_filters(
|
||||
'wc_order_attribution_origin_label',
|
||||
$label,
|
||||
$source_type,
|
||||
$source,
|
||||
$formatted_source
|
||||
);
|
||||
|
||||
if ( false === strpos( $label, '%' ) ) {
|
||||
return $formatted_source;
|
||||
}
|
||||
|
||||
return sprintf( $label, $formatted_source );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description for the order attribution field.
|
||||
*
|
||||
* @param string $field The field name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_field_description( string $field ): string {
|
||||
/* translators: %s is the field name */
|
||||
$description = sprintf( __( 'Order attribution field: %s', 'woocommerce' ), $field );
|
||||
|
||||
/**
|
||||
* Filter the description for the order attribution field.
|
||||
*
|
||||
* @since 8.5.0
|
||||
*
|
||||
* @param string $description The description for the order attribution field.
|
||||
* @param string $field 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Traits;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
|
||||
/**
|
||||
* Trait ScriptDebug
|
||||
*
|
||||
* @since 8.5.0
|
||||
*/
|
||||
trait ScriptDebug {
|
||||
|
||||
/**
|
||||
* Get the script suffix based on the SCRIPT_DEBUG constant.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_script_suffix(): string {
|
||||
return $this->is_script_debug_enabled() ? '' : '.min';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SCRIPT_DEBUG is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_script_debug_enabled(): bool {
|
||||
return Constants::is_true( 'SCRIPT_DEBUG' );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user