This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<?php
/**
* Class ActionScheduler
* @codeCoverageIgnore
*/
abstract class ActionScheduler {
private static $plugin_file = '';
/** @var ActionScheduler_ActionFactory */
private static $factory = NULL;
public static function factory() {
if ( !isset(self::$factory) ) {
self::$factory = new ActionScheduler_ActionFactory();
}
return self::$factory;
}
public static function store() {
return ActionScheduler_Store::instance();
}
public static function logger() {
return ActionScheduler_Logger::instance();
}
public static function runner() {
return ActionScheduler_QueueRunner::instance();
}
public static function admin_view() {
return ActionScheduler_AdminView::instance();
}
/**
* Get the absolute system path to the plugin directory, or a file therein
* @static
* @param string $path
* @return string
*/
public static function plugin_path( $path ) {
$base = dirname(self::$plugin_file);
if ( $path ) {
return trailingslashit($base).$path;
} else {
return untrailingslashit($base);
}
}
/**
* Get the absolute URL to the plugin directory, or a file therein
* @static
* @param string $path
* @return string
*/
public static function plugin_url( $path ) {
return plugins_url($path, self::$plugin_file);
}
public static function autoload( $class ) {
$d = DIRECTORY_SEPARATOR;
if ( 'Deprecated' === substr( $class, -10 ) ) {
$dir = self::plugin_path('deprecated'.$d);
} elseif ( strpos( $class, 'ActionScheduler' ) === 0 ) {
$dir = self::plugin_path('classes'.$d);
} elseif ( strpos( $class, 'CronExpression' ) === 0 ) {
$dir = self::plugin_path('lib'.$d.'cron-expression'.$d);
} else {
return;
}
if ( file_exists( "{$dir}{$class}.php" ) ) {
include( "{$dir}{$class}.php" );
return;
}
}
/**
* Initialize the plugin
*
* @static
* @param string $plugin_file
*/
public static function init( $plugin_file ) {
self::$plugin_file = $plugin_file;
spl_autoload_register( array( __CLASS__, 'autoload' ) );
/**
* Fires in the early stages of Action Scheduler init hook.
*/
do_action( 'action_scheduler_pre_init' );
$store = self::store();
add_action( 'init', array( $store, 'init' ), 1, 0 );
$logger = self::logger();
add_action( 'init', array( $logger, 'init' ), 1, 0 );
$runner = self::runner();
add_action( 'init', array( $runner, 'init' ), 1, 0 );
$admin_view = self::admin_view();
add_action( 'init', array( $admin_view, 'init' ), 0, 0 ); // run before $store::init()
require_once( self::plugin_path('functions.php') );
if ( apply_filters( 'action_scheduler_load_deprecated_functions', true ) ) {
require_once( self::plugin_path('deprecated/functions.php') );
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command' );
}
}
final public function __clone() {
trigger_error("Singleton. No cloning allowed!", E_USER_ERROR);
}
final public function __wakeup() {
trigger_error("Singleton. No serialization allowed!", E_USER_ERROR);
}
final private function __construct() {}
/** Deprecated **/
public static function get_datetime_object( $when = null, $timezone = 'UTC' ) {
_deprecated_function( __METHOD__, '2.0', 'wcs_add_months()' );
return as_get_datetime_object( $when, $timezone );
}
}

View File

@@ -0,0 +1,657 @@
<?php
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once( ABSPATH . 'wp-admin/includes/class-wp-list-table.php' );
}
/**
* Action Scheduler Abstract List Table class
*
* This abstract class enhances WP_List_Table making it ready to use.
*
* By extending this class we can focus on describing how our table looks like,
* which columns needs to be shown, filter, ordered by and more and forget about the details.
*
* This class supports:
* - Bulk actions
* - Search
* - Sortable columns
* - Automatic translations of the columns
*
* @codeCoverageIgnore
* @since 2.0.0
*/
abstract class ActionScheduler_Abstract_ListTable extends WP_List_Table {
/**
* The table name
*/
protected $table_name;
/**
* Package name, used in translations
*/
protected $package;
/**
* How many items do we render per page?
*/
protected $items_per_page = 10;
/**
* Enables search in this table listing. If this array
* is empty it means the listing is not searchable.
*/
protected $search_by = array();
/**
* Columns to show in the table listing. It is a key => value pair. The
* key must much the table column name and the value is the label, which is
* automatically translated.
*/
protected $columns = array();
/**
* Defines the row-actions. It expects an array where the key
* is the column name and the value is an array of actions.
*
* The array of actions are key => value, where key is the method name
* (with the prefix row_action_<key>) and the value is the label
* and title.
*/
protected $row_actions = array();
/**
* The Primary key of our table
*/
protected $ID = 'ID';
/**
* Enables sorting, it expects an array
* of columns (the column names are the values)
*/
protected $sort_by = array();
protected $filter_by = array();
/**
* @var array The status name => count combinations for this table's items. Used to display status filters.
*/
protected $status_counts = array();
/**
* @var array Notices to display when loading the table. Array of arrays of form array( 'class' => {updated|error}, 'message' => 'This is the notice text display.' ).
*/
protected $admin_notices = array();
/**
* @var string Localised string displayed in the <h1> element above the able.
*/
protected $table_header;
/**
* Enables bulk actions. It must be an array where the key is the action name
* and the value is the label (which is translated automatically). It is important
* to notice that it will check that the method exists (`bulk_$name`) and will throw
* an exception if it does not exists.
*
* This class will automatically check if the current request has a bulk action, will do the
* validations and afterwards will execute the bulk method, with two arguments. The first argument
* is the array with primary keys, the second argument is a string with a list of the primary keys,
* escaped and ready to use (with `IN`).
*/
protected $bulk_actions = array();
/**
* Makes translation easier, it basically just wraps
* `_x` with some default (the package name)
*/
protected function translate( $text, $context = '' ) {
return $text;
}
/**
* Reads `$this->bulk_actions` and returns an array that WP_List_Table understands. It
* also validates that the bulk method handler exists. It throws an exception because
* this is a library meant for developers and missing a bulk method is a development-time error.
*/
protected function get_bulk_actions() {
$actions = array();
foreach ( $this->bulk_actions as $action => $label ) {
if ( ! is_callable( array( $this, 'bulk_' . $action ) ) ) {
throw new RuntimeException( "The bulk action $action does not have a callback method" );
}
$actions[ $action ] = $this->translate( $label );
}
return $actions;
}
/**
* Checks if the current request has a bulk action. If that is the case it will validate and will
* execute the bulk method handler. Regardless if the action is valid or not it will redirect to
* the previous page removing the current arguments that makes this request a bulk action.
*/
protected function process_bulk_action() {
global $wpdb;
// Detect when a bulk action is being triggered.
$action = $this->current_action();
if ( ! $action ) {
return;
}
check_admin_referer( 'bulk-' . $this->_args['plural'] );
$method = 'bulk_' . $action;
if ( array_key_exists( $action, $this->bulk_actions ) && is_callable( array( $this, $method ) ) && ! empty( $_GET['ID'] ) && is_array( $_GET['ID'] ) ) {
$ids_sql = '(' . implode( ',', array_fill( 0, count( $_GET['ID'] ), '%s' ) ) . ')';
$this->$method( $_GET['ID'], $wpdb->prepare( $ids_sql, $_GET['ID'] ) );
}
wp_redirect( remove_query_arg(
array( '_wp_http_referer', '_wpnonce', 'ID', 'action', 'action2' ),
wp_unslash( $_SERVER['REQUEST_URI'] )
) );
exit;
}
/**
* Default code for deleting entries. We trust ids_sql because it is
* validated already by process_bulk_action()
*/
protected function bulk_delete( array $ids, $ids_sql ) {
global $wpdb;
$wpdb->query( "DELETE FROM {$this->table_name} WHERE {$this->ID} IN $ids_sql" );
}
/**
* Prepares the _column_headers property which is used by WP_Table_List at rendering.
* It merges the columns and the sortable columns.
*/
protected function prepare_column_headers() {
$this->_column_headers = array(
$this->get_columns(),
array(),
$this->get_sortable_columns(),
);
}
/**
* Reads $this->sort_by and returns the columns name in a format that WP_Table_List
* expects
*/
public function get_sortable_columns() {
$sort_by = array();
foreach ( $this->sort_by as $column ) {
$sort_by[ $column ] = array( $column, true );
}
return $sort_by;
}
/**
* Returns the columns names for rendering. It adds a checkbox for selecting everything
* as the first column
*/
public function get_columns() {
$columns = array_merge(
array( 'cb' => '<input type="checkbox" />' ),
array_map( array( $this, 'translate' ), $this->columns )
);
return $columns;
}
/**
* Get prepared LIMIT clause for items query
*
* @global wpdb $wpdb
*
* @return string Prepared LIMIT clause for items query.
*/
protected function get_items_query_limit() {
global $wpdb;
$per_page = $this->get_items_per_page( $this->package . '_items_per_page', $this->items_per_page );
return $wpdb->prepare( 'LIMIT %d', $per_page );
}
/**
* Returns the number of items to offset/skip for this current view.
*
* @return int
*/
protected function get_items_offset() {
$per_page = $this->get_items_per_page( $this->package . '_items_per_page', $this->items_per_page );
$current_page = $this->get_pagenum();
if ( 1 < $current_page ) {
$offset = $per_page * ( $current_page - 1 );
} else {
$offset = 0;
}
return $offset;
}
/**
* Get prepared OFFSET clause for items query
*
* @global wpdb $wpdb
*
* @return string Prepared OFFSET clause for items query.
*/
protected function get_items_query_offset() {
global $wpdb;
return $wpdb->prepare( 'OFFSET %d', $this->get_items_offset() );
}
/**
* Prepares the ORDER BY sql statement. It uses `$this->sort_by` to know which
* columns are sortable. This requests validates the orderby $_GET parameter is a valid
* column and sortable. It will also use order (ASC|DESC) using DESC by default.
*/
protected function get_items_query_order() {
if ( empty( $this->sort_by ) ) {
return '';
}
$orderby = esc_sql( $this->get_request_orderby() );
$order = esc_sql( $this->get_request_order() );
return "ORDER BY {$orderby} {$order}";
}
/**
* Return the sortable column specified for this request to order the results by, if any.
*
* @return string
*/
protected function get_request_orderby() {
$valid_sortable_columns = array_values( $this->sort_by );
if ( ! empty( $_GET['orderby'] ) && in_array( $_GET['orderby'], $valid_sortable_columns ) ) {
$orderby = sanitize_text_field( $_GET['orderby'] );
} else {
$orderby = $valid_sortable_columns[0];
}
return $orderby;
}
/**
* Return the sortable column order specified for this request.
*
* @return string
*/
protected function get_request_order() {
if ( ! empty( $_GET['order'] ) && 'desc' === strtolower( $_GET['order'] ) ) {
$order = 'DESC';
} else {
$order = 'ASC';
}
return $order;
}
/**
* Return the status filter for this request, if any.
*
* @return string
*/
protected function get_request_status() {
$status = ( ! empty( $_GET['status'] ) ) ? $_GET['status'] : '';
return $status;
}
/**
* Return the search filter for this request, if any.
*
* @return string
*/
protected function get_request_search_query() {
$search_query = ( ! empty( $_GET['s'] ) ) ? $_GET['s'] : '';
return $search_query;
}
/**
* Process and return the columns name. This is meant for using with SQL, this means it
* always includes the primary key.
*
* @return array
*/
protected function get_table_columns() {
$columns = array_keys( $this->columns );
if ( ! in_array( $this->ID, $columns ) ) {
$columns[] = $this->ID;
}
return $columns;
}
/**
* Check if the current request is doing a "full text" search. If that is the case
* prepares the SQL to search texts using LIKE.
*
* If the current request does not have any search or if this list table does not support
* that feature it will return an empty string.
*
* TODO:
* - Improve search doing LIKE by word rather than by phrases.
*
* @return string
*/
protected function get_items_query_search() {
global $wpdb;
if ( empty( $_GET['s'] ) || empty( $this->search_by ) ) {
return '';
}
$filter = array();
foreach ( $this->search_by as $column ) {
$filter[] = '`' . $column . '` like "%' . $wpdb->esc_like( $_GET['s'] ) . '%"';
}
return implode( ' OR ', $filter );
}
/**
* Prepares the SQL to filter rows by the options defined at `$this->filter_by`. Before trusting
* any data sent by the user it validates that it is a valid option.
*/
protected function get_items_query_filters() {
global $wpdb;
if ( ! $this->filter_by || empty( $_GET['filter_by'] ) || ! is_array( $_GET['filter_by'] ) ) {
return '';
}
$filter = array();
foreach ( $this->filter_by as $column => $options ) {
if ( empty( $_GET['filter_by'][ $column ] ) || empty( $options[ $_GET['filter_by'][ $column ] ] ) ) {
continue;
}
$filter[] = $wpdb->prepare( "`$column` = %s", $_GET['filter_by'][ $column ] );
}
return implode( ' AND ', $filter );
}
/**
* Prepares the data to feed WP_Table_List.
*
* This has the core for selecting, sorting and filting data. To keep the code simple
* its logic is split among many methods (get_items_query_*).
*
* Beside populating the items this function will also count all the records that matches
* the filtering criteria and will do fill the pagination variables.
*/
public function prepare_items() {
global $wpdb;
$this->process_bulk_action();
$this->process_row_actions();
if ( ! empty( $_REQUEST['_wp_http_referer'] ) ) {
// _wp_http_referer is used only on bulk actions, we remove it to keep the $_GET shorter
wp_redirect( remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
exit;
}
$this->prepare_column_headers();
$limit = $this->get_items_query_limit();
$offset = $this->get_items_query_offset();
$order = $this->get_items_query_order();
$where = array_filter(array(
$this->get_items_query_search(),
$this->get_items_query_filters(),
));
$columns = '`' . implode( '`, `', $this->get_table_columns() ) . '`';
if ( ! empty( $where ) ) {
$where = 'WHERE ('. implode( ') AND (', $where ) . ')';
} else {
$where = '';
}
$sql = "SELECT $columns FROM {$this->table_name} {$where} {$order} {$limit} {$offset}";
$this->set_items( $wpdb->get_results( $sql, ARRAY_A ) );
$query_count = "SELECT COUNT({$this->ID}) FROM {$this->table_name} {$where}";
$total_items = $wpdb->get_var( $query_count );
$per_page = $this->get_items_per_page( $this->package . '_items_per_page', $this->items_per_page );
$this->set_pagination_args( array(
'total_items' => $total_items,
'per_page' => $per_page,
'total_pages' => ceil( $total_items / $per_page ),
) );
}
public function extra_tablenav( $which ) {
if ( ! $this->filter_by || 'top' !== $which ) {
return;
}
echo '<div class="alignleft actions">';
foreach ( $this->filter_by as $id => $options ) {
$default = ! empty( $_GET['filter_by'][ $id ] ) ? $_GET['filter_by'][ $id ] : '';
if ( empty( $options[ $default ] ) ) {
$default = '';
}
echo '<select name="filter_by[' . esc_attr( $id ) . ']" class="first" id="filter-by-' . esc_attr( $id ) . '">';
foreach ( $options as $value => $label ) {
echo '<option value="' . esc_attr( $value ) . '" ' . esc_html( $value == $default ? 'selected' : '' ) .'>'
. esc_html( $this->translate( $label ) )
. '</option>';
}
echo '</select>';
}
submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'post-query-submit' ) );
echo '</div>';
}
/**
* Set the data for displaying. It will attempt to unserialize (There is a chance that some columns
* are serialized). This can be override in child classes for further data transformation.
*/
protected function set_items( array $items ) {
$this->items = array();
foreach ( $items as $item ) {
$this->items[ $item[ $this->ID ] ] = array_map( 'maybe_unserialize', $item );
}
}
/**
* Renders the checkbox for each row, this is the first column and it is named ID regardless
* of how the primary key is named (to keep the code simpler). The bulk actions will do the proper
* name transformation though using `$this->ID`.
*/
public function column_cb( $row ) {
return '<input name="ID[]" type="checkbox" value="' . esc_attr( $row[ $this->ID ] ) .'" />';
}
/**
* Renders the row-actions.
*
* This method renders the action menu, it reads the definition from the $row_actions property,
* and it checks that the row action method exists before rendering it.
*
* @param array $row Row to render
* @param $column_name Current row
* @return
*/
protected function maybe_render_actions( $row, $column_name ) {
if ( empty( $this->row_actions[ $column_name ] ) ) {
return;
}
$row_id = $row[ $this->ID ];
$actions = '<div class="row-actions">';
$action_count = 0;
foreach ( $this->row_actions[ $column_name ] as $action_key => $action ) {
$action_count++;
if ( ! method_exists( $this, 'row_action_' . $action_key ) ) {
continue;
}
$action_link = ! empty( $action['link'] ) ? $action['link'] : add_query_arg( array( 'row_action' => $action_key, 'row_id' => $row_id, 'nonce' => wp_create_nonce( $action_key . '::' . $row_id ) ) );
$span_class = ! empty( $action['class'] ) ? $action['class'] : $action_key;
$separator = ( $action_count < count( $this->row_actions[ $column_name ] ) ) ? ' | ' : '';
$actions .= sprintf( '<span class="%s">', esc_attr( $span_class ) );
$actions .= sprintf( '<a href="%1$s" title="%2$s">%3$s</a>', esc_url( $action_link ), esc_attr( $action['desc'] ), esc_html( $action['name'] ) );
$actions .= sprintf( '%s</span>', $separator );
}
$actions .= '</div>';
return $actions;
}
protected function process_row_actions() {
$parameters = array( 'row_action', 'row_id', 'nonce' );
foreach ( $parameters as $parameter ) {
if ( empty( $_REQUEST[ $parameter ] ) ) {
return;
}
}
$method = 'row_action_' . $_REQUEST['row_action'];
if ( $_REQUEST['nonce'] === wp_create_nonce( $_REQUEST[ 'row_action' ] . '::' . $_REQUEST[ 'row_id' ] ) && method_exists( $this, $method ) ) {
$this->$method( $_REQUEST['row_id'] );
}
wp_redirect( remove_query_arg(
array( 'row_id', 'row_action', 'nonce' ),
wp_unslash( $_SERVER['REQUEST_URI'] )
) );
exit;
}
/**
* Default column formatting, it will escape everything for security.
*/
public function column_default( $item, $column_name ) {
$column_html = esc_html( $item[ $column_name ] );
$column_html .= $this->maybe_render_actions( $item, $column_name );
return $column_html;
}
/**
* Display the table heading and search query, if any
*/
protected function display_header() {
echo '<h1 class="wp-heading-inline">' . esc_attr( $this->table_header ) . '</h1>';
if ( $this->get_request_search_query() ) {
/* translators: %s: search query */
echo '<span class="subtitle">' . esc_attr( sprintf( __( 'Search results for "%s"', 'woocommerce' ), $this->get_request_search_query() ) ) . '</span>';
}
echo '<hr class="wp-header-end">';
}
/**
* Display the table heading and search query, if any
*/
protected function display_admin_notices() {
foreach ( $this->admin_notices as $notice ) {
echo '<div id="message" class="' . $notice['class'] . '">';
echo ' <p>' . wp_kses_post( $notice['message'] ) . '</p>';
echo '</div>';
}
}
/**
* Prints the available statuses so the user can click to filter.
*/
protected function display_filter_by_status() {
$status_list_items = array();
$request_status = $this->get_request_status();
// Helper to set 'all' filter when not set on status counts passed in
if ( ! isset( $this->status_counts['all'] ) ) {
$this->status_counts = array( 'all' => array_sum( $this->status_counts ) ) + $this->status_counts;
}
foreach ( $this->status_counts as $status_name => $count ) {
if ( 0 === $count ) {
continue;
}
if ( $status_name === $request_status || ( empty( $request_status ) && 'all' === $status_name ) ) {
$status_list_item = '<li class="%1$s"><strong>%3$s</strong> (%4$d)</li>';
} else {
$status_list_item = '<li class="%1$s"><a href="%2$s">%3$s</a> (%4$d)</li>';
}
$status_filter_url = ( 'all' === $status_name ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_name );
$status_list_items[] = sprintf( $status_list_item, esc_attr( $status_name ), esc_url( $status_filter_url ), esc_html( ucfirst( $status_name ) ), absint( $count ) );
}
if ( $status_list_items ) {
echo '<ul class="subsubsub">';
echo implode( " | \n", $status_list_items );
echo '</ul>';
}
}
/**
* Renders the table list, we override the original class to render the table inside a form
* and to render any needed HTML (like the search box). By doing so the callee of a function can simple
* forget about any extra HTML.
*/
protected function display_table() {
echo '<form id="' . esc_attr( $this->_args['plural'] ) . '-filter" method="get">';
foreach ( $_GET as $key => $value ) {
if ( '_' === $key[0] || 'paged' === $key ) {
continue;
}
echo '<input type="hidden" name="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" />';
}
if ( ! empty( $this->search_by ) ) {
echo $this->search_box( $this->get_search_box_button_text(), 'plugin' ); // WPCS: XSS OK
}
parent::display();
echo '</form>';
}
/**
* Render the list table page, including header, notices, status filters and table.
*/
public function display_page() {
$this->prepare_items();
echo '<div class="wrap">';
$this->display_header();
$this->display_admin_notices();
$this->display_filter_by_status();
$this->display_table();
echo '</div>';
}
/**
* Get the text to display in the search box on the list table.
*/
protected function get_search_box_placeholder() {
return __( 'Search', 'woocommerce' );
}
}

