diff --git a/classes/ActionScheduler_AdminView.php b/classes/ActionScheduler_AdminView.php index 6f866547a..053e7dab0 100644 --- a/classes/ActionScheduler_AdminView.php +++ b/classes/ActionScheduler_AdminView.php @@ -260,6 +260,14 @@ public function add_help_tabs() { '

' . sprintf( __( 'About Action Scheduler %s', 'action-scheduler' ), $as_version ) . '

' . '

' . __( 'Action Scheduler is a scalable, traceable job queue for background processing large sets of actions. Action Scheduler works by triggering an action hook to run at some time in the future. Scheduled actions can also be scheduled to run on a recurring schedule.', 'action-scheduler' ) . + '

' . + '

' . esc_html__( 'WP CLI', 'action-scheduler' ) . '

' . + '

' . + sprintf( + /* translators: %1$s is WP CLI command (not translatable) */ + esc_html__( 'WP CLI commands are available: execute %1$s for a list of available commands.', 'action-scheduler' ), + 'wp help action-scheduler' + ) . '

', ) ); diff --git a/classes/WP_CLI/Action/Cancel_Command.php b/classes/WP_CLI/Action/Cancel_Command.php new file mode 100644 index 000000000..e9eb9f260 --- /dev/null +++ b/classes/WP_CLI/Action/Cancel_Command.php @@ -0,0 +1,120 @@ +assoc_args, 'group', '' ); + $callback_args = get_flag_value( $this->assoc_args, 'args', null ); + $all = get_flag_value( $this->assoc_args, 'all', false ); + + if ( ! empty( $this->args[0] ) ) { + $hook = $this->args[0]; + } + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + if ( $all ) { + $this->cancel_all( $hook, $callback_args, $group ); + return; + } + + $this->cancel_single( $hook, $callback_args, $group ); + } + + /** + * Cancel single action. + * + * @param string $hook The hook that the job will trigger. + * @param array $callback_args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * @return void + */ + protected function cancel_single( $hook, $callback_args, $group ) { + if ( empty( $hook ) ) { + \WP_CLI::error( __( 'Please specify hook of action to cancel.', 'action-scheduler' ) ); + } + + try { + $result = as_unschedule_action( $hook, $callback_args, $group ); + } catch ( \Exception $e ) { + $this->print_error( $e, false ); + } + + if ( null === $result ) { + $e = new \Exception( __( 'Unable to cancel scheduled action: check the logs.', 'action-scheduler' ) ); + $this->print_error( $e, false ); + } + + $this->print_success( false ); + } + + /** + * Cancel all actions. + * + * @param string $hook The hook that the job will trigger. + * @param array $callback_args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * @return void + */ + protected function cancel_all( $hook, $callback_args, $group ) { + if ( empty( $hook ) && empty( $group ) ) { + \WP_CLI::error( __( 'Please specify hook and/or group of actions to cancel.', 'action-scheduler' ) ); + } + + try { + $result = as_unschedule_all_actions( $hook, $callback_args, $group ); + } catch ( \Exception $e ) { + $this->print_error( $e, $multiple ); + } + + /** + * Because as_unschedule_all_actions() does not provide a result, + * neither confirm or deny actions cancelled. + */ + \WP_CLI::success( __( 'Request to cancel scheduled actions completed.', 'action-scheduler' ) ); + } + + /** + * Print a success message. + * + * @return void + */ + protected function print_success() { + \WP_CLI::success( __( 'Scheduled action cancelled.', 'action-scheduler' ) ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @param bool $multiple Boolean if multiple actions. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e, $multiple ) { + \WP_CLI::error( + sprintf( + /* translators: %1$s: singular or plural %2$s: refers to the exception error message. */ + __( 'There was an error cancelling the %1$s: %2$s', 'action-scheduler' ), + $multiple ? __( 'scheduled actions', 'action-scheduler' ) : __( 'scheduled action', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Create_Command.php b/classes/WP_CLI/Action/Create_Command.php new file mode 100644 index 000000000..fedd417e2 --- /dev/null +++ b/classes/WP_CLI/Action/Create_Command.php @@ -0,0 +1,151 @@ +args[0]; + $schedule_start = $this->args[1]; + $callback_args = get_flag_value( $this->assoc_args, 'args', array() ); + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $interval = absint( get_flag_value( $this->assoc_args, 'interval', 0 ) ); + $cron = get_flag_value( $this->assoc_args, 'cron', '' ); + $unique = get_flag_value( $this->assoc_args, 'unique', false ); + $priority = absint( get_flag_value( $this->assoc_args, 'priority', 10 ) ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + $function_args = array( + 'start' => $schedule_start, + 'cron' => $cron, + 'interval' => $interval, + 'hook' => $hook, + 'callback_args' => $callback_args, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ); + + try { + // Generate schedule start if appropriate. + if ( ! in_array( $schedule_start, static::ASYNC_OPTS, true ) ) { + $schedule_start = as_get_datetime_object( $schedule_start ); + $function_args['start'] = $schedule_start->format( 'U' ); + } + } catch ( \Exception $e ) { + \WP_CLI::error( $e->getMessage() ); + } + + // Default to creating single action. + $action_type = 'single'; + $function = 'as_schedule_single_action'; + + if ( ! empty( $interval ) ) { // Creating recurring action. + $action_type = 'recurring'; + $function = 'as_schedule_recurring_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'interval', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } elseif ( ! empty( $cron ) ) { // Creating cron action. + $action_type = 'cron'; + $function = 'as_schedule_cron_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'cron', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } elseif ( in_array( $function_args['start'], static::ASYNC_OPTS, true ) ) { // Enqueue async action. + $action_type = 'async'; + $function = 'as_enqueue_async_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } else { // Enqueue single action. + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } + + $function_args = array_values( $function_args ); + + try { + $action_id = call_user_func_array( $function, $function_args ); + } catch ( \Exception $e ) { + $this->print_error( $e ); + } + + if ( 0 === $action_id ) { + $e = new \Exception( __( 'Unable to create a scheduled action.', 'action-scheduler' ) ); + $this->print_error( $e ); + } + + $this->print_success( $action_id, $action_type ); + } + + /** + * Print a success message with the action ID. + * + * @param int $action_id Created action ID. + * @param string $action_type Type of action. + * + * @return void + */ + protected function print_success( $action_id, $action_type ) { + \WP_CLI::success( + sprintf( + /* translators: %1$s: type of action, %2$d: ID of the created action */ + __( '%1$s action (%2$d) scheduled.', 'action-scheduler' ), + ucfirst( $action_type ), + $action_id + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e ) { + \WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message. */ + __( 'There was an error creating the scheduled action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Delete_Command.php b/classes/WP_CLI/Action/Delete_Command.php new file mode 100644 index 000000000..a549e0b4e --- /dev/null +++ b/classes/WP_CLI/Action/Delete_Command.php @@ -0,0 +1,108 @@ + + */ + protected $action_counts = array( + 'deleted' => 0, + 'failed' => 0, + 'total' => 0, + ); + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + */ + public function __construct( array $args, array $assoc_args ) { + parent::__construct( $args, $assoc_args ); + + $this->action_ids = array_map( 'absint', $args ); + $this->action_counts['total'] = count( $this->action_ids ); + + add_action( 'action_scheduler_deleted_action', array( $this, 'on_action_deleted' ) ); + } + + /** + * Execute. + * + * @return void + */ + public function execute() { + $store = \ActionScheduler::store(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d: number of actions to be deleted */ + _n( 'Deleting %d action', 'Deleting %d actions', $this->action_counts['total'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['total'] ) + ), + $this->action_counts['total'] + ); + + foreach ( $this->action_ids as $action_id ) { + try { + $store->delete_action( $action_id ); + } catch ( \Exception $e ) { + $this->action_counts['failed']++; + \WP_CLI::warning( $e->getMessage() ); + } + + $progress_bar->tick(); + } + + $progress_bar->finish(); + + /* translators: %1$d: number of actions deleted */ + $format = _n( 'Deleted %1$d action', 'Deleted %1$d actions', $this->action_counts['deleted'], 'action-scheduler' ) . ', '; + /* translators: %2$d: number of actions deletions failed */ + $format .= _n( '%2$d failure.', '%2$d failures.', $this->action_counts['failed'], 'action-scheduler' ); + + \WP_CLI::success( + sprintf( + $format, + number_format_i18n( $this->action_counts['deleted'] ), + number_format_i18n( $this->action_counts['failed'] ) + ) + ); + } + + /** + * Action: action_scheduler_deleted_action + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_deleted( $action_id ) { + if ( 'action_scheduler_deleted_action' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['deleted']++; + \WP_CLI::debug( sprintf( 'Action %d was deleted.', $action_id ) ); + } + +} diff --git a/classes/WP_CLI/Action/Generate_Command.php b/classes/WP_CLI/Action/Generate_Command.php new file mode 100644 index 000000000..6e6e8c77b --- /dev/null +++ b/classes/WP_CLI/Action/Generate_Command.php @@ -0,0 +1,121 @@ +args[0]; + $schedule_start = $this->args[1]; + $callback_args = get_flag_value( $this->assoc_args, 'args', array() ); + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $interval = (int) get_flag_value( $this->assoc_args, 'interval', 0 ); // avoid absint() to support negative intervals + $count = absint( get_flag_value( $this->assoc_args, 'count', 1 ) ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + $schedule_start = as_get_datetime_object( $schedule_start ); + + $function_args = array( + 'start' => absint( $schedule_start->format( 'U' ) ), + 'interval' => $interval, + 'count' => $count, + 'hook' => $hook, + 'callback_args' => $callback_args, + 'group' => $group, + ); + + $function_args = array_values( $function_args ); + + try { + $actions_added = $this->generate( ...$function_args ); + } catch ( \Exception $e ) { + $this->print_error( $e ); + } + + $num_actions_added = count( (array) $actions_added ); + + $this->print_success( $num_actions_added, 'single' ); + } + + /** + * Schedule multiple single actions. + * + * @param int $schedule_start Starting timestamp of first action. + * @param int $interval How long to wait between runs. + * @param int $count Limit number of actions to schedule. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @return int[] IDs of actions added. + */ + protected function generate( $schedule_start, $interval, $count, $hook, array $args = array(), $group = '' ) { + $actions_added = array(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d is number of actions to create */ + _n( 'Creating %d action', 'Creating %d actions', $count, 'action-scheduler' ), + number_format_i18n( $count ) + ), + $count + ); + + for ( $i = 0; $i < $count; $i++ ) { + $actions_added[] = as_schedule_single_action( $schedule_start + ( $i * $interval ), $hook, $args, $group ); + $progress_bar->tick(); + } + + $progress_bar->finish(); + + return $actions_added; + } + + /** + * Print a success message with the action ID. + * + * @param int $actions_added Number of actions generated. + * @param string $action_type Type of actions scheduled. + * @return void + */ + protected function print_success( $actions_added, $action_type ) { + \WP_CLI::success( + sprintf( + /* translators: %1$d refers to the total number of tasks added, %2$s is the action type */ + _n( '%1$d %2$s action scheduled.', '%1$d %2$s actions scheduled.', $actions_added, 'action-scheduler' ), + number_format_i18n( $actions_added ), + $action_type + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e ) { + \WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message. */ + __( 'There was an error creating the scheduled action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Get_Command.php b/classes/WP_CLI/Action/Get_Command.php new file mode 100644 index 000000000..95df59550 --- /dev/null +++ b/classes/WP_CLI/Action/Get_Command.php @@ -0,0 +1,75 @@ +args[0]; + $store = \ActionScheduler::store(); + $logger = \ActionScheduler::logger(); + $action = $store->fetch_action( $action_id ); + + if ( is_a( $action, ActionScheduler_NullAction::class ) ) { + /* translators: %d is action ID. */ + \WP_CLI::error( sprintf( esc_html__( 'Unable to retrieve action %d.', 'action-scheduler' ), $action_id ) ); + } + + $only_logs = ! empty( $this->assoc_args['field'] ) && 'log_entries' === $this->assoc_args['field']; + $only_logs = $only_logs || ( ! empty( $this->assoc_args['fields'] && 'log_entries' === $this->assoc_args['fields'] ) ); + $log_entries = array(); + + foreach ( $logger->get_logs( $action_id ) as $log_entry ) { + $log_entries[] = array( + 'date' => $log_entry->get_date()->format( static::DATE_FORMAT ), + 'message' => $log_entry->get_message(), + ); + } + + if ( $only_logs ) { + $args = array( + 'format' => \WP_CLI\Utils\get_flag_value( $this->assoc_args, 'format', 'table' ), + ); + + $formatter = new \WP_CLI\Formatter( $args, array( 'date', 'message' ) ); + $formatter->display_items( $log_entries ); + + return; + } + + try { + $status = $store->get_status( $action_id ); + } catch ( \Exception $e ) { + \WP_CLI::error( $e->getMessage() ); + } + + $action_arr = array( + 'id' => $this->args[0], + 'hook' => $action->get_hook(), + 'status' => $status, + 'args' => $action->get_args(), + 'group' => $action->get_group(), + 'recurring' => $action->get_schedule()->is_recurring() ? 'yes' : 'no', + 'scheduled_date' => $this->get_schedule_display_string( $action->get_schedule() ), + 'log_entries' => $log_entries, + ); + + $fields = array_keys( $action_arr ); + + if ( ! empty( $this->assoc_args['fields'] ) ) { + $fields = explode( ',', $this->assoc_args['fields'] ); + } + + $formatter = new \WP_CLI\Formatter( $this->assoc_args, $fields ); + $formatter->display_item( $action_arr ); + } + +} diff --git a/classes/WP_CLI/Action/List_Command.php b/classes/WP_CLI/Action/List_Command.php new file mode 100644 index 000000000..4a3e0835f --- /dev/null +++ b/classes/WP_CLI/Action/List_Command.php @@ -0,0 +1,133 @@ +process_csv_arguments_to_arrays(); + + if ( ! empty( $this->assoc_args['fields'] ) ) { + $fields = $this->assoc_args['fields']; + } + + $formatter = new \WP_CLI\Formatter( $this->assoc_args, $fields ); + $query_args = $this->assoc_args; + + /** + * The `claimed` parameter expects a boolean or integer: + * check for string 'false', and set explicitly to `false` boolean. + */ + if ( array_key_exists( 'claimed', $query_args ) && 'false' === strtolower( $query_args['claimed'] ) ) { + $query_args['claimed'] = false; + } + + $return_format = 'OBJECT'; + + if ( in_array( $formatter->format, array( 'ids', 'count' ), true ) ) { + $return_format = '\'ids\''; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + $params = var_export( $query_args, true ); + + if ( empty( $query_args ) ) { + $params = 'array()'; + } + + \WP_CLI::debug( + sprintf( + 'as_get_scheduled_actions( %s, %s )', + $params, + $return_format + ) + ); + + if ( ! empty( $query_args['args'] ) ) { + $query_args['args'] = json_decode( $query_args['args'], true ); + } + + switch ( $formatter->format ) { + + case 'ids': + $actions = as_get_scheduled_actions( $query_args, 'ids' ); + echo implode( ' ', $actions ); + break; + + case 'count': + $actions = as_get_scheduled_actions( $query_args, 'ids' ); + $formatter->display_items( $actions ); + break; + + default: + $actions = as_get_scheduled_actions( $query_args, OBJECT ); + + $actions_arr = array(); + + foreach ( $actions as $action_id => $action ) { + $action_arr = array( + 'id' => $action_id, + 'hook' => $action->get_hook(), + 'status' => $store->get_status( $action_id ), + 'args' => $action->get_args(), + 'group' => $action->get_group(), + 'recurring' => $action->get_schedule()->is_recurring() ? 'yes' : 'no', + 'scheduled_date' => $this->get_schedule_display_string( $action->get_schedule() ), + 'log_entries' => array(), + ); + + foreach ( $logger->get_logs( $action_id ) as $log_entry ) { + $action_arr['log_entries'][] = array( + 'date' => $log_entry->get_date()->format( static::DATE_FORMAT ), + 'message' => $log_entry->get_message(), + ); + } + + $actions_arr[] = $action_arr; + } + + $formatter->display_items( $actions_arr ); + break; + + } + } + +} diff --git a/classes/WP_CLI/Action/Next_Command.php b/classes/WP_CLI/Action/Next_Command.php new file mode 100644 index 000000000..b71744597 --- /dev/null +++ b/classes/WP_CLI/Action/Next_Command.php @@ -0,0 +1,71 @@ +args[0]; + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $callback_args = get_flag_value( $this->assoc_args, 'args', null ); + $raw = (bool) get_flag_value( $this->assoc_args, 'raw', false ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + if ( $raw ) { + \WP_CLI::line( as_next_scheduled_action( $hook, $callback_args, $group ) ); + return; + } + + $params = array( + 'hook' => $hook, + 'orderby' => 'date', + 'order' => 'ASC', + 'group' => $group, + ); + + if ( is_array( $callback_args ) ) { + $params['args'] = $callback_args; + } + + $params['status'] = \ActionScheduler_Store::STATUS_RUNNING; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + \WP_CLI::debug( 'ActionScheduler()::store()->query_action( ' . var_export( $params, true ) . ' )' ); + + $store = \ActionScheduler::store(); + $action_id = $store->query_action( $params ); + + if ( $action_id ) { + echo $action_id; + return; + } + + $params['status'] = \ActionScheduler_Store::STATUS_PENDING; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + \WP_CLI::debug( 'ActionScheduler()::store()->query_action( ' . var_export( $params, true ) . ' )' ); + + $action_id = $store->query_action( $params ); + + if ( $action_id ) { + echo $action_id; + return; + } + + \WP_CLI::warning( 'No matching next action.' ); + } + +} diff --git a/classes/WP_CLI/Action/Run_Command.php b/classes/WP_CLI/Action/Run_Command.php new file mode 100644 index 000000000..efff37dd9 --- /dev/null +++ b/classes/WP_CLI/Action/Run_Command.php @@ -0,0 +1,194 @@ + + */ + protected $action_counts = array( + 'executed' => 0, + 'failed' => 0, + 'ignored' => 0, + 'invalid' => 0, + 'total' => 0, + ); + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + */ + public function __construct( array $args, array $assoc_args ) { + parent::__construct( $args, $assoc_args ); + + $this->action_ids = array_map( 'absint', $args ); + $this->action_counts['total'] = count( $this->action_ids ); + + add_action( 'action_scheduler_execution_ignored', array( $this, 'on_action_ignored' ) ); + add_action( 'action_scheduler_after_execute', array( $this, 'on_action_executed' ) ); + add_action( 'action_scheduler_failed_execution', array( $this, 'on_action_failed' ), 10, 2 ); + add_action( 'action_scheduler_failed_validation', array( $this, 'on_action_invalid' ), 10, 2 ); + } + + /** + * Execute. + * + * @return void + */ + public function execute() { + $runner = \ActionScheduler::runner(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d: number of actions */ + _n( 'Executing %d action', 'Executing %d actions', $this->action_counts['total'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['total'] ) + ), + $this->action_counts['total'] + ); + + foreach ( $this->action_ids as $action_id ) { + $runner->process_action( $action_id, 'Action Scheduler CLI' ); + $progress_bar->tick(); + } + + $progress_bar->finish(); + + foreach ( array( + 'ignored', + 'invalid', + 'failed', + ) as $type ) { + $count = $this->action_counts[ $type ]; + + if ( empty( $count ) ) { + continue; + } + + /* + * translators: + * %1$d: count of actions evaluated. + * %2$s: type of action evaluated. + */ + $format = _n( '%1$d action %2$s.', '%1$d actions %2$s.', $count, 'action-scheduler' ); + + \WP_CLI::warning( + sprintf( + $format, + number_format_i18n( $count ), + $type + ) + ); + } + + \WP_CLI::success( + sprintf( + /* translators: %d: number of executed actions */ + _n( 'Executed %d action.', 'Executed %d actions.', $this->action_counts['executed'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['executed'] ) + ) + ); + } + + /** + * Action: action_scheduler_execution_ignored + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_ignored( $action_id ) { + if ( 'action_scheduler_execution_ignored' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['ignored']++; + \WP_CLI::debug( sprintf( 'Action %d was ignored.', $action_id ) ); + } + + /** + * Action: action_scheduler_after_execute + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_executed( $action_id ) { + if ( 'action_scheduler_after_execute' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['executed']++; + \WP_CLI::debug( sprintf( 'Action %d was executed.', $action_id ) ); + } + + /** + * Action: action_scheduler_failed_execution + * + * @param int $action_id Action ID. + * @param \Exception $e Exception. + * @return void + */ + public function on_action_failed( $action_id, \Exception $e ) { + if ( 'action_scheduler_failed_execution' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['failed']++; + \WP_CLI::debug( sprintf( 'Action %d failed execution: %s', $action_id, $e->getMessage() ) ); + } + + /** + * Action: action_scheduler_failed_validation + * + * @param int $action_id Action ID. + * @param \Exception $e Exception. + * @return void + */ + public function on_action_invalid( $action_id, \Exception $e ) { + if ( 'action_scheduler_failed_validation' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['invalid']++; + \WP_CLI::debug( sprintf( 'Action %d failed validation: %s', $action_id, $e->getMessage() ) ); + } + +} diff --git a/classes/WP_CLI/Action_Command.php b/classes/WP_CLI/Action_Command.php new file mode 100644 index 000000000..b32eea31d --- /dev/null +++ b/classes/WP_CLI/Action_Command.php @@ -0,0 +1,353 @@ +] + * : Name of the action hook. + * + * [--group=] + * : The group the job is assigned to. + * + * [--args=] + * : JSON object of arguments assigned to the job. + * --- + * default: [] + * --- + * + * [--all] + * : Cancel all occurrences of a scheduled action. + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function cancel( array $args, array $assoc_args ) { + require_once 'Action/Cancel_Command.php'; + $command = new Action\Cancel_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Creates a new scheduled action. + * + * ## OPTIONS + * + * + * : Name of the action hook. + * + * + * : A unix timestamp representing the date you want the action to start. Also 'async' or 'now' to enqueue an async action. + * + * [--args=] + * : JSON object of arguments to pass to callbacks when the hook triggers. + * --- + * default: [] + * --- + * + * [--cron=] + * : A cron-like schedule string (https://crontab.guru/). + * --- + * default: '' + * --- + * + * [--group=] + * : The group to assign this job to. + * --- + * default: '' + * --- + * + * [--interval=] + * : Number of seconds to wait between runs. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * wp action-scheduler action create hook_async async + * wp action-scheduler action create hook_single 1627147598 + * wp action-scheduler action create hook_recurring 1627148188 --interval=5 + * wp action-scheduler action create hook_cron 1627147655 --cron='5 4 * * *' + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function create( array $args, array $assoc_args ) { + require_once 'Action/Create_Command.php'; + $command = new Action\Create_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Delete existing scheduled action(s). + * + * ## OPTIONS + * + * ... + * : One or more IDs of actions to delete. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * # Delete the action with id 100 + * $ wp action-scheduler action delete 100 + * + * # Delete the actions with ids 100 and 200 + * $ wp action-scheduler action delete 100 200 + * + * # Delete the first five pending actions in 'action-scheduler' group + * $ wp action-scheduler action delete $( wp action-scheduler action list --status=pending --group=action-scheduler --format=ids ) + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function delete( array $args, array $assoc_args ) { + require_once 'Action/Delete_Command.php'; + $command = new Action\Delete_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Generates some scheduled actions. + * + * ## OPTIONS + * + * + * : Name of the action hook. + * + * + * : The Unix timestamp representing the date you want the action to start. + * + * [--count=] + * : Number of actions to create. + * --- + * default: 1 + * --- + * + * [--interval=] + * : Number of seconds to wait between runs. + * --- + * default: 0 + * --- + * + * [--args=] + * : JSON object of arguments to pass to callbacks when the hook triggers. + * --- + * default: [] + * --- + * + * [--group=] + * : The group to assign this job to. + * --- + * default: '' + * --- + * + * ## EXAMPLES + * + * wp action-scheduler action generate test_multiple 1627147598 --count=5 --interval=5 + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function generate( array $args, array $assoc_args ) { + require_once 'Action/Generate_Command.php'; + $command = new Action\Generate_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get details about a scheduled action. + * + * ## OPTIONS + * + * + * : The ID of the action to get. + * --- + * default: 0 + * --- + * + * [--field=] + * : Instead of returning the whole action, returns the value of a single field. + * + * [--fields=] + * : Limit the output to specific fields (comma-separated). Defaults to all fields. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function get( array $args, array $assoc_args ) { + require_once 'Action/Get_Command.php'; + $command = new Action\Get_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get a list of scheduled actions. + * + * Display actions based on all arguments supported by + * [as_get_scheduled_actions()](https://actionscheduler.org/api/#function-reference--as_get_scheduled_actions). + * + * ## OPTIONS + * + * [--=] + * : One or more arguments to pass to as_get_scheduled_actions(). + * + * [--field=] + * : Prints the value of a single property for each action. + * + * [--fields=] + * : Limit the output to specific object properties. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - ids + * - json + * - count + * - yaml + * --- + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each action: + * + * * id + * * hook + * * status + * * group + * * recurring + * * scheduled_date + * + * These fields are optionally available: + * + * * args + * * log_entries + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + * + * @subcommand list + */ + public function subcommand_list( array $args, array $assoc_args ) { + require_once 'Action/List_Command.php'; + $command = new Action\List_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get logs for a scheduled action. + * + * ## OPTIONS + * + * + * : The ID of the action to get. + * --- + * default: 0 + * --- + * + * @param array $args Positional arguments. + * @return void + */ + public function logs( array $args ) { + $command = sprintf( 'action-scheduler action get %d --field=log_entries', $args[0] ); + WP_CLI::runcommand( $command ); + } + + /** + * Get the ID or timestamp of the next scheduled action. + * + * ## OPTIONS + * + * + * : The hook of the next scheduled action. + * + * [--args=] + * : JSON object of arguments to search for next scheduled action. + * --- + * default: [] + * --- + * + * [--group=] + * : The group to which the next scheduled action is assigned. + * --- + * default: '' + * --- + * + * [--raw] + * : Display the raw output of as_next_scheduled_action() (timestamp or boolean). + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function next( array $args, array $assoc_args ) { + require_once 'Action/Next_Command.php'; + $command = new Action\Next_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Run existing scheduled action(s). + * + * ## OPTIONS + * + * ... + * : One or more IDs of actions to run. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * # Run the action with id 100 + * $ wp action-scheduler action run 100 + * + * # Run the actions with ids 100 and 200 + * $ wp action-scheduler action run 100 200 + * + * # Run the first five pending actions in 'action-scheduler' group + * $ wp action-scheduler action run $( wp action-scheduler action list --status=pending --group=action-scheduler --format=ids ) + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function run( array $args, array $assoc_args ) { + require_once 'Action/Run_Command.php'; + $command = new Action\Run_Command( $args, $assoc_args ); + $command->execute(); + } + +} diff --git a/classes/WP_CLI/System_Command.php b/classes/WP_CLI/System_Command.php new file mode 100644 index 000000000..5a585b5cc --- /dev/null +++ b/classes/WP_CLI/System_Command.php @@ -0,0 +1,222 @@ +store = \ActionScheduler::store(); + } + + /** + * Print in-use data store class. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + * + * @subcommand data-store + */ + public function datastore( array $args, array $assoc_args ) { + echo $this->get_current_datastore(); + } + + /** + * Print in-use runner class. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function runner( array $args, array $assoc_args ) { + echo $this->get_current_runner(); + } + + /** + * Get system status. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function status( array $args, array $assoc_args ) { + /** + * Get runner status. + * + * @link https://github.com/woocommerce/action-scheduler-disable-default-runner + */ + $runner_enabled = has_action( 'action_scheduler_run_queue', array( \ActionScheduler::runner(), 'run' ) ); + + \WP_CLI::line( sprintf( 'Data store: %s', $this->get_current_datastore() ) ); + \WP_CLI::line( sprintf( 'Runner: %s%s', $this->get_current_runner(), ( $runner_enabled ? '' : ' (disabled)' ) ) ); + \WP_CLI::line( sprintf( 'Version: %s', $this->get_latest_version() ) ); + + $rows = array(); + $action_counts = $this->store->action_counts(); + $oldest_and_newest = $this->get_oldest_and_newest( array_keys( $action_counts ) ); + + foreach ( $action_counts as $status => $count ) { + $rows[] = array( + 'status' => $status, + 'count' => $count, + 'oldest' => $oldest_and_newest[ $status ]['oldest'], + 'newest' => $oldest_and_newest[ $status ]['newest'], + ); + } + + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'status', 'count', 'oldest', 'newest' ) ); + $formatter->display_items( $rows ); + } + + /** + * Display the active version, or all registered versions. + * + * ## OPTIONS + * + * [--all] + * : List all registered versions. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function version( array $args, array $assoc_args ) { + $all = (bool) get_flag_value( $assoc_args, 'all' ); + $instance = \ActionScheduler_Versions::instance(); + $latest = $this->get_latest_version( $instance ); + + if ( $all ) { + $versions = $instance->get_versions(); + + $rows = array(); + + foreach ( $versions as $version => $callback ) { + $active = 'no'; + + if ( $version === $latest ) { + $active = 'yes'; + } + + $rows[ $version ] = array( + 'version' => $version, + 'callback' => $callback, + 'active' => $active, + ); + } + + uksort( $rows, 'version_compare' ); + + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'version', 'callback', 'active' ) ); + $formatter->display_items( $rows ); + + return; + } + + echo $latest; + } + + /** + * Get current data store. + * + * @return string + */ + protected function get_current_datastore() { + return get_class( $this->store ); + } + + /** + * Get latest version. + * + * @param null|\ActionScheduler_Versions $instance Versions. + * @return string + */ + protected function get_latest_version( $instance = null ) { + if ( is_null( $instance ) ) { + $instance = \ActionScheduler_Versions::instance(); + } + + return $instance->latest_version(); + } + + /** + * Get current runner. + * + * @return string + */ + protected function get_current_runner() { + return get_class( \ActionScheduler::runner() ); + } + + /** + * 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' => '–', + 'newest' => '–', + ); + + 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 string + */ + protected function get_action_status_date( $status, $date_type = 'oldest' ) { + $order = 'oldest' === $date_type ? 'ASC' : 'DESC'; + + $args = array( + 'claimed' => false, + 'status' => $status, + 'per_page' => 1, + 'order' => $order, + ); + + $action = $this->store->query_actions( $args ); + + 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 = '–'; + } + + return $action_date; + } + +} diff --git a/classes/abstracts/ActionScheduler.php b/classes/abstracts/ActionScheduler.php index d3d281343..774abb185 100644 --- a/classes/abstracts/ActionScheduler.php +++ b/classes/abstracts/ActionScheduler.php @@ -240,6 +240,8 @@ function () { if ( defined( 'WP_CLI' ) && WP_CLI ) { WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command' ); WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Clean_Command' ); + WP_CLI::add_command( 'action-scheduler action', '\Action_Scheduler\WP_CLI\Action_Command' ); + WP_CLI::add_command( 'action-scheduler', '\Action_Scheduler\WP_CLI\System_Command' ); if ( ! ActionScheduler_DataController::is_migration_complete() && Controller::instance()->allow_migration() ) { $command = new Migration_Command(); $command->register(); @@ -296,6 +298,7 @@ protected static function is_class_abstract( $class ) { 'ActionScheduler_Abstract_Schema' => true, 'ActionScheduler_Store' => true, 'ActionScheduler_TimezoneHelper' => true, + 'ActionScheduler_WPCLI_Command' => true, ); return isset( $abstracts[ $class ] ) && $abstracts[ $class ]; @@ -340,9 +343,11 @@ protected static function is_class_migration( $class ) { */ protected static function is_class_cli( $class ) { static $cli_segments = array( - 'QueueRunner' => true, - 'Command' => true, - 'ProgressBar' => true, + 'QueueRunner' => true, + 'Command' => true, + 'ProgressBar' => true, + '\Action_Scheduler\WP_CLI\Action_Command' => true, + '\Action_Scheduler\WP_CLI\System_Command' => true, ); $segments = explode( '_', $class ); diff --git a/classes/abstracts/ActionScheduler_WPCLI_Command.php b/classes/abstracts/ActionScheduler_WPCLI_Command.php new file mode 100644 index 000000000..847c109ee --- /dev/null +++ b/classes/abstracts/ActionScheduler_WPCLI_Command.php @@ -0,0 +1,83 @@ + + */ + protected $assoc_args; + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @throws \Exception When loading a CLI command file outside of WP CLI context. + */ + public function __construct( array $args, array $assoc_args ) { + if ( ! defined( 'WP_CLI' ) || ! constant( 'WP_CLI' ) ) { + /* translators: %s php class name */ + throw new \Exception( sprintf( __( 'The %s class can only be run within WP CLI.', 'action-scheduler' ), get_class( $this ) ) ); + } + + $this->args = $args; + $this->assoc_args = $assoc_args; + } + + /** + * Execute command. + */ + abstract public function execute(); + + /** + * Get the scheduled date in a human friendly format. + * + * @see ActionScheduler_ListTable::get_schedule_display_string() + * @param ActionScheduler_Schedule $schedule Schedule. + * @return string + */ + protected function get_schedule_display_string( ActionScheduler_Schedule $schedule ) { + + $schedule_display_string = ''; + + if ( ! $schedule->get_date() ) { + return '0000-00-00 00:00:00'; + } + + $next_timestamp = $schedule->get_date()->getTimestamp(); + + $schedule_display_string .= $schedule->get_date()->format( static::DATE_FORMAT ); + + return $schedule_display_string; + } + + /** + * Transforms arguments with '__' from CSV into expected arrays. + * + * @see \WP_CLI\CommandWithDBObject::process_csv_arguments_to_arrays() + * @link https://github.com/wp-cli/entity-command/blob/c270cc9a2367cb8f5845f26a6b5e203397c91392/src/WP_CLI/CommandWithDBObject.php#L99 + * @return void + */ + protected function process_csv_arguments_to_arrays() { + foreach ( $this->assoc_args as $k => $v ) { + if ( false !== strpos( $k, '__' ) ) { + $this->assoc_args[ $k ] = explode( ',', $v ); + } + } + } + +} diff --git a/docs/wp-cli.md b/docs/wp-cli.md index 2f8c1b580..63287d7ae 100644 --- a/docs/wp-cli.md +++ b/docs/wp-cli.md @@ -41,6 +41,19 @@ These are the commands available to use with Action Scheduler: * `--group` - Process only actions in a specific group, like `'woocommerce-memberships'`. By default, actions in any group (or no group) will be processed. * `--exclude-groups` - Ignore actions from the specified group or groups (to specify multiple groups, supply a comma-separated list of slugs). This option is ignored if `--group` is also specified. * `--force` - By default, Action Scheduler limits the number of concurrent batches that can be run at once to ensure the server does not get overwhelmed. Using the `--force` flag overrides this behavior to force the WP CLI queue to run. + +* `action-scheduler action cancel` +* `action-scheduler action create` +* `action-scheduler action delete` +* `action-scheduler action generate` +* `action-scheduler action get` +* `action-scheduler action list` +* `action-scheduler action next` +* `action-scheduler action run` +* `action-scheduler datastore` +* `action-scheduler runner` +* `action-scheduler status` +* `action-scheduler version` The best way to get a full list of commands and their available options is to use WP CLI itself. This can be done by running `wp action-scheduler` to list all Action Scheduler commands, or by including the `--help` flag with any of the individual commands. This will provide all relevant parameters and flags for the command.