View File

@@ -0,0 +1,219 @@
<?php
/**
* Abstract class with common Queue Cleaner functionality.
*/
abstract class ActionScheduler_Abstract_QueueRunner extends ActionScheduler_Abstract_QueueRunner_Deprecated {
/** @var ActionScheduler_QueueCleaner */
protected $cleaner;
/** @var ActionScheduler_FatalErrorMonitor */
protected $monitor;
/** @var ActionScheduler_Store */
protected $store;
/**
* The created time.
*
* Represents when the queue runner was constructed and used when calculating how long a PHP request has been running.
* For this reason it should be as close as possible to the PHP request start time.
*
* @var int
*/
private $created_time;
/**
* ActionScheduler_Abstract_QueueRunner constructor.
*
* @param ActionScheduler_Store $store
* @param ActionScheduler_FatalErrorMonitor $monitor
* @param ActionScheduler_QueueCleaner $cleaner
*/
public function __construct( ActionScheduler_Store $store = null, ActionScheduler_FatalErrorMonitor $monitor = null, ActionScheduler_QueueCleaner $cleaner = null ) {
$this->created_time = microtime( true );
$this->store = $store ? $store : ActionScheduler_Store::instance();
$this->monitor = $monitor ? $monitor : new ActionScheduler_FatalErrorMonitor( $this->store );
$this->cleaner = $cleaner ? $cleaner : new ActionScheduler_QueueCleaner( $this->store );
}
/**
* Process an individual action.
*
* @param int $action_id The action ID to process.
*/
public function process_action( $action_id ) {
try {
do_action( 'action_scheduler_before_execute', $action_id );
if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
do_action( 'action_scheduler_execution_ignored', $action_id );
return;
}
$action = $this->store->fetch_action( $action_id );
$this->store->log_execution( $action_id );
$action->execute();
do_action( 'action_scheduler_after_execute', $action_id, $action );
$this->store->mark_complete( $action_id );
} catch ( Exception $e ) {
$this->store->mark_failure( $action_id );
do_action( 'action_scheduler_failed_execution', $action_id, $e );
}
if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) ) {
$this->schedule_next_instance( $action );
}
}
/**
* Schedule the next instance of the action if necessary.
*
* @param ActionScheduler_Action $action
*/
protected function schedule_next_instance( ActionScheduler_Action $action ) {
$schedule = $action->get_schedule();
$next = $schedule->next( as_get_datetime_object() );
if ( ! is_null( $next ) && $schedule->is_recurring() ) {
$this->store->save_action( $action, $next );
}
}
/**
* Run the queue cleaner.
*
* @author Jeremy Pry
*/
protected function run_cleanup() {
$this->cleaner->clean( 10 * $this->get_time_limit() );
}
/**
* Get the number of concurrent batches a runner allows.
*
* @return int
*/
public function get_allowed_concurrent_batches() {
return apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 5 );
}
/**
* Get the maximum number of seconds a batch can run for.
*
* @return int The number of seconds.
*/
protected function get_time_limit() {
$time_limit = 30;
// Apply deprecated filter from deprecated get_maximum_execution_time() method
if ( has_filter( 'action_scheduler_maximum_execution_time' ) ) {
_deprecated_function( 'action_scheduler_maximum_execution_time', '2.1.1', 'action_scheduler_queue_runner_time_limit' );
$time_limit = apply_filters( 'action_scheduler_maximum_execution_time', $time_limit );
}
return absint( apply_filters( 'action_scheduler_queue_runner_time_limit', $time_limit ) );
}
/**
* Get the number of seconds the process has been running.
*
* @return int The number of seconds.
*/
protected function get_execution_time() {
$execution_time = microtime( true ) - $this->created_time;
// Get the CPU time if the hosting environment uses it rather than wall-clock time to calculate a process's execution time.
if ( function_exists( 'getrusage' ) && apply_filters( 'action_scheduler_use_cpu_execution_time', defined( 'PANTHEON_ENVIRONMENT' ) ) ) {
$resource_usages = getrusage();
if ( isset( $resource_usages['ru_stime.tv_usec'], $resource_usages['ru_stime.tv_usec'] ) ) {
$execution_time = $resource_usages['ru_stime.tv_sec'] + ( $resource_usages['ru_stime.tv_usec'] / 1000000 );
}
}
return $execution_time;
}
/**
* Check if the host's max execution time is (likely) to be exceeded if processing more actions.
*
* @param int $processed_actions The number of actions processed so far - used to determine the likelihood of exceeding the time limit if processing another action
* @return bool
*/
protected function time_likely_to_be_exceeded( $processed_actions ) {
$execution_time = $this->get_execution_time();
$max_execution_time = $this->get_time_limit();
$time_per_action = $execution_time / $processed_actions;
$estimated_time = $execution_time + ( $time_per_action * 3 );
$likely_to_be_exceeded = $estimated_time > $max_execution_time;
return apply_filters( 'action_scheduler_maximum_execution_time_likely_to_be_exceeded', $likely_to_be_exceeded, $this, $processed_actions, $execution_time, $max_execution_time );
}
/**
* Get memory limit
*
* Based on WP_Background_Process::get_memory_limit()
*
* @return int
*/
protected function get_memory_limit() {
if ( function_exists( 'ini_get' ) ) {
$memory_limit = ini_get( 'memory_limit' );
} else {
$memory_limit = '128M'; // Sensible default, and minimum required by WooCommerce
}
if ( ! $memory_limit || -1 === $memory_limit || '-1' === $memory_limit ) {
// Unlimited, set to 32GB.
$memory_limit = '32G';
}
return ActionScheduler_Compatibility::convert_hr_to_bytes( $memory_limit );
}
/**
* Memory exceeded
*
* Ensures the batch process never exceeds 90% of the maximum WordPress memory.
*
* Based on WP_Background_Process::memory_exceeded()
*
* @return bool
*/
protected function memory_exceeded() {
$memory_limit = $this->get_memory_limit() * 0.90;
$current_memory = memory_get_usage( true );
$memory_exceeded = $current_memory >= $memory_limit;
return apply_filters( 'action_scheduler_memory_exceeded', $memory_exceeded, $this );
}
/**
* See if the batch limits have been exceeded, which is when memory usage is almost at
* the maximum limit, or the time to process more actions will exceed the max time limit.
*
* Based on WC_Background_Process::batch_limits_exceeded()
*
* @param int $processed_actions The number of actions processed so far - used to determine the likelihood of exceeding the time limit if processing another action
* @return bool
*/
protected function batch_limits_exceeded( $processed_actions ) {
return $this->memory_exceeded() || $this->time_likely_to_be_exceeded( $processed_actions );
}
/**
* Process actions in the queue.
*
* @author Jeremy Pry
* @return int The number of actions processed.
*/
abstract public function run();
}

View File

@@ -0,0 +1,75 @@
<?php
/**
* Class ActionScheduler_Action
*/
class ActionScheduler_Action {
protected $hook = '';
protected $args = array();
/** @var ActionScheduler_Schedule */
protected $schedule = NULL;
protected $group = '';
public function __construct( $hook, array $args = array(), ActionScheduler_Schedule $schedule = NULL, $group = '' ) {
$schedule = empty( $schedule ) ? new ActionScheduler_NullSchedule() : $schedule;
$this->set_hook($hook);
$this->set_schedule($schedule);
$this->set_args($args);
$this->set_group($group);
}
public function execute() {
return do_action_ref_array($this->get_hook(), $this->get_args());
}
/**
* @param string $hook
*/
protected function set_hook( $hook ) {
$this->hook = $hook;
}
public function get_hook() {
return $this->hook;
}
protected function set_schedule( ActionScheduler_Schedule $schedule ) {
$this->schedule = $schedule;
}
/**
* @return ActionScheduler_Schedule
*/
public function get_schedule() {
return $this->schedule;
}
protected function set_args( array $args ) {
$this->args = $args;
}
public function get_args() {
return $this->args;
}
/**
* @param string $group
*/
protected function set_group( $group ) {
$this->group = $group;
}
/**
* @return string
*/
public function get_group() {
return $this->group;
}
/**
* @return bool If the action has been finished
*/
public function is_finished() {
return FALSE;
}
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* Class ActionScheduler_ActionClaim
*/
class ActionScheduler_ActionClaim {
private $id = '';
private $action_ids = array();
public function __construct( $id, array $action_ids ) {
$this->id = $id;
$this->action_ids = $action_ids;
}
public function get_id() {
return $this->id;
}
public function get_actions() {
return $this->action_ids;
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Class ActionScheduler_ActionFactory
*/
class ActionScheduler_ActionFactory {
/**
* @param string $status The action's status in the data store
* @param string $hook The hook to trigger when this action runs
* @param array $args Args to pass to callbacks when the hook is triggered
* @param ActionScheduler_Schedule $schedule The action's schedule
* @param string $group A group to put the action in
*
* @return ActionScheduler_Action An instance of the stored action
*/
public function get_stored_action( $status, $hook, array $args = array(), ActionScheduler_Schedule $schedule = null, $group = '' ) {
switch ( $status ) {
case ActionScheduler_Store::STATUS_PENDING :
$action_class = 'ActionScheduler_Action';
break;
case ActionScheduler_Store::STATUS_CANCELED :
$action_class = 'ActionScheduler_CanceledAction';
break;
default :
$action_class = 'ActionScheduler_FinishedAction';
break;
}
$action_class = apply_filters( 'action_scheduler_stored_action_class', $action_class, $status, $hook, $args, $schedule, $group );
$action = new $action_class( $hook, $args, $schedule, $group );
/**
* Allow 3rd party code to change the instantiated action for a given hook, args, schedule and group.
*
* @param ActionScheduler_Action $action The instantiated action.
* @param string $hook The instantiated action's hook.
* @param array $args The instantiated action's args.
* @param ActionScheduler_Schedule $schedule The instantiated action's schedule.
* @param string $group The instantiated action's group.
*/
return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group );
}
/**
* @param string $hook The hook to trigger when this action runs
* @param array $args Args to pass when the hook is triggered
* @param int $when Unix timestamp when the action will run
* @param string $group A group to put the action in
*
* @return string The ID of the stored action
*/
public function single( $hook, $args = array(), $when = null, $group = '' ) {
$date = as_get_datetime_object( $when );
$schedule = new ActionScheduler_SimpleSchedule( $date );
$action = new ActionScheduler_Action( $hook, $args, $schedule, $group );
return $this->store( $action );
}
/**
* @param string $hook The hook to trigger when this action runs
* @param array $args Args to pass when the hook is triggered
* @param int $first Unix timestamp for the first run
* @param int $interval Seconds between runs
* @param string $group A group to put the action in
*
* @return string The ID of the stored action
*/
public function recurring( $hook, $args = array(), $first = null, $interval = null, $group = '' ) {
if ( empty($interval) ) {
return $this->single( $hook, $args, $first, $group );
}
$date = as_get_datetime_object( $first );
$schedule = new ActionScheduler_IntervalSchedule( $date, $interval );
$action = new ActionScheduler_Action( $hook, $args, $schedule, $group );
return $this->store( $action );
}
/**
* @param string $hook The hook to trigger when this action runs
* @param array $args Args to pass when the hook is triggered
* @param int $first Unix timestamp for the first run
* @param int $schedule A cron definition string
* @param string $group A group to put the action in
*
* @return string The ID of the stored action
*/
public function cron( $hook, $args = array(), $first = null, $schedule = null, $group = '' ) {
if ( empty($schedule) ) {
return $this->single( $hook, $args, $first, $group );
}
$date = as_get_datetime_object( $first );
$cron = CronExpression::factory( $schedule );
$schedule = new ActionScheduler_CronSchedule( $date, $cron );
$action = new ActionScheduler_Action( $hook, $args, $schedule, $group );
return $this->store( $action );
}
/**
* @param ActionScheduler_Action $action
*
* @return string The ID of the stored action
*/
protected function store( ActionScheduler_Action $action ) {
$store = ActionScheduler_Store::instance();
return $store->save_action( $action );
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Class ActionScheduler_AdminView
* @codeCoverageIgnore
*/
class ActionScheduler_AdminView extends ActionScheduler_AdminView_Deprecated {
private static $admin_view = NULL;
/**
* @return ActionScheduler_QueueRunner
* @codeCoverageIgnore
*/
public static function instance() {
if ( empty( self::$admin_view ) ) {
$class = apply_filters('action_scheduler_admin_view_class', 'ActionScheduler_AdminView');
self::$admin_view = new $class();
}
return self::$admin_view;
}
/**
* @codeCoverageIgnore
*/
public function init() {
if ( is_admin() && ( ! defined( 'DOING_AJAX' ) || false == DOING_AJAX ) ) {
if ( class_exists( 'WooCommerce' ) ) {
add_action( 'woocommerce_admin_status_content_action-scheduler', array( $this, 'render_admin_ui' ) );
add_action( 'woocommerce_system_status_report', array( $this, 'system_status_report' ) );
add_filter( 'woocommerce_admin_status_tabs', array( $this, 'register_system_status_tab' ) );
}
add_action( 'admin_menu', array( $this, 'register_menu' ) );
}
}
public function system_status_report() {
$table = new ActionScheduler_wcSystemStatus( ActionScheduler::store() );
$table->render();
}
/**
* Registers action-scheduler into WooCommerce > System status.
*
* @param array $tabs An associative array of tab key => label.
* @return array $tabs An associative array of tab key => label, including Action Scheduler's tabs
*/
public function register_system_status_tab( array $tabs ) {
$tabs['action-scheduler'] = __( 'Scheduled Actions', 'woocommerce' );
return $tabs;
}
/**
* Include Action Scheduler's administration under the Tools menu.
*
* A menu under the Tools menu is important for backward compatibility (as that's
* where it started), and also provides more convenient access than the WooCommerce
* System Status page, and for sites where WooCommerce isn't active.
*/
public function register_menu() {
add_submenu_page(
'tools.php',
__( 'Scheduled Actions', 'woocommerce' ),
__( 'Scheduled Actions', 'woocommerce' ),
'manage_options',
'action-scheduler',
array( $this, 'render_admin_ui' )
);
}
/**
* Renders the Admin UI
*/
public function render_admin_ui() {
$table = new ActionScheduler_ListTable( ActionScheduler::store(), ActionScheduler::logger(), ActionScheduler::runner() );
$table->display_page();
}
}

View File

@@ -0,0 +1,21 @@
<?php
/**
* Class ActionScheduler_CanceledAction
*
* Stored action which was canceled and therefore acts like a finished action but should always return a null schedule,
* regardless of schedule passed to its constructor.
*/
class ActionScheduler_CanceledAction extends ActionScheduler_FinishedAction {
/**
* @param string $hook
* @param array $args
* @param ActionScheduler_Schedule $schedule
* @param string $group
*/
public function __construct( $hook, array $args = array(), ActionScheduler_Schedule $schedule = null, $group = '' ) {
parent::__construct( $hook, $args, $schedule, $group );
$this->set_schedule( new ActionScheduler_NullSchedule() );
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Class ActionScheduler_Compatibility
*/
class ActionScheduler_Compatibility {
/**
* Converts a shorthand byte value to an integer byte value.
*
* Wrapper for wp_convert_hr_to_bytes(), moved to load.php in WordPress 4.6 from media.php
*
* @link https://secure.php.net/manual/en/function.ini-get.php
* @link https://secure.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
*
* @param string $value A (PHP ini) byte value, either shorthand or ordinary.
* @return int An integer byte value.
*/
public static function convert_hr_to_bytes( $value ) {
if ( function_exists( 'wp_convert_hr_to_bytes' ) ) {
return wp_convert_hr_to_bytes( $value );
}
$value = strtolower( trim( $value ) );
$bytes = (int) $value;
if ( false !== strpos( $value, 'g' ) ) {
$bytes *= GB_IN_BYTES;
} elseif ( false !== strpos( $value, 'm' ) ) {
$bytes *= MB_IN_BYTES;
} elseif ( false !== strpos( $value, 'k' ) ) {
$bytes *= KB_IN_BYTES;
}
// Deal with large (float) values which run into the maximum integer size.
return min( $bytes, PHP_INT_MAX );
}
/**
* Attempts to raise the PHP memory limit for memory intensive processes.
*
* Only allows raising the existing limit and prevents lowering it.
*
* Wrapper for wp_raise_memory_limit(), added in WordPress v4.6.0
*
* @return bool|int|string The limit that was set or false on failure.
*/
public static function raise_memory_limit() {
if ( function_exists( 'wp_raise_memory_limit' ) ) {
return wp_raise_memory_limit( 'admin' );
}
$current_limit = @ini_get( 'memory_limit' );
$current_limit_int = self::convert_hr_to_bytes( $current_limit );
if ( -1 === $current_limit_int ) {
return false;
}
$wp_max_limit = WP_MAX_MEMORY_LIMIT;
$wp_max_limit_int = self::convert_hr_to_bytes( $wp_max_limit );
$filtered_limit = apply_filters( 'admin_memory_limit', $wp_max_limit );
$filtered_limit_int = self::convert_hr_to_bytes( $filtered_limit );
if ( -1 === $filtered_limit_int || ( $filtered_limit_int > $wp_max_limit_int && $filtered_limit_int > $current_limit_int ) ) {
if ( false !== @ini_set( 'memory_limit', $filtered_limit ) ) {
return $filtered_limit;
} else {
return false;
}
} elseif ( -1 === $wp_max_limit_int || $wp_max_limit_int > $current_limit_int ) {
if ( false !== @ini_set( 'memory_limit', $wp_max_limit ) ) {
return $wp_max_limit;
} else {
return false;
}
}
return false;
}
/**
* Attempts to raise the PHP timeout for time intensive processes.
*
* Only allows raising the existing limit and prevents lowering it. Wrapper for wc_set_time_limit(), when available.
*
* @param int The time limit in seconds.
*/
public static function raise_time_limit( $limit = 0 ) {
if ( $limit < ini_get( 'max_execution_time' ) ) {
return;
}
if ( function_exists( 'wc_set_time_limit' ) ) {
wc_set_time_limit( $limit );
} elseif ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) {
@set_time_limit( $limit );
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Class ActionScheduler_CronSchedule
*/
class ActionScheduler_CronSchedule implements ActionScheduler_Schedule {
/** @var DateTime */
private $start = NULL;
private $start_timestamp = 0;
/** @var CronExpression */
private $cron = NULL;
public function __construct( DateTime $start, CronExpression $cron ) {
$this->start = $start;
$this->cron = $cron;
}
/**
* @param DateTime $after
* @return DateTime|null
*/
public function next( DateTime $after = NULL ) {
$after = empty($after) ? clone $this->start : clone $after;
return $this->cron->getNextRunDate($after, 0, false);
}
/**
* @return bool
*/
public function is_recurring() {
return true;
}
/**
* @return string
*/
public function get_recurrence() {
return strval($this->cron);
}
/**
* For PHP 5.2 compat, since DateTime objects can't be serialized
* @return array
*/
public function __sleep() {
$this->start_timestamp = $this->start->getTimestamp();
return array(
'start_timestamp',
'cron'
);
}
public function __wakeup() {
$this->start = as_get_datetime_object($this->start_timestamp);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/**
* ActionScheduler DateTime class.
*
* This is a custom extension to DateTime that
*/
class ActionScheduler_DateTime extends DateTime {
/**
* UTC offset.
*
* Only used when a timezone is not set. When a timezone string is
* used, this will be set to 0.
*
* @var int
*/
protected $utcOffset = 0;
/**
* Get the unix timestamp of the current object.
*
* Missing in PHP 5.2 so just here so it can be supported consistently.
*
* @return int
*/
public function getTimestamp() {
return method_exists( 'DateTime', 'getTimestamp' ) ? parent::getTimestamp() : $this->format( 'U' );
}
/**
* Set the UTC offset.
*
* This represents a fixed offset instead of a timezone setting.
*
* @param $offset
*/
public function setUtcOffset( $offset ) {
$this->utcOffset = intval( $offset );
}
/**
* Returns the timezone offset.
*
* @return int
* @link http://php.net/manual/en/datetime.getoffset.php
*/
public function getOffset() {
return $this->utcOffset ? $this->utcOffset : parent::getOffset();
}
/**
* Set the TimeZone associated with the DateTime
*
* @param DateTimeZone $timezone
*
* @return static
* @link http://php.net/manual/en/datetime.settimezone.php
*/
public function setTimezone( $timezone ) {
$this->utcOffset = 0;
parent::setTimezone( $timezone );
return $this;
}
/**
* Get the timestamp with the WordPress timezone offset added or subtracted.
*
* @since 3.0.0
* @return int
*/
public function getOffsetTimestamp() {
return $this->getTimestamp() + $this->getOffset();
}
}

View File

@@ -0,0 +1,11 @@
<?php
/**
* ActionScheduler Exception Interface.
*
* Facilitates catching Exceptions unique to Action Scheduler.
*
* @package Prospress\ActionScheduler
* @since %VERSION%
*/
interface ActionScheduler_Exception {}

View File

@@ -0,0 +1,55 @@
<?php
/**
* Class ActionScheduler_FatalErrorMonitor
*/
class ActionScheduler_FatalErrorMonitor {
/** @var ActionScheduler_ActionClaim */
private $claim = NULL;
/** @var ActionScheduler_Store */
private $store = NULL;
private $action_id = 0;
public function __construct( ActionScheduler_Store $store ) {
$this->store = $store;
}
public function attach( ActionScheduler_ActionClaim $claim ) {
$this->claim = $claim;
add_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) );
add_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0, 1 );
add_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0, 0 );
add_action( 'action_scheduler_execution_ignored', array( $this, 'untrack_action' ), 0, 0 );
add_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0, 0 );
}
public function detach() {
$this->claim = NULL;
$this->untrack_action();
remove_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) );
remove_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0 );
remove_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0 );
remove_action( 'action_scheduler_execution_ignored', array( $this, 'untrack_action' ), 0 );
remove_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0 );
}
public function track_current_action( $action_id ) {
$this->action_id = $action_id;
}
public function untrack_action() {
$this->action_id = 0;
}
public function handle_unexpected_shutdown() {
if ( $error = error_get_last() ) {
if ( in_array( $error['type'], array( E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ) ) ) {
if ( !empty($this->action_id) ) {
$this->store->mark_failure( $this->action_id );
do_action( 'action_scheduler_unexpected_shutdown', $this->action_id, $error );
}
}
$this->store->release_claim( $this->claim );
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
/**
* Class ActionScheduler_FinishedAction
*/
class ActionScheduler_FinishedAction extends ActionScheduler_Action {
public function execute() {
// don't execute
}
public function is_finished() {
return TRUE;
}
}

View File

@@ -0,0 +1,60 @@
<?php
/**
* Class ActionScheduler_IntervalSchedule
*/
class ActionScheduler_IntervalSchedule implements ActionScheduler_Schedule {
/** @var DateTime */
private $start = NULL;
private $start_timestamp = 0;
private $interval_in_seconds = 0;
public function __construct( DateTime $start, $interval ) {
$this->start = $start;
$this->interval_in_seconds = (int)$interval;
}
/**
* @param DateTime $after
*
* @return DateTime|null
*/
public function next( DateTime $after = NULL ) {
$after = empty($after) ? as_get_datetime_object('@0') : clone $after;
if ( $after > $this->start ) {
$after->modify('+'.$this->interval_in_seconds.' seconds');
return $after;
}
return clone $this->start;
}
/**
* @return bool
*/
public function is_recurring() {
return true;
}
/**
* @return int
*/
public function interval_in_seconds() {
return $this->interval_in_seconds;
}
/**
* For PHP 5.2 compat, since DateTime objects can't be serialized
* @return array
*/
public function __sleep() {
$this->start_timestamp = $this->start->getTimestamp();
return array(
'start_timestamp',
'interval_in_seconds'
);
}
public function __wakeup() {
$this->start = as_get_datetime_object($this->start_timestamp);
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* InvalidAction Exception.
*
* Used for identifying actions that are invalid in some way.
*
* @package Prospress\ActionScheduler
*/
class ActionScheduler_InvalidActionException extends \InvalidArgumentException implements ActionScheduler_Exception {
/**
* Create a new exception when the action's args cannot be decoded to an array.
*
* @author Jeremy Pry
*
* @param string $action_id The action ID with bad args.
* @return static
*/
public static function from_decoding_args( $action_id ) {
$message = sprintf(
__( 'Action [%s] has invalid arguments. It cannot be JSON decoded to an array.', 'woocommerce' ),
$action_id
);
return new static( $message );
}
}

View File

@@ -0,0 +1,533 @@
<?php
/**
* Implements the admin view of the actions.
* @codeCoverageIgnore
*/
class ActionScheduler_ListTable extends ActionScheduler_Abstract_ListTable {
/**
* The package name.
*
* @var string
*/
protected $package = 'action-scheduler';
/**
* Columns to show (name => label).
*
* @var array
*/
protected $columns = array();
/**
* Actions (name => label).
*
* @var array
*/
protected $row_actions = array();
/**
* The active data stores
*
* @var ActionScheduler_Store
*/
protected $store;
/**
* A logger to use for getting action logs to display
*
* @var ActionScheduler_Logger
*/
protected $logger;
/**
* A ActionScheduler_QueueRunner runner instance (or child class)
*
* @var ActionScheduler_QueueRunner
*/
protected $runner;
/**
* Bulk actions. The key of the array is the method name of the implementation:
*
* bulk_<key>(array $ids, string $sql_in).
*
* See the comments in the parent class for further details
*
* @var array
*/
protected $bulk_actions = array();
/**
* Flag variable to render our notifications, if any, once.
*
* @var bool
*/
protected static $did_notification = false;
/**
* Array of seconds for common time periods, like week or month, alongside an internationalised string representation, i.e. "Day" or "Days"
*
* @var array
*/
private static $time_periods;
/**
* Sets the current data store object into `store->action` and initialises the object.
*
* @param ActionScheduler_Store $store
* @param ActionScheduler_Logger $logger
* @param ActionScheduler_QueueRunner $runner
*/
public function __construct( ActionScheduler_Store $store, ActionScheduler_Logger $logger, ActionScheduler_QueueRunner $runner ) {
$this->store = $store;
$this->logger = $logger;
$this->runner = $runner;
$this->table_header = __( 'Scheduled Actions', 'woocommerce' );
$this->bulk_actions = array(
'delete' => __( 'Delete', 'woocommerce' ),
);
$this->columns = array(
'hook' => __( 'Hook', 'woocommerce' ),
'status' => __( 'Status', 'woocommerce' ),
'args' => __( 'Arguments', 'woocommerce' ),
'group' => __( 'Group', 'woocommerce' ),
'recurrence' => __( 'Recurrence', 'woocommerce' ),
'schedule' => __( 'Scheduled Date', 'woocommerce' ),
'log_entries' => __( 'Log', 'woocommerce' ),
);
$this->sort_by = array(
'schedule',
'hook',
'group',
);
$this->search_by = array(
'hook',
'args',
'claim_id',
);
$request_status = $this->get_request_status();
if ( empty( $request_status ) ) {
$this->sort_by[] = 'status';
} elseif ( in_array( $request_status, array( 'in-progress', 'failed' ) ) ) {
$this->columns += array( 'claim_id' => __( 'Claim ID', 'woocommerce' ) );
$this->sort_by[] = 'claim_id';
}
$this->row_actions = array(
'hook' => array(
'run' => array(
'name' => __( 'Run', 'woocommerce' ),
'desc' => __( 'Process the action now as if it were run as part of a queue', 'woocommerce' ),
),
'cancel' => array(
'name' => __( 'Cancel', 'woocommerce' ),
'desc' => __( 'Cancel the action now to avoid it being run in future', 'woocommerce' ),
'class' => 'cancel trash',
),
),
);
self::$time_periods = array(
array(
'seconds' => YEAR_IN_SECONDS,
'names' => _n_noop( '%s year', '%s years', 'woocommerce' ),
),
array(
'seconds' => MONTH_IN_SECONDS,
'names' => _n_noop( '%s month', '%s months', 'woocommerce' ),
),
array(
'seconds' => WEEK_IN_SECONDS,
'names' => _n_noop( '%s week', '%s weeks', 'woocommerce' ),
),
array(
'seconds' => DAY_IN_SECONDS,
'names' => _n_noop( '%s day', '%s days', 'woocommerce' ),
),
array(
'seconds' => HOUR_IN_SECONDS,
'names' => _n_noop( '%s hour', '%s hours', 'woocommerce' ),
),
array(
'seconds' => MINUTE_IN_SECONDS,
'names' => _n_noop( '%s minute', '%s minutes', 'woocommerce' ),
),
array(
'seconds' => 1,
'names' => _n_noop( '%s second', '%s seconds', 'woocommerce' ),
),
);
parent::__construct( array(
'singular' => 'action-scheduler',
'plural' => 'action-scheduler',
'ajax' => false,
) );
}
/**
* Convert an interval of seconds into a two part human friendly string.
*
* The WordPress human_time_diff() function only calculates the time difference to one degree, meaning
* even if an action is 1 day and 11 hours away, it will display "1 day". This function goes one step
* further to display two degrees of accuracy.
*
* Inspired by the Crontrol::interval() function by Edward Dale: https://wordpress.org/plugins/wp-crontrol/
*
* @param int $interval A interval in seconds.
* @param int $periods_to_include Depth of time periods to include, e.g. for an interval of 70, and $periods_to_include of 2, both minutes and seconds would be included. With a value of 1, only minutes would be included.
* @return string A human friendly string representation of the interval.
*/
private static function human_interval( $interval, $periods_to_include = 2 ) {
if ( $interval <= 0 ) {
return __( 'Now!', 'woocommerce' );
}
$output = '';
for ( $time_period_index = 0, $periods_included = 0, $seconds_remaining = $interval; $time_period_index < count( self::$time_periods ) && $seconds_remaining > 0 && $periods_included < $periods_to_include; $time_period_index++ ) {
$periods_in_interval = floor( $seconds_remaining / self::$time_periods[ $time_period_index ]['seconds'] );
if ( $periods_in_interval > 0 ) {
if ( ! empty( $output ) ) {
$output .= ' ';
}
$output .= sprintf( _n( self::$time_periods[ $time_period_index ]['names'][0], self::$time_periods[ $time_period_index ]['names'][1], $periods_in_interval, 'woocommerce' ), $periods_in_interval );
$seconds_remaining -= $periods_in_interval * self::$time_periods[ $time_period_index ]['seconds'];
$periods_included++;
}
}
return $output;
}
/**
* Returns the recurrence of an action or 'Non-repeating'. The output is human readable.
*
* @param ActionScheduler_Action $action
*
* @return string
*/
protected function get_recurrence( $action ) {
$recurrence = $action->get_schedule();
if ( $recurrence->is_recurring() ) {
if ( method_exists( $recurrence, 'interval_in_seconds' ) ) {
return sprintf( __( 'Every %s', 'woocommerce' ), self::human_interval( $recurrence->interval_in_seconds() ) );
}
if ( method_exists( $recurrence, 'get_recurrence' ) ) {
return sprintf( __( 'Cron %s', 'woocommerce' ), $recurrence->get_recurrence() );
}
}
return __( 'Non-repeating', 'woocommerce' );
}
/**
* Serializes the argument of an action to render it in a human friendly format.
*
* @param array $row The array representation of the current row of the table
*
* @return string
*/
public function column_args( array $row ) {
if ( empty( $row['args'] ) ) {
return '';
}
$row_html = '<ul>';
foreach ( $row['args'] as $key => $value ) {
$row_html .= sprintf( '<li><code>%s => %s</code></li>', esc_html( var_export( $key, true ) ), esc_html( var_export( $value, true ) ) );
}
$row_html .= '</ul>';
return apply_filters( 'action_scheduler_list_table_column_args', $row_html, $row );
}
/**
* Prints the logs entries inline. We do so to avoid loading Javascript and other hacks to show it in a modal.
*
* @param array $row Action array.
* @return string
*/
public function column_log_entries( array $row ) {
$log_entries_html = '<ol>';
$timezone = new DateTimezone( 'UTC' );
foreach ( $row['log_entries'] as $log_entry ) {
$log_entries_html .= $this->get_log_entry_html( $log_entry, $timezone );
}
$log_entries_html .= '</ol>';
return $log_entries_html;
}
/**
* Prints the logs entries inline. We do so to avoid loading Javascript and other hacks to show it in a modal.
*
* @param ActionScheduler_LogEntry $log_entry
* @param DateTimezone $timezone
* @return string
*/
protected function get_log_entry_html( ActionScheduler_LogEntry $log_entry, DateTimezone $timezone ) {
$date = $log_entry->get_date();
$date->setTimezone( $timezone );
return sprintf( '<li><strong>%s</strong><br/>%s</li>', esc_html( $date->format( 'Y-m-d H:i:s O' ) ), esc_html( $log_entry->get_message() ) );
}
/**
* Only display row actions for pending actions.
*
* @param array $row Row to render
* @param string $column_name Current row
*
* @return string
*/
protected function maybe_render_actions( $row, $column_name ) {
if ( 'pending' === strtolower( $row['status'] ) ) {
return parent::maybe_render_actions( $row, $column_name );
}
return '';
}
/**
* Renders admin notifications
*
* Notifications:
* 1. When the maximum number of tasks are being executed simultaneously
* 2. Notifications when a task us manually executed
*/
public function display_admin_notices() {
if ( $this->store->get_claim_count() >= $this->runner->get_allowed_concurrent_batches() ) {
$this->admin_notices[] = array(
'class' => 'updated',
'message' => sprintf( __( 'Maximum simultaneous batches already in progress (%s queues). No actions will be processed until the current batches are complete.', 'woocommerce' ), $this->store->get_claim_count() ),
);
}
$notification = get_transient( 'action_scheduler_admin_notice' );
if ( is_array( $notification ) ) {
delete_transient( 'action_scheduler_admin_notice' );
$action = $this->store->fetch_action( $notification['action_id'] );
$action_hook_html = '<strong><code>' . $action->get_hook() . '</code></strong>';
if ( 1 == $notification['success'] ) {
$class = 'updated';
switch ( $notification['row_action_type'] ) {
case 'run' :
$action_message_html = sprintf( __( 'Successfully executed action: %s', 'woocommerce' ), $action_hook_html );
break;
case 'cancel' :
$action_message_html = sprintf( __( 'Successfully canceled action: %s', 'woocommerce' ), $action_hook_html );
break;
default :
$action_message_html = sprintf( __( 'Successfully processed change for action: %s', 'woocommerce' ), $action_hook_html );
break;
}
} else {
$class = 'error';
$action_message_html = sprintf( __( 'Could not process change for action: "%s" (ID: %d). Error: %s', 'woocommerce' ), $action_hook_html, esc_html( $notification['action_id'] ), esc_html( $notification['error_message'] ) );
}
$action_message_html = apply_filters( 'action_scheduler_admin_notice_html', $action_message_html, $action, $notification );
$this->admin_notices[] = array(
'class' => $class,
'message' => $action_message_html,
);
}
parent::display_admin_notices();
}
/**
* Prints the scheduled date in a human friendly format.
*
* @param array $row The array representation of the current row of the table
*
* @return string
*/
public function column_schedule( $row ) {
return $this->get_schedule_display_string( $row['schedule'] );
}
/**
* Get the scheduled date in a human friendly format.
*
* @param ActionScheduler_Schedule $schedule
* @return string
*/
protected function get_schedule_display_string( ActionScheduler_Schedule $schedule ) {
$schedule_display_string = '';
if ( ! $schedule->next() ) {
return $schedule_display_string;
}
$next_timestamp = $schedule->next()->getTimestamp();
$schedule_display_string .= $schedule->next()->format( 'Y-m-d H:i:s O' );
$schedule_display_string .= '<br/>';
if ( gmdate( 'U' ) > $next_timestamp ) {
$schedule_display_string .= sprintf( __( ' (%s ago)', 'woocommerce' ), self::human_interval( gmdate( 'U' ) - $next_timestamp ) );
} else {
$schedule_display_string .= sprintf( __( ' (%s)', 'woocommerce' ), self::human_interval( $next_timestamp - gmdate( 'U' ) ) );
}
return $schedule_display_string;
}
/**
* Bulk delete
*
* Deletes actions based on their ID. This is the handler for the bulk delete. It assumes the data
* properly validated by the callee and it will delete the actions without any extra validation.
*
* @param array $ids
* @param string $ids_sql Inherited and unused
*/
protected function bulk_delete( array $ids, $ids_sql ) {
foreach ( $ids as $id ) {
$this->store->delete_action( $id );
}
}
/**
* Implements the logic behind running an action. ActionScheduler_Abstract_ListTable validates the request and their
* parameters are valid.
*
* @param int $action_id
*/
protected function row_action_cancel( $action_id ) {
$this->process_row_action( $action_id, 'cancel' );
}
/**
* Implements the logic behind running an action. ActionScheduler_Abstract_ListTable validates the request and their
* parameters are valid.
*
* @param int $action_id
*/
protected function row_action_run( $action_id ) {
$this->process_row_action( $action_id, 'run' );
}
/**
* Implements the logic behind processing an action once an action link is clicked on the list table.
*
* @param int $action_id
* @param string $row_action_type The type of action to perform on the action.
*/
protected function process_row_action( $action_id, $row_action_type ) {
try {
switch ( $row_action_type ) {
case 'run' :
$this->runner->process_action( $action_id );
break;
case 'cancel' :
$this->store->cancel_action( $action_id );
break;
}
$success = 1;
$error_message = '';
} catch ( Exception $e ) {
$success = 0;
$error_message = $e->getMessage();
}
set_transient( 'action_scheduler_admin_notice', compact( 'action_id', 'success', 'error_message', 'row_action_type' ), 30 );
}
/**
* {@inheritDoc}
*/
public function prepare_items() {
$this->process_bulk_action();
$this->process_row_actions();
if ( ! empty( $_REQUEST['_wp_http_referer'] ) ) {
// _wp_http_referer is used only on bulk actions, we remove it to keep the $_GET shorter
wp_redirect( remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
exit;
}
$this->prepare_column_headers();
$per_page = $this->get_items_per_page( $this->package . '_items_per_page', $this->items_per_page );
$query = array(
'per_page' => $per_page,
'offset' => $this->get_items_offset(),
'status' => $this->get_request_status(),
'orderby' => $this->get_request_orderby(),
'order' => $this->get_request_order(),
'search' => $this->get_request_search_query(),
);
$this->items = array();
$total_items = $this->store->query_actions( $query, 'count' );
$status_labels = $this->store->get_status_labels();
foreach ( $this->store->query_actions( $query ) as $action_id ) {
try {
$action = $this->store->fetch_action( $action_id );
} catch ( Exception $e ) {
continue;
}
$this->items[ $action_id ] = array(
'ID' => $action_id,
'hook' => $action->get_hook(),
'status' => $status_labels[ $this->store->get_status( $action_id ) ],
'args' => $action->get_args(),
'group' => $action->get_group(),
'log_entries' => $this->logger->get_logs( $action_id ),
'claim_id' => $this->store->get_claim_id( $action_id ),
'recurrence' => $this->get_recurrence( $action ),
'schedule' => $action->get_schedule(),
);
}
$this->set_pagination_args( array(
'total_items' => $total_items,
'per_page' => $per_page,
'total_pages' => ceil( $total_items / $per_page ),
) );
}
/**
* Prints the available statuses so the user can click to filter.
*/
protected function display_filter_by_status() {
$this->status_counts = $this->store->action_counts();
parent::display_filter_by_status();
}
/**
* Get the text to display in the search box on the list table.
*/
protected function get_search_box_button_text() {
return __( 'Search hook, args and claim ID', 'woocommerce' );
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* Class ActionScheduler_LogEntry
*/
class ActionScheduler_LogEntry {
/**
* @var int $action_id
*/
protected $action_id = '';
/**
* @var string $message
*/
protected $message = '';
/**
* @var Datetime $date
*/
protected $date;
/**
* Constructor
*
* @param mixed $action_id Action ID
* @param string $message Message
* @param Datetime $date Datetime object with the time when this log entry was created. If this parameter is
* not provided a new Datetime object (with current time) will be created.
*/
public function __construct( $action_id, $message, $date = null ) {
/*
* ActionScheduler_wpCommentLogger::get_entry() previously passed a 3rd param of $comment->comment_type
* to ActionScheduler_LogEntry::__construct(), goodness knows why, and the Follow-up Emails plugin
* hard-codes loading its own version of ActionScheduler_wpCommentLogger with that out-dated method,
* goodness knows why, so we need to guard against that here instead of using a DateTime type declaration
* for the constructor's 3rd param of $date and causing a fatal error with older versions of FUE.
*/
if ( null !== $date && ! is_a( $date, 'DateTime' ) ) {
_doing_it_wrong( __METHOD__, 'The third parameter must be a valid DateTime instance, or null.', '2.0.0' );
$date = null;
}
$this->action_id = $action_id;
$this->message = $message;
$this->date = $date ? $date : new Datetime;
}
/**
* Returns the date when this log entry was created
*
* @return Datetime
*/
public function get_date() {
return $this->date;
}
public function get_action_id() {
return $this->action_id;
}
public function get_message() {
return $this->message;
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Class ActionScheduler_Logger
* @codeCoverageIgnore
*/
abstract class ActionScheduler_Logger {
private static $logger = NULL;
/**
* @return ActionScheduler_Logger
*/
public static function instance() {
if ( empty(self::$logger) ) {
$class = apply_filters('action_scheduler_logger_class', 'ActionScheduler_wpCommentLogger');
self::$logger = new $class();
}
return self::$logger;
}
/**
* @param string $action_id
* @param string $message
* @param DateTime $date
*
* @return string The log entry ID
*/
abstract public function log( $action_id, $message, DateTime $date = NULL );
/**
* @param string $entry_id
*
* @return ActionScheduler_LogEntry
*/
abstract public function get_entry( $entry_id );
/**
* @param string $action_id
*
* @return ActionScheduler_LogEntry[]
*/
abstract public function get_logs( $action_id );
/**
* @codeCoverageIgnore
*/
public function init() {
add_action( 'action_scheduler_stored_action', array( $this, 'log_stored_action' ), 10, 1 );
add_action( 'action_scheduler_canceled_action', array( $this, 'log_canceled_action' ), 10, 1 );
add_action( 'action_scheduler_before_execute', array( $this, 'log_started_action' ), 10, 1 );
add_action( 'action_scheduler_after_execute', array( $this, 'log_completed_action' ), 10, 1 );
add_action( 'action_scheduler_failed_execution', array( $this, 'log_failed_action' ), 10, 2 );
add_action( 'action_scheduler_failed_action', array( $this, 'log_timed_out_action' ), 10, 2 );
add_action( 'action_scheduler_unexpected_shutdown', array( $this, 'log_unexpected_shutdown' ), 10, 2 );
add_action( 'action_scheduler_reset_action', array( $this, 'log_reset_action' ), 10, 1 );
add_action( 'action_scheduler_execution_ignored', array( $this, 'log_ignored_action' ), 10, 1 );
add_action( 'action_scheduler_failed_fetch_action', array( $this, 'log_failed_fetch_action' ), 10, 1 );
}
public function log_stored_action( $action_id ) {
$this->log( $action_id, __( 'action created', 'woocommerce' ) );
}
public function log_canceled_action( $action_id ) {
$this->log( $action_id, __( 'action canceled', 'woocommerce' ) );
}
public function log_started_action( $action_id ) {
$this->log( $action_id, __( 'action started', 'woocommerce' ) );
}
public function log_completed_action( $action_id ) {
$this->log( $action_id, __( 'action complete', 'woocommerce' ) );
}
public function log_failed_action( $action_id, Exception $exception ) {
$this->log( $action_id, sprintf( __( 'action failed: %s', 'woocommerce' ), $exception->getMessage() ) );
}
public function log_timed_out_action( $action_id, $timeout ) {
$this->log( $action_id, sprintf( __( 'action timed out after %s seconds', 'woocommerce' ), $timeout ) );
}
public function log_unexpected_shutdown( $action_id, $error ) {
if ( ! empty( $error ) ) {
$this->log( $action_id, sprintf( __( 'unexpected shutdown: PHP Fatal error %s in %s on line %s', 'woocommerce' ), $error['message'], $error['file'], $error['line'] ) );
}
}
public function log_reset_action( $action_id ) {
$this->log( $action_id, __( 'action reset', 'woocommerce' ) );
}
public function log_ignored_action( $action_id ) {
$this->log( $action_id, __( 'action ignored', 'woocommerce' ) );
}
public function log_failed_fetch_action( $action_id ) {
$this->log( $action_id, __( 'There was a failure fetching this action', 'woocommerce' ) );
}
}

View File

@@ -0,0 +1,16 @@
<?php
/**
* Class ActionScheduler_NullAction
*/
class ActionScheduler_NullAction extends ActionScheduler_Action {
public function __construct( $hook = '', array $args = array(), ActionScheduler_Schedule $schedule = NULL ) {
$this->set_schedule( new ActionScheduler_NullSchedule() );
}
public function execute() {
// don't execute
}
}

View File

@@ -0,0 +1,11 @@
<?php
/**
* Class ActionScheduler_NullLogEntry
*/
class ActionScheduler_NullLogEntry extends ActionScheduler_LogEntry {
public function __construct( $action_id = '', $message = '' ) {
// nothing to see here
}
}

View File

@@ -0,0 +1,19 @@
<?php
/**
* Class ActionScheduler_NullSchedule
*/
class ActionScheduler_NullSchedule implements ActionScheduler_Schedule {
public function next( DateTime $after = NULL ) {
return NULL;
}
/**
* @return bool
*/
public function is_recurring() {
return false;
}
}

View File

@@ -0,0 +1,155 @@
<?php
/**
* Class ActionScheduler_QueueCleaner
*/
class ActionScheduler_QueueCleaner {
/** @var int */
protected $batch_size;
/** @var ActionScheduler_Store */
private $store = null;
/**
* 31 days in seconds.
*
* @var int
*/
private $month_in_seconds = 2678400;
/**
* ActionScheduler_QueueCleaner constructor.
*
* @param ActionScheduler_Store $store The store instance.
* @param int $batch_size The batch size.
*/
public function __construct( ActionScheduler_Store $store = null, $batch_size = 20 ) {
$this->store = $store ? $store : ActionScheduler_Store::instance();
$this->batch_size = $batch_size;
}
public function delete_old_actions() {
$lifespan = apply_filters( 'action_scheduler_retention_period', $this->month_in_seconds );
$cutoff = as_get_datetime_object($lifespan.' seconds ago');
$statuses_to_purge = array(
ActionScheduler_Store::STATUS_COMPLETE,
ActionScheduler_Store::STATUS_CANCELED,
);
foreach ( $statuses_to_purge as $status ) {
$actions_to_delete = $this->store->query_actions( array(
'status' => $status,
'modified' => $cutoff,
'modified_compare' => '<=',
'per_page' => $this->get_batch_size(),
) );
foreach ( $actions_to_delete as $action_id ) {
try {
$this->store->delete_action( $action_id );
} catch ( Exception $e ) {
/**
* Notify 3rd party code of exceptions when deleting a completed action older than the retention period
*
* This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their
* actions.
*
* @since 2.0.0
*
* @param int $action_id The scheduled actions ID in the data store
* @param Exception $e The exception thrown when attempting to delete the action from the data store
* @param int $lifespan The retention period, in seconds, for old actions
* @param int $count_of_actions_to_delete The number of old actions being deleted in this batch
*/
do_action( 'action_scheduler_failed_old_action_deletion', $action_id, $e, $lifespan, count( $actions_to_delete ) );
}
}
}
}
/**
* Unclaim pending actions that have not been run within a given time limit.
*
* When called by ActionScheduler_Abstract_QueueRunner::run_cleanup(), the time limit passed
* as a parameter is 10x the time limit used for queue processing.
*
* @param int $time_limit The number of seconds to allow a queue to run before unclaiming its pending actions. Default 300 (5 minutes).
*/
public function reset_timeouts( $time_limit = 300 ) {
$timeout = apply_filters( 'action_scheduler_timeout_period', $time_limit );
if ( $timeout < 0 ) {
return;
}
$cutoff = as_get_datetime_object($timeout.' seconds ago');
$actions_to_reset = $this->store->query_actions( array(
'status' => ActionScheduler_Store::STATUS_PENDING,
'modified' => $cutoff,
'modified_compare' => '<=',
'claimed' => true,
'per_page' => $this->get_batch_size(),
) );
foreach ( $actions_to_reset as $action_id ) {
$this->store->unclaim_action( $action_id );
do_action( 'action_scheduler_reset_action', $action_id );
}
}
/**
* Mark actions that have been running for more than a given time limit as failed, based on
* the assumption some uncatachable and unloggable fatal error occurred during processing.
*
* When called by ActionScheduler_Abstract_QueueRunner::run_cleanup(), the time limit passed
* as a parameter is 10x the time limit used for queue processing.
*
* @param int $time_limit The number of seconds to allow an action to run before it is considered to have failed. Default 300 (5 minutes).
*/
public function mark_failures( $time_limit = 300 ) {
$timeout = apply_filters( 'action_scheduler_failure_period', $time_limit );
if ( $timeout < 0 ) {
return;
}
$cutoff = as_get_datetime_object($timeout.' seconds ago');
$actions_to_reset = $this->store->query_actions( array(
'status' => ActionScheduler_Store::STATUS_RUNNING,
'modified' => $cutoff,
'modified_compare' => '<=',
'per_page' => $this->get_batch_size(),
) );
foreach ( $actions_to_reset as $action_id ) {
$this->store->mark_failure( $action_id );
do_action( 'action_scheduler_failed_action', $action_id, $timeout );
}
}
/**
* Do all of the cleaning actions.
*
* @param int $time_limit The number of seconds to use as the timeout and failure period. Default 300 (5 minutes).
* @author Jeremy Pry
*/
public function clean( $time_limit = 300 ) {
$this->delete_old_actions();
$this->reset_timeouts( $time_limit );
$this->mark_failures( $time_limit );
}
/**
* Get the batch size for cleaning the queue.
*
* @author Jeremy Pry
* @return int
*/
protected function get_batch_size() {
/**
* Filter the batch size when cleaning the queue.
*
* @param int $batch_size The number of actions to clean in one batch.
*/
return absint( apply_filters( 'action_scheduler_cleanup_batch_size', $this->batch_size ) );
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Class ActionScheduler_QueueRunner
*/
class ActionScheduler_QueueRunner extends ActionScheduler_Abstract_QueueRunner {
const WP_CRON_HOOK = 'action_scheduler_run_queue';
const WP_CRON_SCHEDULE = 'every_minute';
/** @var ActionScheduler_QueueRunner */
private static $runner = null;
/**
* @return ActionScheduler_QueueRunner
* @codeCoverageIgnore
*/
public static function instance() {
if ( empty(self::$runner) ) {
$class = apply_filters('action_scheduler_queue_runner_class', 'ActionScheduler_QueueRunner');
self::$runner = new $class();
}
return self::$runner;
}
/**
* ActionScheduler_QueueRunner constructor.
*
* @param ActionScheduler_Store $store
* @param ActionScheduler_FatalErrorMonitor $monitor
* @param ActionScheduler_QueueCleaner $cleaner
*/
public function __construct( ActionScheduler_Store $store = null, ActionScheduler_FatalErrorMonitor $monitor = null, ActionScheduler_QueueCleaner $cleaner = null ) {
parent::__construct( $store, $monitor, $cleaner );
}
/**
* @codeCoverageIgnore
*/
public function init() {
add_filter( 'cron_schedules', array( self::instance(), 'add_wp_cron_schedule' ) );
if ( !wp_next_scheduled(self::WP_CRON_HOOK) ) {
$schedule = apply_filters( 'action_scheduler_run_schedule', self::WP_CRON_SCHEDULE );
wp_schedule_event( time(), $schedule, self::WP_CRON_HOOK );
}
add_action( self::WP_CRON_HOOK, array( self::instance(), 'run' ) );
}
public function run() {
ActionScheduler_Compatibility::raise_memory_limit();
ActionScheduler_Compatibility::raise_time_limit( $this->get_time_limit() );
do_action( 'action_scheduler_before_process_queue' );
$this->run_cleanup();
$processed_actions = 0;
if ( $this->store->get_claim_count() < $this->get_allowed_concurrent_batches() ) {
$batch_size = apply_filters( 'action_scheduler_queue_runner_batch_size', 25 );
do {
$processed_actions_in_batch = $this->do_batch( $batch_size );
$processed_actions += $processed_actions_in_batch;
} while ( $processed_actions_in_batch > 0 && ! $this->batch_limits_exceeded( $processed_actions ) ); // keep going until we run out of actions, time, or memory
}
do_action( 'action_scheduler_after_process_queue' );
return $processed_actions;
}
protected function do_batch( $size = 100 ) {
$claim = $this->store->stake_claim($size);
$this->monitor->attach($claim);
$processed_actions = 0;
foreach ( $claim->get_actions() as $action_id ) {
// bail if we lost the claim
if ( ! in_array( $action_id, $this->store->find_actions_by_claim_id( $claim->get_id() ) ) ) {
break;
}
$this->process_action( $action_id );
$processed_actions++;
if ( $this->batch_limits_exceeded( $processed_actions ) ) {
break;
}
}
$this->store->release_claim($claim);
$this->monitor->detach();
$this->clear_caches();
return $processed_actions;
}
/**
* Running large batches can eat up memory, as WP adds data to its object cache.
*
* If using a persistent object store, this has the side effect of flushing that
* as well, so this is disabled by default. To enable:
*
* add_filter( 'action_scheduler_queue_runner_flush_cache', '__return_true' );
*/
protected function clear_caches() {
if ( ! wp_using_ext_object_cache() || apply_filters( 'action_scheduler_queue_runner_flush_cache', false ) ) {
wp_cache_flush();
}
}
public function add_wp_cron_schedule( $schedules ) {
$schedules['every_minute'] = array(
'interval' => 60, // in seconds
'display' => __( 'Every minute', 'woocommerce' ),
);
return $schedules;
}
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* Class ActionScheduler_Schedule
*/
interface ActionScheduler_Schedule {
/**
* @param DateTime $after
* @return DateTime|null
*/
public function next( DateTime $after = NULL );
/**
* @return bool
*/
public function is_recurring();
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* Class ActionScheduler_SimpleSchedule
*/
class ActionScheduler_SimpleSchedule implements ActionScheduler_Schedule {
private $date = NULL;
private $timestamp = 0;
public function __construct( DateTime $date ) {
$this->date = clone $date;
}
/**
* @param DateTime $after
*
* @return DateTime|null
*/
public function next( DateTime $after = NULL ) {
$after = empty($after) ? as_get_datetime_object('@0') : $after;
return ( $after > $this->date ) ? NULL : clone $this->date;
}
/**
* @return bool
*/
public function is_recurring() {
return false;
}
/**
* For PHP 5.2 compat, since DateTime objects can't be serialized
* @return array
*/
public function __sleep() {
$this->timestamp = $this->date->getTimestamp();
return array(
'timestamp',
);
}
public function __wakeup() {
$this->date = as_get_datetime_object($this->timestamp);
}
}

View File

@@ -0,0 +1,212 @@
<?php
/**
* Class ActionScheduler_Store
* @codeCoverageIgnore
*/
abstract class ActionScheduler_Store {
const STATUS_COMPLETE = 'complete';
const STATUS_PENDING = 'pending';
const STATUS_RUNNING = 'in-progress';
const STATUS_FAILED = 'failed';
const STATUS_CANCELED = 'canceled';
/** @var ActionScheduler_Store */
private static $store = NULL;
/**
* @param ActionScheduler_Action $action
* @param DateTime $scheduled_date Optional Date of the first instance
* to store. Otherwise uses the first date of the action's
* schedule.
*
* @return string The action ID
*/
abstract public function save_action( ActionScheduler_Action $action, DateTime $scheduled_date = NULL );
/**
* @param string $action_id
*
* @return ActionScheduler_Action
*/
abstract public function fetch_action( $action_id );
/**
* @param string $hook
* @param array $params
* @return string ID of the next action matching the criteria
*/
abstract public function find_action( $hook, $params = array() );
/**
* @param array $query
* @return array The IDs of actions matching the query
*/
abstract public function query_actions( $query = array() );
/**
* Get a count of all actions in the store, grouped by status
*
* @return array
*/
abstract public function action_counts();
/**
* @param string $action_id
*/
abstract public function cancel_action( $action_id );
/**
* @param string $action_id
*/
abstract public function delete_action( $action_id );
/**
* @param string $action_id
*
* @return DateTime The date the action is schedule to run, or the date that it ran.
*/
abstract public function get_date( $action_id );
/**
* @param int $max_actions
* @param DateTime $before_date Claim only actions schedule before the given date. Defaults to now.
* @param array $hooks Claim only actions with a hook or hooks.
* @param string $group Claim only actions in the given group.
*
* @return ActionScheduler_ActionClaim
*/
abstract public function stake_claim( $max_actions = 10, DateTime $before_date = null, $hooks = array(), $group = '' );
/**
* @return int
*/
abstract public function get_claim_count();
/**
* @param ActionScheduler_ActionClaim $claim
*/
abstract public function release_claim( ActionScheduler_ActionClaim $claim );
/**
* @param string $action_id
*/
abstract public function unclaim_action( $action_id );
/**
* @param string $action_id
*/
abstract public function mark_failure( $action_id );
/**
* @param string $action_id
*/
abstract public function log_execution( $action_id );
/**
* @param string $action_id
*/
abstract public function mark_complete( $action_id );
/**
* @param string $action_id
*
* @return string
*/
abstract public function get_status( $action_id );
/**
* @param string $action_id
* @return mixed
*/
abstract public function get_claim_id( $action_id );
/**
* @param string $claim_id
* @return array
*/
abstract public function find_actions_by_claim_id( $claim_id );
/**
* @param string $comparison_operator
* @return string
*/
protected function validate_sql_comparator( $comparison_operator ) {
if ( in_array( $comparison_operator, array('!=', '>', '>=', '<', '<=', '=') ) ) {
return $comparison_operator;
}
return '=';
}
/**
* Get the time MySQL formated date/time string for an action's (next) scheduled date.
*
* @param ActionScheduler_Action $action
* @param DateTime $scheduled_date (optional)
* @return string
*/
protected function get_scheduled_date_string( ActionScheduler_Action $action, DateTime $scheduled_date = NULL ) {
$next = null === $scheduled_date ? $action->get_schedule()->next() : $scheduled_date;
if ( ! $next ) {
throw new InvalidArgumentException( __( 'Invalid schedule. Cannot save action.', 'woocommerce' ) );
}
$next->setTimezone( new DateTimeZone( 'UTC' ) );
return $next->format( 'Y-m-d H:i:s' );
}
/**
* Get the time MySQL formated date/time string for an action's (next) scheduled date.
*
* @param ActionScheduler_Action $action
* @param DateTime $scheduled_date (optional)
* @return string
*/
protected function get_scheduled_date_string_local( ActionScheduler_Action $action, DateTime $scheduled_date = NULL ) {
$next = null === $scheduled_date ? $action->get_schedule()->next() : $scheduled_date;
if ( ! $next ) {
throw new InvalidArgumentException( __( 'Invalid schedule. Cannot save action.', 'woocommerce' ) );
}
ActionScheduler_TimezoneHelper::set_local_timezone( $next );
return $next->format( 'Y-m-d H:i:s' );
}
/**
* @return array
*/
public function get_status_labels() {
return array(
self::STATUS_COMPLETE => __( 'Complete', 'woocommerce' ),
self::STATUS_PENDING => __( 'Pending', 'woocommerce' ),
self::STATUS_RUNNING => __( 'In-progress', 'woocommerce' ),
self::STATUS_FAILED => __( 'Failed', 'woocommerce' ),
self::STATUS_CANCELED => __( 'Canceled', 'woocommerce' ),
);
}
public function init() {}
/**
* @return ActionScheduler_Store
*/
public static function instance() {
if ( empty(self::$store) ) {
$class = apply_filters('action_scheduler_store_class', 'ActionScheduler_wpPostStore');
self::$store = new $class();
}
return self::$store;
}
/**
* Get the site's local time.
*
* @deprecated 2.1.0
* @return DateTimeZone
*/
protected function get_local_timezone() {
_deprecated_function( __FUNCTION__, '2.1.0', 'ActionScheduler_TimezoneHelper::set_local_timezone()' );
return ActionScheduler_TimezoneHelper::get_local_timezone();
}
}

View File

@@ -0,0 +1,152 @@
<?php
/**
* Class ActionScheduler_TimezoneHelper
*/
abstract class ActionScheduler_TimezoneHelper {
private static $local_timezone = NULL;
/**
* Set a DateTime's timezone to the WordPress site's timezone, or a UTC offset
* if no timezone string is available.
*
* @since 2.1.0
*
* @param DateTime $date
* @return ActionScheduler_DateTime
*/
public static function set_local_timezone( DateTime $date ) {
// Accept a DateTime for easier backward compatibility, even though we require methods on ActionScheduler_DateTime
if ( ! is_a( $date, 'ActionScheduler_DateTime' ) ) {
$date = as_get_datetime_object( $date->format( 'U' ) );
}
if ( get_option( 'timezone_string' ) ) {
$date->setTimezone( new DateTimeZone( self::get_local_timezone_string() ) );
} else {
$date->setUtcOffset( self::get_local_timezone_offset() );
}
return $date;
}
/**
* Helper to retrieve the timezone string for a site until a WP core method exists
* (see https://core.trac.wordpress.org/ticket/24730).
*
* Adapted from wc_timezone_string() and https://secure.php.net/manual/en/function.timezone-name-from-abbr.php#89155.
*
* If no timezone string is set, and its not possible to match the UTC offset set for the site to a timezone
* string, then an empty string will be returned, and the UTC offset should be used to set a DateTime's
* timezone.
*
* @since 2.1.0
* @return string PHP timezone string for the site or empty if no timezone string is available.
*/
protected static function get_local_timezone_string( $reset = false ) {
// If site timezone string exists, return it.
$timezone = get_option( 'timezone_string' );
if ( $timezone ) {
return $timezone;
}
// Get UTC offset, if it isn't set then return UTC.
$utc_offset = intval( get_option( 'gmt_offset', 0 ) );
if ( 0 === $utc_offset ) {
return 'UTC';
}
// Adjust UTC offset from hours to seconds.
$utc_offset *= 3600;
// Attempt to guess the timezone string from the UTC offset.
$timezone = timezone_name_from_abbr( '', $utc_offset );
if ( $timezone ) {
return $timezone;
}
// Last try, guess timezone string manually.
foreach ( timezone_abbreviations_list() as $abbr ) {
foreach ( $abbr as $city ) {
if ( (bool) date( 'I' ) === (bool) $city['dst'] && $city['timezone_id'] && intval( $city['offset'] ) === $utc_offset ) {
return $city['timezone_id'];
}
}
}
// No timezone string
return '';
}
/**
* Get timezone offset in seconds.
*
* @since 2.1.0
* @return float
*/
protected static function get_local_timezone_offset() {
$timezone = get_option( 'timezone_string' );
if ( $timezone ) {
$timezone_object = new DateTimeZone( $timezone );
return $timezone_object->getOffset( new DateTime( 'now' ) );
} else {
return floatval( get_option( 'gmt_offset', 0 ) ) * HOUR_IN_SECONDS;
}
}
/**
* @deprecated 2.1.0
*/
public static function get_local_timezone( $reset = FALSE ) {
_deprecated_function( __FUNCTION__, '2.1.0', 'ActionScheduler_TimezoneHelper::set_local_timezone()' );
if ( $reset ) {
self::$local_timezone = NULL;
}
if ( !isset(self::$local_timezone) ) {
$tzstring = get_option('timezone_string');
if ( empty($tzstring) ) {
$gmt_offset = get_option('gmt_offset');
if ( $gmt_offset == 0 ) {
$tzstring = 'UTC';
} else {
$gmt_offset *= HOUR_IN_SECONDS;
$tzstring = timezone_name_from_abbr( '', $gmt_offset, 1 );
// If there's no timezone string, try again with no DST.
if ( false === $tzstring ) {
$tzstring = timezone_name_from_abbr( '', $gmt_offset, 0 );
}
// Try mapping to the first abbreviation we can find.
if ( false === $tzstring ) {
$is_dst = date( 'I' );
foreach ( timezone_abbreviations_list() as $abbr ) {
foreach ( $abbr as $city ) {
if ( $city['dst'] == $is_dst && $city['offset'] == $gmt_offset ) {
// If there's no valid timezone ID, keep looking.
if ( null === $city['timezone_id'] ) {
continue;
}
$tzstring = $city['timezone_id'];
break 2;
}
}
}
}
// If we still have no valid string, then fall back to UTC.
if ( false === $tzstring ) {
$tzstring = 'UTC';
}
}
}
self::$local_timezone = new DateTimeZone($tzstring);
}
return self::$local_timezone;
}
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* Class ActionScheduler_Versions
*/
class ActionScheduler_Versions {
/**
* @var ActionScheduler_Versions
*/
private static $instance = NULL;
private $versions = array();
public function register( $version_string, $initialization_callback ) {
if ( isset($this->versions[$version_string]) ) {
return FALSE;
}
$this->versions[$version_string] = $initialization_callback;
return TRUE;
}
public function get_versions() {
return $this->versions;
}
public function latest_version() {
$keys = array_keys($this->versions);
if ( empty($keys) ) {
return false;
}
uasort( $keys, 'version_compare' );
return end($keys);
}
public function latest_version_callback() {
$latest = $this->latest_version();
if ( empty($latest) || !isset($this->versions[$latest]) ) {
return '__return_null';
}
return $this->versions[$latest];
}
/**
* @return ActionScheduler_Versions
* @codeCoverageIgnore
*/
public static function instance() {
if ( empty(self::$instance) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* @codeCoverageIgnore
*/
public static function initialize_latest_version() {
$self = self::instance();
call_user_func($self->latest_version_callback());
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* WP CLI Queue runner.
*
* This class can only be called from within a WP CLI instance.
*/
class ActionScheduler_WPCLI_QueueRunner extends ActionScheduler_Abstract_QueueRunner {
/** @var array */
protected $actions;
/** @var ActionScheduler_ActionClaim */
protected $claim;
/** @var \cli\progress\Bar */
protected $progress_bar;
/**
* ActionScheduler_WPCLI_QueueRunner constructor.
*
* @param ActionScheduler_Store $store
* @param ActionScheduler_FatalErrorMonitor $monitor
* @param ActionScheduler_QueueCleaner $cleaner
*
* @throws Exception When this is not run within WP CLI
*/
public function __construct( ActionScheduler_Store $store = null, ActionScheduler_FatalErrorMonitor $monitor = null, ActionScheduler_QueueCleaner $cleaner = null ) {
if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {
/* translators: %s php class name */
throw new Exception( sprintf( __( 'The %s class can only be run within WP CLI.', 'woocommerce' ), __CLASS__ ) );
}
parent::__construct( $store, $monitor, $cleaner );
}
/**
* Set up the Queue before processing.
*
* @author Jeremy Pry
*
* @param int $batch_size The batch size to process.
* @param array $hooks The hooks being used to filter the actions claimed in this batch.
* @param string $group The group of actions to claim with this batch.
* @param bool $force Whether to force running even with too many concurrent processes.
*
* @return int The number of actions that will be run.
* @throws \WP_CLI\ExitException When there are too many concurrent batches.
*/
public function setup( $batch_size, $hooks = array(), $group = '', $force = false ) {
$this->run_cleanup();
$this->add_hooks();
// Check to make sure there aren't too many concurrent processes running.
$claim_count = $this->store->get_claim_count();
$too_many = $claim_count >= $this->get_allowed_concurrent_batches();
if ( $too_many ) {
if ( $force ) {
WP_CLI::warning( __( 'There are too many concurrent batches, but the run is forced to continue.', 'woocommerce' ) );
} else {
WP_CLI::error( __( 'There are too many concurrent batches.', 'woocommerce' ) );
}
}
// Stake a claim and store it.
$this->claim = $this->store->stake_claim( $batch_size, null, $hooks, $group );
$this->monitor->attach( $this->claim );
$this->actions = $this->claim->get_actions();
return count( $this->actions );
}
/**
* Add our hooks to the appropriate actions.
*
* @author Jeremy Pry
*/
protected function add_hooks() {
add_action( 'action_scheduler_before_execute', array( $this, 'before_execute' ) );
add_action( 'action_scheduler_after_execute', array( $this, 'after_execute' ), 10, 2 );
add_action( 'action_scheduler_failed_execution', array( $this, 'action_failed' ), 10, 2 );
}
/**
* Set up the WP CLI progress bar.
*
* @author Jeremy Pry
*/
protected function setup_progress_bar() {
$count = count( $this->actions );
$this->progress_bar = \WP_CLI\Utils\make_progress_bar(
sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'woocommerce' ), number_format_i18n( $count ) ),
$count
);
}
/**
* Process actions in the queue.
*
* @author Jeremy Pry
* @return int The number of actions processed.
*/
public function run() {
do_action( 'action_scheduler_before_process_queue' );
$this->setup_progress_bar();
foreach ( $this->actions as $action_id ) {
// Error if we lost the claim.
if ( ! in_array( $action_id, $this->store->find_actions_by_claim_id( $this->claim->get_id() ) ) ) {
WP_CLI::warning( __( 'The claim has been lost. Aborting current batch.', 'woocommerce' ) );
break;
}
$this->process_action( $action_id );
$this->progress_bar->tick();
$this->maybe_stop_the_insanity();
}
$completed = $this->progress_bar->current();
$this->progress_bar->finish();
$this->store->release_claim( $this->claim );
do_action( 'action_scheduler_after_process_queue' );
return $completed;
}
/**
* Handle WP CLI message when the action is starting.
*
* @author Jeremy Pry
*
* @param $action_id
*/
public function before_execute( $action_id ) {
/* translators: %s refers to the action ID */
WP_CLI::log( sprintf( __( 'Started processing action %s', 'woocommerce' ), $action_id ) );
}
/**
* Handle WP CLI message when the action has completed.
*
* @author Jeremy Pry
*
* @param int $action_id
* @param null|ActionScheduler_Action $action The instance of the action. Default to null for backward compatibility.
*/
public function after_execute( $action_id, $action = null ) {
// backward compatibility
if ( null === $action ) {
$action = $this->store->fetch_action( $action_id );
}
/* translators: %s refers to the action ID */
WP_CLI::log( sprintf( __( 'Completed processing action %s with hook: %s', 'woocommerce' ), $action_id, $action->get_hook() ) );
}
/**
* Handle WP CLI message when the action has failed.
*
* @author Jeremy Pry
*
* @param int $action_id
* @param Exception $exception
* @throws \WP_CLI\ExitException With failure message.
*/
public function action_failed( $action_id, $exception ) {
WP_CLI::error(
/* translators: %1$s refers to the action ID, %2$s refers to the Exception message */
sprintf( __( 'Error processing action %1$s: %2$s', 'woocommerce' ), $action_id, $exception->getMessage() ),
false
);
}
/**
* Sleep and help avoid hitting memory limit
*
* @param int $sleep_time Amount of seconds to sleep
*/
protected function stop_the_insanity( $sleep_time = 0 ) {
if ( 0 < $sleep_time ) {
/* translators: 1: sleep time 2: time unit */
WP_CLI::warning( sprintf( __( 'Stopped the insanity for %$1d %$2s', 'woocommerce' ), $sleep_time, _n( 'second', 'seconds', $sleep_time, 'woocommerce' ) ) );
sleep( $sleep_time );
}
WP_CLI::warning( __( 'Attempting to reduce used memory...', 'woocommerce' ) );
/**
* @var $wpdb \wpdb
* @var $wp_object_cache \WP_Object_Cache
*/
global $wpdb, $wp_object_cache;
$wpdb->queries = array();
if ( ! is_object( $wp_object_cache ) ) {
return;
}
$wp_object_cache->group_ops = array();
$wp_object_cache->stats = array();
$wp_object_cache->memcache_debug = array();
$wp_object_cache->cache = array();
if ( is_callable( array( $wp_object_cache, '__remoteset' ) ) ) {
call_user_func( array( $wp_object_cache, '__remoteset' ) ); // important
}
}
/**
* Maybe trigger the stop_the_insanity() method to free up memory.
*/
protected function maybe_stop_the_insanity() {
// The value returned by progress_bar->current() might be padded. Remove padding, and convert to int.
$current_iteration = intval( trim( $this->progress_bar->current() ) );
if ( 0 === $current_iteration % 50 ) {
$this->stop_the_insanity();
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Commands for the Action Scheduler by Prospress.
*/
class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
/**
* Run the Action Scheduler
*
* ## OPTIONS
*
* [--batch-size=<size>]
* : The maximum number of actions to run. Defaults to 100.
*
* [--batches=<size>]
* : Limit execution to a number of batches. Defaults to 0, meaning batches will continue being executed until all actions are complete.
*
* [--cleanup-batch-size=<size>]
* : The maximum number of actions to clean up. Defaults to the value of --batch-size.
*
* [--hooks=<hooks>]
* : Only run actions with the specified hook. Omitting this option runs actions with any hook. Define multiple hooks as a comma separated string (without spaces), e.g. `--hooks=hook_one,hook_two,hook_three`
*
* [--group=<group>]
* : Only run actions from the specified group. Omitting this option runs actions from all groups.
*
* [--force]
* : Whether to force execution despite the maximum number of concurrent processes being exceeded.
*
* @param array $args Positional arguments.
* @param array $assoc_args Keyed arguments.
* @throws \WP_CLI\ExitException When an error occurs.
*/
public function run( $args, $assoc_args ) {
// Handle passed arguments.
$batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) );
$batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) );
$clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) );
$hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) );
$hooks = array_filter( array_map( 'trim', $hooks ) );
$group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' );
$force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false );
$batches_completed = 0;
$actions_completed = 0;
$unlimited = $batches === 0;
try {
// Custom queue cleaner instance.
$cleaner = new ActionScheduler_QueueCleaner( null, $clean );
// Get the queue runner instance
$runner = new ActionScheduler_WPCLI_QueueRunner( null, null, $cleaner );
// Determine how many tasks will be run in the first batch.
$total = $runner->setup( $batch, $hooks, $group, $force );
// Run actions for as long as possible.
while ( $total > 0 ) {
$this->print_total_actions( $total );
$actions_completed += $runner->run();
$batches_completed++;
// Maybe set up tasks for the next batch.
$total = ( $unlimited || $batches_completed < $batches ) ? $runner->setup( $batch, $hooks, $group, $force ) : 0;
}
} catch ( Exception $e ) {
$this->print_error( $e );
}
$this->print_total_batches( $batches_completed );
$this->print_success( $actions_completed );
}
/**
* Print WP CLI message about how many actions are about to be processed.
*
* @author Jeremy Pry
*
* @param int $total
*/
protected function print_total_actions( $total ) {
WP_CLI::log(
sprintf(
/* translators: %d refers to how many scheduled taks were found to run */
_n( 'Found %d scheduled task', 'Found %d scheduled tasks', $total, 'woocommerce' ),
number_format_i18n( $total )
)
);
}
/**
* Print WP CLI message about how many batches of actions were processed.
*
* @author Jeremy Pry
*
* @param int $batches_completed
*/
protected function print_total_batches( $batches_completed ) {
WP_CLI::log(
sprintf(
/* translators: %d refers to the total number of batches executed */
_n( '%d batch executed.', '%d batches executed.', $batches_completed, 'woocommerce' ),
number_format_i18n( $batches_completed )
)
);
}
/**
* Convert an exception into a WP CLI error.
*
* @author Jeremy Pry
*
* @param Exception $e The error object.
*
* @throws \WP_CLI\ExitException
*/
protected function print_error( Exception $e ) {
WP_CLI::error(
sprintf(
/* translators: %s refers to the exception error message. */
__( 'There was an error running the action scheduler: %s', 'woocommerce' ),
$e->getMessage()
)
);
}
/**
* Print a success message with the number of completed actions.
*
* @author Jeremy Pry
*
* @param int $actions_completed
*/
protected function print_success( $actions_completed ) {
WP_CLI::success(
sprintf(
/* translators: %d refers to the total number of taskes completed */
_n( '%d scheduled task completed.', '%d scheduled tasks completed.', $actions_completed, 'woocommerce' ),
number_format_i18n( $actions_completed )
)
);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* Class ActionScheduler_wcSystemStatus
*/
class ActionScheduler_wcSystemStatus {
/**
* The active data stores
*
* @var ActionScheduler_Store
*/
protected $store;
function __construct( $store ) {
$this->store = $store;
}
/**
* Display action data, including number of actions grouped by status and the oldest & newest action in each status.
*
* Helpful to identify issues, like a clogged queue.
*/
public function render() {
$action_counts = $this->store->action_counts();
$status_labels = $this->store->get_status_labels();
$oldest_and_newest = $this->get_oldest_and_newest( array_keys( $status_labels ) );
$this->get_template( $status_labels, $action_counts, $oldest_and_newest );
}
/**
* Get oldest and newest scheduled dates for a given set of statuses.
*
* @param array $status_keys Set of statuses to find oldest & newest action for.
* @return array
*/
protected function get_oldest_and_newest( $status_keys ) {
$oldest_and_newest = array();
foreach ( $status_keys as $status ) {
$oldest_and_newest[ $status ] = array(
'oldest' => '&ndash;',
'newest' => '&ndash;',
);
if ( 'in-progress' === $status ) {
continue;
}
$oldest_and_newest[ $status ]['oldest'] = $this->get_action_status_date( $status, 'oldest' );
$oldest_and_newest[ $status ]['newest'] = $this->get_action_status_date( $status, 'newest' );
}
return $oldest_and_newest;
}
/**
* Get oldest or newest scheduled date for a given status.
*
* @param string $status Action status label/name string.
* @param string $date_type Oldest or Newest.
* @return DateTime
*/
protected function get_action_status_date( $status, $date_type = 'oldest' ) {
$order = 'oldest' === $date_type ? 'ASC' : 'DESC';
$action = $this->store->query_actions( array(
'claimed' => false,
'status' => $status,
'per_page' => 1,
'order' => $order,
) );
if ( ! empty( $action ) ) {
$date_object = $this->store->get_date( $action[0] );
$action_date = $date_object->format( 'Y-m-d H:i:s O' );
} else {
$action_date = '&ndash;';
}
return $action_date;
}
/**
* Get oldest or newest scheduled date for a given status.
*
* @param array $status_labels Set of statuses to find oldest & newest action for.
* @param array $action_counts Number of actions grouped by status.
* @param array $oldest_and_newest Date of the oldest and newest action with each status.
*/
protected function get_template( $status_labels, $action_counts, $oldest_and_newest ) {
?>
<table class="wc_status_table widefat" cellspacing="0">
<thead>
<tr>
<th colspan="5" data-export-label="Action Scheduler"><h2><?php esc_html_e( 'Action Scheduler', 'woocommerce' ); ?><?php echo wc_help_tip( esc_html__( 'This section shows scheduled action counts.', 'woocommerce' ) ); ?></h2></th>
</tr>
<tr>
<td><strong><?php esc_html_e( 'Action Status', 'woocommerce' ); ?></strong></td>
<td class="help">&nbsp;</td>
<td><strong><?php esc_html_e( 'Count', 'woocommerce' ); ?></strong></td>
<td><strong><?php esc_html_e( 'Oldest Scheduled Date', 'woocommerce' ); ?></strong></td>
<td><strong><?php esc_html_e( 'Newest Scheduled Date', 'woocommerce' ); ?></strong></td>
</tr>
</thead>
<tbody>
<?php
foreach ( $action_counts as $status => $count ) {
// WC uses the 3rd column for export, so we need to display more data in that (hidden when viewed as part of the table) and add an empty 2nd column.
printf(
'<tr><td>%1$s</td><td>&nbsp;</td><td>%2$s<span style="display: none;">, Oldest: %3$s, Newest: %4$s</span></td><td>%3$s</td><td>%4$s</td></tr>',
esc_html( $status_labels[ $status ] ),
number_format_i18n( $count ),
$oldest_and_newest[ $status ]['oldest'],
$oldest_and_newest[ $status ]['newest']
);
}
?>
</tbody>
</table>
<?php
}
/**
* is triggered when invoking inaccessible methods in an object context.
*
* @param $name string
* @param $arguments array
*
* @return mixed
* @link https://php.net/manual/en/language.oop5.overloading.php#language.oop5.overloading.methods
*/
public function __call( $name, $arguments ) {
switch ( $name ) {
case 'print':
_deprecated_function( __CLASS__ . '::print()', '2.2.4', __CLASS__ . '::render()' );
return call_user_func_array( array( $this, 'render' ), $arguments );
}
return null;
}
}

View File

@@ -0,0 +1,240 @@
<?php
/**
* Class ActionScheduler_wpCommentLogger
*/
class ActionScheduler_wpCommentLogger extends ActionScheduler_Logger {
const AGENT = 'ActionScheduler';
const TYPE = 'action_log';
/**
* @param string $action_id
* @param string $message
* @param DateTime $date
*
* @return string The log entry ID
*/
public function log( $action_id, $message, DateTime $date = NULL ) {
if ( empty($date) ) {
$date = as_get_datetime_object();
} else {
$date = as_get_datetime_object( clone $date );
}
$comment_id = $this->create_wp_comment( $action_id, $message, $date );
return $comment_id;
}
protected function create_wp_comment( $action_id, $message, DateTime $date ) {
$comment_date_gmt = $date->format('Y-m-d H:i:s');
ActionScheduler_TimezoneHelper::set_local_timezone( $date );
$comment_data = array(
'comment_post_ID' => $action_id,
'comment_date' => $date->format('Y-m-d H:i:s'),
'comment_date_gmt' => $comment_date_gmt,
'comment_author' => self::AGENT,
'comment_content' => $message,
'comment_agent' => self::AGENT,
'comment_type' => self::TYPE,
);
return wp_insert_comment($comment_data);
}
/**
* @param string $entry_id
*
* @return ActionScheduler_LogEntry
*/
public function get_entry( $entry_id ) {
$comment = $this->get_comment( $entry_id );
if ( empty($comment) || $comment->comment_type != self::TYPE ) {
return new ActionScheduler_NullLogEntry();
}
$date = as_get_datetime_object( $comment->comment_date_gmt );
ActionScheduler_TimezoneHelper::set_local_timezone( $date );
return new ActionScheduler_LogEntry( $comment->comment_post_ID, $comment->comment_content, $date );
}
/**
* @param string $action_id
*
* @return ActionScheduler_LogEntry[]
*/
public function get_logs( $action_id ) {
$status = 'all';
if ( get_post_status($action_id) == 'trash' ) {
$status = 'post-trashed';
}
$comments = get_comments(array(
'post_id' => $action_id,
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
'type' => self::TYPE,
'status' => $status,
));
$logs = array();
foreach ( $comments as $c ) {
$entry = $this->get_entry( $c );
if ( !empty($entry) ) {
$logs[] = $entry;
}
}
return $logs;
}
protected function get_comment( $comment_id ) {
return get_comment( $comment_id );
}
/**
* @param WP_Comment_Query $query
*/
public function filter_comment_queries( $query ) {
foreach ( array('ID', 'parent', 'post_author', 'post_name', 'post_parent', 'type', 'post_type', 'post_id', 'post_ID') as $key ) {
if ( !empty($query->query_vars[$key]) ) {
return; // don't slow down queries that wouldn't include action_log comments anyway
}
}
$query->query_vars['action_log_filter'] = TRUE;
add_filter( 'comments_clauses', array( $this, 'filter_comment_query_clauses' ), 10, 2 );
}
/**
* @param array $clauses
* @param WP_Comment_Query $query
*
* @return array
*/
public function filter_comment_query_clauses( $clauses, $query ) {
if ( !empty($query->query_vars['action_log_filter']) ) {
$clauses['where'] .= $this->get_where_clause();
}
return $clauses;
}
/**
* Make sure Action Scheduler logs are excluded from comment feeds, which use WP_Query, not
* the WP_Comment_Query class handled by @see self::filter_comment_queries().
*
* @param string $where
* @param WP_Query $query
*
* @return string
*/
public function filter_comment_feed( $where, $query ) {
if ( is_comment_feed() ) {
$where .= $this->get_where_clause();
}
return $where;
}
/**
* Return a SQL clause to exclude Action Scheduler comments.
*
* @return string
*/
protected function get_where_clause() {
global $wpdb;
return sprintf( " AND {$wpdb->comments}.comment_type != '%s'", self::TYPE );
}
/**
* Remove action log entries from wp_count_comments()
*
* @param array $stats
* @param int $post_id
*
* @return object
*/
public function filter_comment_count( $stats, $post_id ) {
global $wpdb;
if ( 0 === $post_id ) {
$stats = $this->get_comment_count();
}
return $stats;
}
/**
* Retrieve the comment counts from our cache, or the database if the cached version isn't set.
*
* @return object
*/
protected function get_comment_count() {
global $wpdb;
$stats = get_transient( 'as_comment_count' );
if ( ! $stats ) {
$stats = array();
$count = $wpdb->get_results( "SELECT comment_approved, COUNT( * ) AS num_comments FROM {$wpdb->comments} WHERE comment_type NOT IN('order_note','action_log') GROUP BY comment_approved", ARRAY_A );
$total = 0;
$stats = array();
$approved = array( '0' => 'moderated', '1' => 'approved', 'spam' => 'spam', 'trash' => 'trash', 'post-trashed' => 'post-trashed' );
foreach ( (array) $count as $row ) {
// Don't count post-trashed toward totals
if ( 'post-trashed' != $row['comment_approved'] && 'trash' != $row['comment_approved'] ) {
$total += $row['num_comments'];
}
if ( isset( $approved[ $row['comment_approved'] ] ) ) {
$stats[ $approved[ $row['comment_approved'] ] ] = $row['num_comments'];
}
}
$stats['total_comments'] = $total;
$stats['all'] = $total;
foreach ( $approved as $key ) {
if ( empty( $stats[ $key ] ) ) {
$stats[ $key ] = 0;
}
}
$stats = (object) $stats;
set_transient( 'as_comment_count', $stats );
}
return $stats;
}
/**
* Delete comment count cache whenever there is new comment or the status of a comment changes. Cache
* will be regenerated next time ActionScheduler_wpCommentLogger::filter_comment_count() is called.
*/
public function delete_comment_count_cache() {
delete_transient( 'as_comment_count' );
}
/**
* @codeCoverageIgnore
*/
public function init() {
add_action( 'action_scheduler_before_process_queue', array( $this, 'disable_comment_counting' ), 10, 0 );
add_action( 'action_scheduler_after_process_queue', array( $this, 'enable_comment_counting' ), 10, 0 );
parent::init();
add_action( 'pre_get_comments', array( $this, 'filter_comment_queries' ), 10, 1 );
add_action( 'wp_count_comments', array( $this, 'filter_comment_count' ), 20, 2 ); // run after WC_Comments::wp_count_comments() to make sure we exclude order notes and action logs
add_action( 'comment_feed_where', array( $this, 'filter_comment_feed' ), 10, 2 );
// Delete comments count cache whenever there is a new comment or a comment status changes
add_action( 'wp_insert_comment', array( $this, 'delete_comment_count_cache' ) );
add_action( 'wp_set_comment_status', array( $this, 'delete_comment_count_cache' ) );
}
public function disable_comment_counting() {
wp_defer_comment_counting(true);
}
public function enable_comment_counting() {
wp_defer_comment_counting(false);
}
}

View File

@@ -0,0 +1,821 @@
<?php
/**
* Class ActionScheduler_wpPostStore
*/
class ActionScheduler_wpPostStore extends ActionScheduler_Store {
const POST_TYPE = 'scheduled-action';
const GROUP_TAXONOMY = 'action-group';
const SCHEDULE_META_KEY = '_action_manager_schedule';
/** @var DateTimeZone */
protected $local_timezone = NULL;
/** @var int */
private static $max_index_length = 191;
public function save_action( ActionScheduler_Action $action, DateTime $scheduled_date = NULL ){
try {
$this->validate_action( $action );
$post_array = $this->create_post_array( $action, $scheduled_date );
$post_id = $this->save_post_array( $post_array );
$schedule = $action->get_schedule();
if ( ! is_null( $scheduled_date ) && $schedule->is_recurring() ) {
$schedule = new ActionScheduler_IntervalSchedule( $scheduled_date, $schedule->interval_in_seconds() );
}
$this->save_post_schedule( $post_id, $schedule );
$this->save_action_group( $post_id, $action->get_group() );
do_action( 'action_scheduler_stored_action', $post_id );
return $post_id;
} catch ( Exception $e ) {
throw new RuntimeException( sprintf( __('Error saving action: %s', 'woocommerce'), $e->getMessage() ), 0 );
}
}
protected function create_post_array( ActionScheduler_Action $action, DateTime $scheduled_date = NULL ) {
$post = array(
'post_type' => self::POST_TYPE,
'post_title' => $action->get_hook(),
'post_content' => json_encode($action->get_args()),
'post_status' => ( $action->is_finished() ? 'publish' : 'pending' ),
'post_date_gmt' => $this->get_scheduled_date_string( $action, $scheduled_date ),
'post_date' => $this->get_scheduled_date_string_local( $action, $scheduled_date ),
);
return $post;
}
protected function save_post_array( $post_array ) {
add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 );
add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
$post_id = wp_insert_post($post_array);
remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 );
remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
if ( is_wp_error($post_id) || empty($post_id) ) {
throw new RuntimeException(__('Unable to save action.', 'woocommerce'));
}
return $post_id;
}
public function filter_insert_post_data( $postdata ) {
if ( $postdata['post_type'] == self::POST_TYPE ) {
$postdata['post_author'] = 0;
if ( $postdata['post_status'] == 'future' ) {
$postdata['post_status'] = 'publish';
}
}
return $postdata;
}
/**
* Create a (probably unique) post name for scheduled actions in a more performant manner than wp_unique_post_slug().
*
* When an action's post status is transitioned to something other than 'draft', 'pending' or 'auto-draft, like 'publish'
* or 'failed' or 'trash', WordPress will find a unique slug (stored in post_name column) using the wp_unique_post_slug()
* function. This is done to ensure URL uniqueness. The approach taken by wp_unique_post_slug() is to iterate over existing
* post_name values that match, and append a number 1 greater than the largest. This makes sense when manually creating a
* post from the Edit Post screen. It becomes a bottleneck when automatically processing thousands of actions, with a
* database containing thousands of related post_name values.
*
* WordPress 5.1 introduces the 'pre_wp_unique_post_slug' filter for plugins to address this issue.
*
* We can short-circuit WordPress's wp_unique_post_slug() approach using the 'pre_wp_unique_post_slug' filter. This
* method is available to be used as a callback on that filter. It provides a more scalable approach to generating a
* post_name/slug that is probably unique. Because Action Scheduler never actually uses the post_name field, or an
* action's slug, being probably unique is good enough.
*
* For more backstory on this issue, see:
* - https://github.com/Prospress/action-scheduler/issues/44 and
* - https://core.trac.wordpress.org/ticket/21112
*
* @param string $override_slug Short-circuit return value.
* @param string $slug The desired slug (post_name).
* @param int $post_ID Post ID.
* @param string $post_status The post status.
* @param string $post_type Post type.
* @return string
*/
public function set_unique_post_slug( $override_slug, $slug, $post_ID, $post_status, $post_type ) {
if ( self::POST_TYPE == $post_type ) {
$override_slug = uniqid( self::POST_TYPE . '-', true ) . '-' . wp_generate_password( 32, false );
}
return $override_slug;
}
protected function save_post_schedule( $post_id, $schedule ) {
update_post_meta( $post_id, self::SCHEDULE_META_KEY, $schedule );
}
protected function save_action_group( $post_id, $group ) {
if ( empty($group) ) {
wp_set_object_terms( $post_id, array(), self::GROUP_TAXONOMY, FALSE );
} else {
wp_set_object_terms( $post_id, array($group), self::GROUP_TAXONOMY, FALSE );
}
}
public function fetch_action( $action_id ) {
$post = $this->get_post( $action_id );
if ( empty($post) || $post->post_type != self::POST_TYPE ) {
return $this->get_null_action();
}
return $this->make_action_from_post($post);
}
protected function get_post( $action_id ) {
if ( empty($action_id) ) {
return NULL;
}
return get_post($action_id);
}
protected function get_null_action() {
return new ActionScheduler_NullAction();
}
protected function make_action_from_post( $post ) {
$hook = $post->post_title;
try {
$args = json_decode( $post->post_content, true );
$this->validate_args( $args, $post->ID );
$schedule = get_post_meta( $post->ID, self::SCHEDULE_META_KEY, true );
if ( empty( $schedule ) || ! is_a( $schedule, 'ActionScheduler_Schedule' ) ) {
throw ActionScheduler_InvalidActionException::from_decoding_args( $post->ID );
}
} catch ( ActionScheduler_InvalidActionException $exception ) {
$schedule = new ActionScheduler_NullSchedule();
$args = array();
do_action( 'action_scheduler_failed_fetch_action', $post->ID );
}
$group = wp_get_object_terms( $post->ID, self::GROUP_TAXONOMY, array('fields' => 'names') );
$group = empty( $group ) ? '' : reset($group);
return ActionScheduler::factory()->get_stored_action( $this->get_action_status_by_post_status( $post->post_status ), $hook, $args, $schedule, $group );
}
/**
* @param string $post_status
*
* @throws InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels()
* @return string
*/
protected function get_action_status_by_post_status( $post_status ) {
switch ( $post_status ) {
case 'publish' :
$action_status = self::STATUS_COMPLETE;
break;
case 'trash' :
$action_status = self::STATUS_CANCELED;
break;
default :
if ( ! array_key_exists( $post_status, $this->get_status_labels() ) ) {
throw new InvalidArgumentException( sprintf( 'Invalid post status: "%s". No matching action status available.', $post_status ) );
}
$action_status = $post_status;
break;
}
return $action_status;
}
/**
* @param string $action_status
* @throws InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels()
* @return string
*/
protected function get_post_status_by_action_status( $action_status ) {
switch ( $action_status ) {
case self::STATUS_COMPLETE :
$post_status = 'publish';
break;
case self::STATUS_CANCELED :
$post_status = 'trash';
break;
default :
if ( ! array_key_exists( $action_status, $this->get_status_labels() ) ) {
throw new InvalidArgumentException( sprintf( 'Invalid action status: "%s".', $action_status ) );
}
$post_status = $action_status;
break;
}
return $post_status;
}
/**
* @param string $hook
* @param array $params
*
* @return string ID of the next action matching the criteria or NULL if not found
*/
public function find_action( $hook, $params = array() ) {
$params = wp_parse_args( $params, array(
'args' => NULL,
'status' => ActionScheduler_Store::STATUS_PENDING,
'group' => '',
));
/** @var wpdb $wpdb */
global $wpdb;
$query = "SELECT p.ID FROM {$wpdb->posts} p";
$args = array();
if ( !empty($params['group']) ) {
$query .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
$query .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
$query .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id AND t.slug=%s";
$args[] = $params['group'];
}
$query .= " WHERE p.post_title=%s";
$args[] = $hook;
$query .= " AND p.post_type=%s";
$args[] = self::POST_TYPE;
if ( !is_null($params['args']) ) {
$query .= " AND p.post_content=%s";
$args[] = json_encode($params['args']);
}
if ( ! empty( $params['status'] ) ) {
$query .= " AND p.post_status=%s";
$args[] = $this->get_post_status_by_action_status( $params['status'] );
}
switch ( $params['status'] ) {
case self::STATUS_COMPLETE:
case self::STATUS_RUNNING:
case self::STATUS_FAILED:
$order = 'DESC'; // Find the most recent action that matches
break;
case self::STATUS_PENDING:
default:
$order = 'ASC'; // Find the next action that matches
break;
}
$query .= " ORDER BY post_date_gmt $order LIMIT 1";
$query = $wpdb->prepare( $query, $args );
$id = $wpdb->get_var($query);
return $id;
}
/**
* Returns the SQL statement to query (or count) actions.
*
* @param array $query Filtering options
* @param string $select_or_count Whether the SQL should select and return the IDs or just the row count
* @throws InvalidArgumentException if $select_or_count not count or select
* @return string SQL statement. The returned SQL is already properly escaped.
*/
protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) {
if ( ! in_array( $select_or_count, array( 'select', 'count' ) ) ) {
throw new InvalidArgumentException(__('Invalid schedule. Cannot save action.', 'woocommerce'));
}
$query = wp_parse_args( $query, array(
'hook' => '',
'args' => NULL,
'date' => NULL,
'date_compare' => '<=',
'modified' => NULL,
'modified_compare' => '<=',
'group' => '',
'status' => '',
'claimed' => NULL,
'per_page' => 5,
'offset' => 0,
'orderby' => 'date',
'order' => 'ASC',
'search' => '',
) );
/** @var wpdb $wpdb */
global $wpdb;
$sql = ( 'count' === $select_or_count ) ? 'SELECT count(p.ID)' : 'SELECT p.ID ';
$sql .= "FROM {$wpdb->posts} p";
$sql_params = array();
if ( ! empty( $query['group'] ) || 'group' === $query['orderby'] ) {
$sql .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
$sql .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
$sql .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id";
if ( ! empty( $query['group'] ) ) {
$sql .= " AND t.slug=%s";
$sql_params[] = $query['group'];
}
}
$sql .= " WHERE post_type=%s";
$sql_params[] = self::POST_TYPE;
if ( $query['hook'] ) {
$sql .= " AND p.post_title=%s";
$sql_params[] = $query['hook'];
}
if ( !is_null($query['args']) ) {
$sql .= " AND p.post_content=%s";
$sql_params[] = json_encode($query['args']);
}
if ( ! empty( $query['status'] ) ) {
$sql .= " AND p.post_status=%s";
$sql_params[] = $this->get_post_status_by_action_status( $query['status'] );
}
if ( $query['date'] instanceof DateTime ) {
$date = clone $query['date'];
$date->setTimezone( new DateTimeZone('UTC') );
$date_string = $date->format('Y-m-d H:i:s');
$comparator = $this->validate_sql_comparator($query['date_compare']);
$sql .= " AND p.post_date_gmt $comparator %s";
$sql_params[] = $date_string;
}
if ( $query['modified'] instanceof DateTime ) {
$modified = clone $query['modified'];
$modified->setTimezone( new DateTimeZone('UTC') );
$date_string = $modified->format('Y-m-d H:i:s');
$comparator = $this->validate_sql_comparator($query['modified_compare']);
$sql .= " AND p.post_modified_gmt $comparator %s";
$sql_params[] = $date_string;
}
if ( $query['claimed'] === TRUE ) {
$sql .= " AND p.post_password != ''";
} elseif ( $query['claimed'] === FALSE ) {
$sql .= " AND p.post_password = ''";
} elseif ( !is_null($query['claimed']) ) {
$sql .= " AND p.post_password = %s";
$sql_params[] = $query['claimed'];
}
if ( ! empty( $query['search'] ) ) {
$sql .= " AND (p.post_title LIKE %s OR p.post_content LIKE %s OR p.post_password LIKE %s)";
for( $i = 0; $i < 3; $i++ ) {
$sql_params[] = sprintf( '%%%s%%', $query['search'] );
}
}
if ( 'select' === $select_or_count ) {
switch ( $query['orderby'] ) {
case 'hook':
$orderby = 'p.post_title';
break;
case 'group':
$orderby = 't.name';
break;
case 'status':
$orderby = 'p.post_status';
break;
case 'modified':
$orderby = 'p.post_modified';
break;
case 'claim_id':
$orderby = 'p.post_password';
break;
case 'schedule':
case 'date':
default:
$orderby = 'p.post_date_gmt';
break;
}
if ( 'ASC' === strtoupper( $query['order'] ) ) {
$order = 'ASC';
} else {
$order = 'DESC';
}
$sql .= " ORDER BY $orderby $order";
if ( $query['per_page'] > 0 ) {
$sql .= " LIMIT %d, %d";
$sql_params[] = $query['offset'];
$sql_params[] = $query['per_page'];
}
}
return $wpdb->prepare( $sql, $sql_params );
}
/**
* @param array $query
* @param string $query_type Whether to select or count the results. Default, select.
* @return string|array The IDs of actions matching the query
*/
public function query_actions( $query = array(), $query_type = 'select' ) {
/** @var wpdb $wpdb */
global $wpdb;
$sql = $this->get_query_actions_sql( $query, $query_type );
return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql );
}
/**
* Get a count of all actions in the store, grouped by status
*
* @return array
*/
public function action_counts() {
$action_counts_by_status = array();
$action_stati_and_labels = $this->get_status_labels();
$posts_count_by_status = (array) wp_count_posts( self::POST_TYPE, 'readable' );
foreach ( $posts_count_by_status as $post_status_name => $count ) {
try {
$action_status_name = $this->get_action_status_by_post_status( $post_status_name );
} catch ( Exception $e ) {
// Ignore any post statuses that aren't for actions
continue;
}
if ( array_key_exists( $action_status_name, $action_stati_and_labels ) ) {
$action_counts_by_status[ $action_status_name ] = $count;
}
}
return $action_counts_by_status;
}
/**
* @param string $action_id
*
* @throws InvalidArgumentException
*/
public function cancel_action( $action_id ) {
$post = get_post($action_id);
if ( empty($post) || ($post->post_type != self::POST_TYPE) ) {
throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'woocommerce'), $action_id));
}
do_action( 'action_scheduler_canceled_action', $action_id );
add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
wp_trash_post($action_id);
remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
}
public function delete_action( $action_id ) {
$post = get_post($action_id);
if ( empty($post) || ($post->post_type != self::POST_TYPE) ) {
throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'woocommerce'), $action_id));
}
do_action( 'action_scheduler_deleted_action', $action_id );
wp_delete_post($action_id, TRUE);
}
/**
* @param string $action_id
*
* @throws InvalidArgumentException
* @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
*/
public function get_date( $action_id ) {
$next = $this->get_date_gmt( $action_id );
return ActionScheduler_TimezoneHelper::set_local_timezone( $next );
}
/**
* @param string $action_id
*
* @throws InvalidArgumentException
* @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
*/
public function get_date_gmt( $action_id ) {
$post = get_post($action_id);
if ( empty($post) || ($post->post_type != self::POST_TYPE) ) {
throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'woocommerce'), $action_id));
}
if ( $post->post_status == 'publish' ) {
return as_get_datetime_object($post->post_modified_gmt);
} else {
return as_get_datetime_object($post->post_date_gmt);
}
}
/**
* @param int $max_actions
* @param DateTime $before_date Jobs must be schedule before this date. Defaults to now.
* @param array $hooks Claim only actions with a hook or hooks.
* @param string $group Claim only actions in the given group.
*
* @return ActionScheduler_ActionClaim
* @throws RuntimeException When there is an error staking a claim.
* @throws InvalidArgumentException When the given group is not valid.
*/
public function stake_claim( $max_actions = 10, DateTime $before_date = null, $hooks = array(), $group = '' ) {
$claim_id = $this->generate_claim_id();
$this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group );
$action_ids = $this->find_actions_by_claim_id( $claim_id );
return new ActionScheduler_ActionClaim( $claim_id, $action_ids );
}
/**
* @return int
*/
public function get_claim_count(){
global $wpdb;
$sql = "SELECT COUNT(DISTINCT post_password) FROM {$wpdb->posts} WHERE post_password != '' AND post_type = %s AND post_status IN ('in-progress','pending')";
$sql = $wpdb->prepare( $sql, array( self::POST_TYPE ) );
return $wpdb->get_var( $sql );
}
protected function generate_claim_id() {
$claim_id = md5(microtime(true) . rand(0,1000));
return substr($claim_id, 0, 20); // to fit in db field with 20 char limit
}
/**
* @param string $claim_id
* @param int $limit
* @param DateTime $before_date Should use UTC timezone.
* @param array $hooks Claim only actions with a hook or hooks.
* @param string $group Claim only actions in the given group.
*
* @return int The number of actions that were claimed
* @throws RuntimeException When there is a database error.
* @throws InvalidArgumentException When the group is invalid.
*/
protected function claim_actions( $claim_id, $limit, DateTime $before_date = null, $hooks = array(), $group = '' ) {
// Set up initial variables.
$date = null === $before_date ? as_get_datetime_object() : clone $before_date;
$limit_ids = ! empty( $group );
$ids = $limit_ids ? $this->get_actions_by_group( $group, $limit, $date ) : array();
// If limiting by IDs and no posts found, then return early since we have nothing to update.
if ( $limit_ids && 0 === count( $ids ) ) {
return 0;
}
/** @var wpdb $wpdb */
global $wpdb;
/*
* Build up custom query to update the affected posts. Parameters are built as a separate array
* to make it easier to identify where they are in the query.
*
* We can't use $wpdb->update() here because of the "ID IN ..." clause.
*/
$update = "UPDATE {$wpdb->posts} SET post_password = %s, post_modified_gmt = %s, post_modified = %s";
$params = array(
$claim_id,
current_time( 'mysql', true ),
current_time( 'mysql' ),
);
// Build initial WHERE clause.
$where = "WHERE post_type = %s AND post_status = %s AND post_password = ''";
$params[] = self::POST_TYPE;
$params[] = ActionScheduler_Store::STATUS_PENDING;
if ( ! empty( $hooks ) ) {
$placeholders = array_fill( 0, count( $hooks ), '%s' );
$where .= ' AND post_title IN (' . join( ', ', $placeholders ) . ')';
$params = array_merge( $params, array_values( $hooks ) );
}
/*
* Add the IDs to the WHERE clause. IDs not escaped because they came directly from a prior DB query.
*
* If we're not limiting by IDs, then include the post_date_gmt clause.
*/
if ( $limit_ids ) {
$where .= ' AND ID IN (' . join( ',', $ids ) . ')';
} else {
$where .= ' AND post_date_gmt <= %s';
$params[] = $date->format( 'Y-m-d H:i:s' );
}
// Add the ORDER BY clause and,ms limit.
$order = 'ORDER BY menu_order ASC, post_date_gmt ASC, ID ASC LIMIT %d';
$params[] = $limit;
// Run the query and gather results.
$rows_affected = $wpdb->query( $wpdb->prepare( "{$update} {$where} {$order}", $params ) );
if ( $rows_affected === false ) {
throw new RuntimeException( __( 'Unable to claim actions. Database error.', 'woocommerce' ) );
}
return (int) $rows_affected;
}
/**
* Get IDs of actions within a certain group and up to a certain date/time.
*
* @param string $group The group to use in finding actions.
* @param int $limit The number of actions to retrieve.
* @param DateTime $date DateTime object representing cutoff time for actions. Actions retrieved will be
* up to and including this DateTime.
*
* @return array IDs of actions in the appropriate group and before the appropriate time.
* @throws InvalidArgumentException When the group does not exist.
*/
protected function get_actions_by_group( $group, $limit, DateTime $date ) {
// Ensure the group exists before continuing.
if ( ! term_exists( $group, self::GROUP_TAXONOMY )) {
throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'woocommerce' ), $group ) );
}
// Set up a query for post IDs to use later.
$query = new WP_Query();
$query_args = array(
'fields' => 'ids',
'post_type' => self::POST_TYPE,
'post_status' => ActionScheduler_Store::STATUS_PENDING,
'has_password' => false,
'posts_per_page' => $limit * 3,
'suppress_filters' => true,
'no_found_rows' => true,
'orderby' => array(
'menu_order' => 'ASC',
'date' => 'ASC',
'ID' => 'ASC',
),
'date_query' => array(
'column' => 'post_date_gmt',
'before' => $date->format( 'Y-m-d H:i' ),
'inclusive' => true,
),
'tax_query' => array(
array(
'taxonomy' => self::GROUP_TAXONOMY,
'field' => 'slug',
'terms' => $group,
'include_children' => false,
),
),
);
return $query->query( $query_args );
}
/**
* @param string $claim_id
* @return array
*/
public function find_actions_by_claim_id( $claim_id ) {
/** @var wpdb $wpdb */
global $wpdb;
$sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s";
$sql = $wpdb->prepare( $sql, array( self::POST_TYPE, $claim_id ) );
$action_ids = $wpdb->get_col( $sql );
return $action_ids;
}
public function release_claim( ActionScheduler_ActionClaim $claim ) {
$action_ids = $this->find_actions_by_claim_id( $claim->get_id() );
if ( empty($action_ids) ) {
return; // nothing to do
}
$action_id_string = implode(',', array_map('intval', $action_ids));
/** @var wpdb $wpdb */
global $wpdb;
$sql = "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID IN ($action_id_string) AND post_password = %s";
$sql = $wpdb->prepare( $sql, array( $claim->get_id() ) );
$result = $wpdb->query($sql);
if ( $result === false ) {
throw new RuntimeException( sprintf( __('Unable to unlock claim %s. Database error.', 'woocommerce'), $claim->get_id() ) );
}
}
/**
* @param string $action_id
*/
public function unclaim_action( $action_id ) {
/** @var wpdb $wpdb */
global $wpdb;
$sql = "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID = %d AND post_type = %s";
$sql = $wpdb->prepare( $sql, $action_id, self::POST_TYPE );
$result = $wpdb->query($sql);
if ( $result === false ) {
throw new RuntimeException( sprintf( __('Unable to unlock claim on action %s. Database error.', 'woocommerce'), $action_id ) );
}
}
public function mark_failure( $action_id ) {
/** @var wpdb $wpdb */
global $wpdb;
$sql = "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type = %s";
$sql = $wpdb->prepare( $sql, self::STATUS_FAILED, $action_id, self::POST_TYPE );
$result = $wpdb->query($sql);
if ( $result === false ) {
throw new RuntimeException( sprintf( __('Unable to mark failure on action %s. Database error.', 'woocommerce'), $action_id ) );
}
}
/**
* Return an action's claim ID, as stored in the post password column
*
* @param string $action_id
* @return mixed
*/
public function get_claim_id( $action_id ) {
return $this->get_post_column( $action_id, 'post_password' );
}
/**
* Return an action's status, as stored in the post status column
*
* @param string $action_id
* @return mixed
*/
public function get_status( $action_id ) {
$status = $this->get_post_column( $action_id, 'post_status' );
if ( $status === null ) {
throw new InvalidArgumentException( __( 'Invalid action ID. No status found.', 'woocommerce' ) );
}
return $this->get_action_status_by_post_status( $status );
}
private function get_post_column( $action_id, $column_name ) {
/** @var \wpdb $wpdb */
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( "SELECT {$column_name} FROM {$wpdb->posts} WHERE ID=%d AND post_type=%s", $action_id, self::POST_TYPE ) );
}
/**
* @param string $action_id
*/
public function log_execution( $action_id ) {
/** @var wpdb $wpdb */
global $wpdb;
$sql = "UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s";
$sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time('mysql', true), current_time('mysql'), $action_id, self::POST_TYPE );
$wpdb->query($sql);
}
public function mark_complete( $action_id ) {
$post = get_post($action_id);
if ( empty($post) || ($post->post_type != self::POST_TYPE) ) {
throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'woocommerce'), $action_id));
}
add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 );
add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
$result = wp_update_post(array(
'ID' => $action_id,
'post_status' => 'publish',
), TRUE);
remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 );
remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
if ( is_wp_error($result) ) {
throw new RuntimeException($result->get_error_message());
}
}
/**
* InnoDB indexes have a maximum size of 767 bytes by default, which is only 191 characters with utf8mb4.
*
* Previously, AS wasn't concerned about args length, as we used the (unindex) post_content column. However,
* as we prepare to move to custom tables, and can use an indexed VARCHAR column instead, we want to warn
* developers of this impending requirement.
*
* @param ActionScheduler_Action $action
*/
protected function validate_action( ActionScheduler_Action $action ) {
if ( strlen( json_encode( $action->get_args() ) ) > self::$max_index_length ) {
_doing_it_wrong( 'ActionScheduler_Action::$args', sprintf( 'To ensure the action args column can be indexed, action args should not be more than %d characters when encoded as JSON. Support for strings longer than this will be removed in a future version.', self::$max_index_length ), '2.1.0' );
}
}
/**
* @codeCoverageIgnore
*/
public function init() {
$post_type_registrar = new ActionScheduler_wpPostStore_PostTypeRegistrar();
$post_type_registrar->register();
$post_status_registrar = new ActionScheduler_wpPostStore_PostStatusRegistrar();
$post_status_registrar->register();
$taxonomy_registrar = new ActionScheduler_wpPostStore_TaxonomyRegistrar();
$taxonomy_registrar->register();
}
/**
* Validate that we could decode action arguments.
*
* @param mixed $args The decoded arguments.
* @param int $action_id The action ID.
*
* @throws ActionScheduler_InvalidActionException When the decoded arguments are invalid.
*/
private function validate_args( $args, $action_id ) {
// Ensure we have an array of args.
if ( ! is_array( $args ) ) {
throw ActionScheduler_InvalidActionException::from_decoding_args( $action_id );
}
// Validate JSON decoding if possible.
if ( function_exists( 'json_last_error' ) && JSON_ERROR_NONE !== json_last_error() ) {
throw ActionScheduler_InvalidActionException::from_decoding_args( $action_id );
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* Class ActionScheduler_wpPostStore_PostStatusRegistrar
* @codeCoverageIgnore
*/
class ActionScheduler_wpPostStore_PostStatusRegistrar {
public function register() {
register_post_status( ActionScheduler_Store::STATUS_RUNNING, array_merge( $this->post_status_args(), $this->post_status_running_labels() ) );
register_post_status( ActionScheduler_Store::STATUS_FAILED, array_merge( $this->post_status_args(), $this->post_status_failed_labels() ) );
}
/**
* Build the args array for the post type definition
*
* @return array
*/
protected function post_status_args() {
$args = array(
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
);
return apply_filters( 'action_scheduler_post_status_args', $args );
}
/**
* Build the args array for the post type definition
*
* @return array
*/
protected function post_status_failed_labels() {
$labels = array(
'label' => _x( 'Failed', 'post', 'woocommerce' ),
'label_count' => _n_noop( 'Failed <span class="count">(%s)</span>', 'Failed <span class="count">(%s)</span>', 'woocommerce' ),
);
return apply_filters( 'action_scheduler_post_status_failed_labels', $labels );
}
/**
* Build the args array for the post type definition
*
* @return array
*/
protected function post_status_running_labels() {
$labels = array(
'label' => _x( 'In-Progress', 'post', 'woocommerce' ),
'label_count' => _n_noop( 'In-Progress <span class="count">(%s)</span>', 'In-Progress <span class="count">(%s)</span>', 'woocommerce' ),
);
return apply_filters( 'action_scheduler_post_status_running_labels', $labels );
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Class ActionScheduler_wpPostStore_PostTypeRegistrar
* @codeCoverageIgnore
*/
class ActionScheduler_wpPostStore_PostTypeRegistrar {
public function register() {
register_post_type( ActionScheduler_wpPostStore::POST_TYPE, $this->post_type_args() );
}
/**
* Build the args array for the post type definition
*
* @return array
*/
protected function post_type_args() {
$args = array(
'label' => __( 'Scheduled Actions', 'woocommerce' ),
'description' => __( 'Scheduled actions are hooks triggered on a cetain date and time.', 'woocommerce' ),
'public' => false,
'map_meta_cap' => true,
'hierarchical' => false,
'supports' => array('title', 'editor','comments'),
'rewrite' => false,
'query_var' => false,
'can_export' => true,
'ep_mask' => EP_NONE,
'labels' => array(
'name' => __( 'Scheduled Actions', 'woocommerce' ),
'singular_name' => __( 'Scheduled Action', 'woocommerce' ),
'menu_name' => _x( 'Scheduled Actions', 'Admin menu name', 'woocommerce' ),
'add_new' => __( 'Add', 'woocommerce' ),
'add_new_item' => __( 'Add New Scheduled Action', 'woocommerce' ),
'edit' => __( 'Edit', 'woocommerce' ),
'edit_item' => __( 'Edit Scheduled Action', 'woocommerce' ),
'new_item' => __( 'New Scheduled Action', 'woocommerce' ),
'view' => __( 'View Action', 'woocommerce' ),
'view_item' => __( 'View Action', 'woocommerce' ),
'search_items' => __( 'Search Scheduled Actions', 'woocommerce' ),
'not_found' => __( 'No actions found', 'woocommerce' ),
'not_found_in_trash' => __( 'No actions found in trash', 'woocommerce' ),
),
);
$args = apply_filters('action_scheduler_post_type_args', $args);
return $args;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* Class ActionScheduler_wpPostStore_TaxonomyRegistrar
* @codeCoverageIgnore
*/
class ActionScheduler_wpPostStore_TaxonomyRegistrar {
public function register() {
register_taxonomy( ActionScheduler_wpPostStore::GROUP_TAXONOMY, ActionScheduler_wpPostStore::POST_TYPE, $this->taxonomy_args() );
}
protected function taxonomy_args() {
$args = array(
'label' => __('Action Group', 'woocommerce'),
'public' => false,
'hierarchical' => false,
'show_admin_column' => true,
'query_var' => false,
'rewrite' => false,
);
$args = apply_filters('action_scheduler_taxonomy_args', $args);
return $args;
}
}