] * : Limit the output to specific object fields. * * [--=] * : Filter by one or more fields. * * [--field=] * : Prints the value of a single field for each event. * * [--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 cron event: * * hook * * next_run_gmt * * next_run_relative * * recurrence * * These fields are optionally available: * * time * * sig * * args * * schedule * * interval * * next_run * * actions * * ## EXAMPLES * * # List scheduled cron events * $ wp cron event list * +-------------------+---------------------+---------------------+------------+ * | hook | next_run_gmt | next_run_relative | recurrence | * +-------------------+---------------------+---------------------+------------+ * | wp_version_check | 2016-05-31 22:15:13 | 11 hours 57 minutes | 12 hours | * | wp_update_plugins | 2016-05-31 22:15:13 | 11 hours 57 minutes | 12 hours | * | wp_update_themes | 2016-05-31 22:15:14 | 11 hours 57 minutes | 12 hours | * +-------------------+---------------------+---------------------+------------+ * * # List scheduled cron events in JSON * $ wp cron event list --fields=hook,next_run --format=json * [{"hook":"wp_version_check","next_run":"2016-05-31 10:15:13"},{"hook":"wp_update_plugins","next_run":"2016-05-31 10:15:13"},{"hook":"wp_update_themes","next_run":"2016-05-31 10:15:14"}] * * @subcommand list */ public function list_( $args, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); $events = self::get_cron_events(); if ( is_wp_error( $events ) ) { $events = array(); } // Populate actions field only if requested $requested_fields = $formatter->fields; if ( ! empty( $requested_fields ) && in_array( 'actions', $requested_fields, true ) ) { foreach ( $events as $event ) { $event->actions = self::get_hook_actions( $event->hook ); } } foreach ( $events as $key => $event ) { foreach ( $this->fields as $field ) { if ( ! empty( $assoc_args[ $field ] ) && $event->{$field} !== $assoc_args[ $field ] ) { unset( $events[ $key ] ); break; } } } if ( 'ids' === $formatter->format ) { echo implode( ' ', wp_list_pluck( $events, 'hook' ) ); } else { $formatter->display_items( $events ); } } /** * Schedules a new cron event. * * ## OPTIONS * * * : The hook name. * * [] * : A Unix timestamp or an English textual datetime description compatible with `strtotime()`. Defaults to now. * * [] * : How often the event should recur. See `wp cron schedule list` for available schedule names. Defaults to no recurrence. * * [--=] * : Arguments to pass to the hook for the event. should be a numeric key, not a string. * * ## EXAMPLES * * # Schedule a new cron event * $ wp cron event schedule cron_test * Success: Scheduled event with hook 'cron_test' for 2016-05-31 10:19:16 GMT. * * # Schedule new cron event with hourly recurrence * $ wp cron event schedule cron_test now hourly * Success: Scheduled event with hook 'cron_test' for 2016-05-31 10:20:32 GMT. * * # Schedule new cron event and pass arguments * $ wp cron event schedule cron_test '+1 hour' --0=first-argument --1=second-argument * Success: Scheduled event with hook 'cron_test' for 2016-05-31 11:21:35 GMT. * * @param array{0: string, 1?: string, 2?: string} $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function schedule( $args, $assoc_args ) { if ( count( $assoc_args ) && count( array_filter( array_keys( $assoc_args ), 'is_string' ) ) ) { WP_CLI::warning( 'Numeric keys should be used for the hook arguments.' ); } $hook = $args[0]; $next_run = Utils\get_flag_value( $args, 1, 'now' ); $recurrence = Utils\get_flag_value( $args, 2, false ); if ( empty( $next_run ) ) { $timestamp = time(); } elseif ( is_numeric( $next_run ) ) { $timestamp = absint( $next_run ); } else { $timestamp = strtotime( $next_run ); } if ( ! $timestamp ) { WP_CLI::error( sprintf( "'%s' is not a valid datetime.", $next_run ) ); } if ( ! empty( $recurrence ) ) { $schedules = wp_get_schedules(); if ( ! isset( $schedules[ $recurrence ] ) ) { WP_CLI::error( sprintf( "'%s' is not a valid schedule name for recurrence.", $recurrence ) ); } // WordPress expects a list bug we knowingly pass an associative array. // @phpstan-ignore argument.type $event = wp_schedule_event( $timestamp, $recurrence, $hook, $assoc_args ); } else { // Ditto. // @phpstan-ignore argument.type $event = wp_schedule_single_event( $timestamp, $hook, $assoc_args ); } if ( false !== $event ) { WP_CLI::success( sprintf( "Scheduled event with hook '%s' for %s GMT.", $hook, date( self::$time_format, $timestamp ) ) ); //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date } else { WP_CLI::error( 'Event not scheduled.' ); } } /** * Runs the next scheduled cron event for the given hook. * * ## OPTIONS * * [...] * : One or more hooks to run. * * [--due-now] * : Run all hooks due right now. Respects the doing_cron transient to * prevent overlapping runs. * * [--exclude=] * : Comma-separated list of hooks to exclude. * * [--all] * : Run all hooks. * * [--network] * : Run hooks across all sites in a multisite installation. * * ## EXAMPLES * * # Run all cron events due right now * $ wp cron event run --due-now * Executed the cron event 'cron_test_1' in 0.01s. * Executed the cron event 'cron_test_2' in 0.006s. * Success: Executed a total of 2 cron events. * * # Run all cron events due right now across all sites in a multisite * $ wp cron event run --due-now --network * Executed the cron event 'cron_test_1' in 0.01s. * Executed the cron event 'cron_test_2' in 0.006s. * Success: Executed a total of 2 cron events across 3 sites. */ public function run( $args, $assoc_args ) { $due_now = Utils\get_flag_value( $assoc_args, 'due-now' ); $network = Utils\get_flag_value( $assoc_args, 'network' ); $lock_timeout = defined( 'WP_CRON_LOCK_TIMEOUT' ) ? WP_CRON_LOCK_TIMEOUT : 60; if ( $network ) { if ( ! is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } $sites = get_sites( array( 'fields' => 'ids', 'number' => 0, ) ); if ( empty( $sites ) ) { WP_CLI::error( 'No sites found in the network.' ); } // Remove network flag before passing to get_selected_cron_events. $network_assoc_args = $assoc_args; unset( $network_assoc_args['network'] ); $total_executed = 0; $site_count = count( $sites ); foreach ( $sites as $site_id ) { switch_to_blog( $site_id ); $doing_cron_value = null; if ( $due_now ) { $doing_cron_transient = get_transient( 'doing_cron' ); if ( is_numeric( $doing_cron_transient ) && (float) $doing_cron_transient > microtime( true ) - $lock_timeout ) { WP_CLI::warning( 'A cron event run is already in progress; skipping.' ); return; } $doing_cron_value = sprintf( '%.22F', microtime( true ) ); set_transient( 'doing_cron', $doing_cron_value, $lock_timeout ); } $events = self::get_selected_cron_events( $args, $network_assoc_args ); if ( ! is_wp_error( $events ) ) { $total_executed += self::run_events( $events ); if ( $due_now && get_transient( 'doing_cron' ) === $doing_cron_value ) { delete_transient( 'doing_cron' ); } } else { if ( $due_now && get_transient( 'doing_cron' ) === $doing_cron_value ) { delete_transient( 'doing_cron' ); } WP_CLI::debug( sprintf( 'No events found for site %d: %s', $site_id, $events->get_error_message() ), 'cron' ); } restore_current_blog(); } $message = sprintf( 'Executed a total of %d %s across %d %s.', $total_executed, Utils\pluralize( 'cron event', $total_executed ), $site_count, Utils\pluralize( 'site', $site_count ) ); WP_CLI::success( $message ); return; } $doing_cron_value = null; if ( $due_now ) { $doing_cron_transient = get_transient( 'doing_cron' ); if ( is_numeric( $doing_cron_transient ) && (float) $doing_cron_transient > microtime( true ) - $lock_timeout ) { WP_CLI::warning( 'A cron event run is already in progress; skipping.' ); return; } $doing_cron_value = sprintf( '%.22F', microtime( true ) ); set_transient( 'doing_cron', $doing_cron_value, $lock_timeout ); } $events = self::get_selected_cron_events( $args, $assoc_args ); if ( is_wp_error( $events ) ) { if ( $due_now && get_transient( 'doing_cron' ) === $doing_cron_value ) { delete_transient( 'doing_cron' ); } WP_CLI::error( $events ); } $executed = self::run_events( $events ); if ( $due_now && get_transient( 'doing_cron' ) === $doing_cron_value ) { delete_transient( 'doing_cron' ); } $message = ( 1 === $executed ) ? 'Executed a total of %d cron event.' : 'Executed a total of %d cron events.'; WP_CLI::success( sprintf( $message, $executed ) ); } /** * Unschedules all cron events for a given hook. * * ## OPTIONS * * * : Name of the hook for which all events should be unscheduled. * * ## EXAMPLES * * # Unschedule a cron event on given hook. * $ wp cron event unschedule cron_test * Success: Unscheduled 2 events for hook 'cron_test'. */ public function unschedule( $args, $assoc_args ) { list( $hook ) = $args; // Count events before unscheduling for WP < 5.1 compatibility where // wp_unschedule_hook() returns null instead of the event count. $crons = _get_cron_array(); $event_count = 0; if ( is_array( $crons ) ) { foreach ( $crons as $cron ) { if ( ! is_array( $cron ) ) { continue; } /** * @var array> $cron */ if ( isset( $cron[ $hook ] ) ) { $event_count += count( $cron[ $hook ] ); } } } if ( 0 === $event_count ) { WP_CLI::error( sprintf( "No events found for hook '%s'.", $hook ) ); } $unscheduled = wp_unschedule_hook( $hook ); if ( false === $unscheduled ) { WP_CLI::error( sprintf( "Failed to unschedule events for hook '%s'.", $hook ) ); } $count = ( is_int( $unscheduled ) && $unscheduled > 0 ) ? $unscheduled : $event_count; WP_CLI::success( sprintf( 'Unscheduled %1$d %2$s for hook \'%3$s\'.', $count, Utils\pluralize( 'event', $count ), $hook ) ); } /** * Deletes all scheduled cron events for the given hook. * * ## OPTIONS * * [...] * : One or more hooks to delete. * * [--due-now] * : Delete all hooks due right now. * * [--exclude=] * : Comma-separated list of hooks to exclude. * * [--all] * : Delete all hooks. * * [--match-args=] * : Only delete events whose arguments match the given JSON-encoded array or scalar value. Argument types must match exactly (for example, `["123"]` vs `[123]`). Requires exactly one hook name. * * ## EXAMPLES * * # Delete all scheduled cron events for the given hook * $ wp cron event delete cron_test * Success: Deleted a total of 2 cron events. * * # Delete a specific cron event by hook and arguments * $ wp cron event delete cron_test --match-args='["123"]' * Success: Deleted a total of 1 cron event. */ public function delete( $args, $assoc_args ) { $match_args = Utils\get_flag_value( $assoc_args, 'match-args' ); if ( null !== $match_args ) { if ( 1 !== count( $args ) ) { WP_CLI::error( 'The --match-args parameter requires exactly one hook name.' ); } if ( Utils\get_flag_value( $assoc_args, 'all' ) || Utils\get_flag_value( $assoc_args, 'due-now' ) ) { WP_CLI::error( 'The --match-args parameter cannot be combined with --all or --due-now.' ); } $trimmed_match_args = ltrim( $match_args ); // Only JSON-decode when the value clearly looks like JSON: // - starts with '[' for arrays // - starts with '"' for explicitly quoted strings if ( '' !== $trimmed_match_args && ( '[' === $trimmed_match_args[0] || '"' === $trimmed_match_args[0] ) ) { $decoded_args = json_decode( $match_args, true ); if ( null === $decoded_args && JSON_ERROR_NONE !== json_last_error() ) { // Not valid JSON — treat as a single string argument wrapped in an array. $decoded_args = array( $match_args ); } elseif ( ! is_array( $decoded_args ) ) { $decoded_args = array( $decoded_args ); } } else { // Treat non-JSON-looking values as a single string argument. $decoded_args = array( $match_args ); } } $events = self::get_selected_cron_events( $args, $assoc_args ); if ( is_wp_error( $events ) ) { WP_CLI::error( $events ); } if ( null !== $match_args ) { $events = array_filter( $events, function ( $event ) use ( $decoded_args ) { return $event->args === $decoded_args; } ); } $deleted = 0; foreach ( $events as $event ) { $result = self::delete_event( $event ); if ( $result ) { ++$deleted; } else { WP_CLI::warning( sprintf( "Failed to the delete the cron event '%s'.", $event->hook ) ); } } $message = sprintf( 'Deleted a total of %d %s.', $deleted, Utils\pluralize( 'cron event', $deleted ) ); WP_CLI::success( sprintf( $message, $deleted ) ); } /** * Runs multiple cron events and logs their execution. * * @param array $events Array of event objects to run. * @return int The number of events executed. */ private static function run_events( array $events ) { $executed = 0; foreach ( $events as $event ) { WP_CLI::debug( sprintf( "Beginning execution of cron event '%s'.", $event->hook ), 'cron' ); $start = microtime( true ); self::run_event( $event ); $total = round( microtime( true ) - $start, 3 ); ++$executed; WP_CLI::log( sprintf( "Executed the cron event '%s' in %ss.", $event->hook, $total ) ); if ( ! empty( $event->args ) ) { WP_CLI::debug( sprintf( 'Arguments: %s', wp_json_encode( $event->args ) ), 'cron' ); } } return $executed; } /** * Executes an event immediately. * * @param stdClass $event The event * @return bool Whether the event was successfully executed or not. */ protected static function run_event( stdClass $event ) { if ( ! defined( 'DOING_CRON' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Using native WordPress constant. define( 'DOING_CRON', true ); } if ( false !== $event->schedule ) { wp_reschedule_event( $event->time, $event->schedule, $event->hook, $event->args ); } wp_unschedule_event( $event->time, $event->hook, $event->args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Can't prefix dynamic hooks here, calling registered hooks. do_action_ref_array( $event->hook, $event->args ); return true; } /** * Deletes a cron event. * * @param stdClass $event The event * @return bool Whether the event was successfully deleted or not. */ protected static function delete_event( stdClass $event ) { $crons = _get_cron_array(); if ( ! isset( $crons[ $event->time ][ $event->hook ][ $event->sig ] ) ) { return false; } wp_unschedule_event( $event->time, $event->hook, $event->args ); return true; } /** * Callback function to format a cron event. * * @param stdClass $event The event. * @return stdClass The formatted event object. */ protected static function format_event( stdClass $event ) { $schedules = wp_get_schedules(); $event->recurrence = ( isset( $schedules[ $event->schedule ] ) ) ? self::interval( $event->interval ) : 'Non-repeating'; $event->next_run = get_date_from_gmt( date( 'Y-m-d H:i:s', $event->time ), self::$time_format ); //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date $event->next_run_gmt = date( self::$time_format, $event->time ); //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date $event->next_run_relative = self::interval( $event->time - time() ); $event->actions = ''; return $event; } /** * Fetches an array of scheduled cron events. * * @return array|WP_Error An array of event objects, or a WP_Error object if there are no events scheduled. */ protected static function get_cron_events( $is_due_now = false ) { // wp_get_ready_cron_jobs since 5.1.0 if ( $is_due_now && function_exists( 'wp_get_ready_cron_jobs' ) ) { $crons = wp_get_ready_cron_jobs(); } else { $crons = _get_cron_array(); } $events = array(); if ( empty( $crons ) && ! $is_due_now ) { return new WP_Error( 'no_events', 'You currently have no scheduled cron events.' ); } if ( ! is_array( $crons ) ) { return []; } foreach ( $crons as $time => $hooks ) { // Incorrectly registered cron events can produce a string key. if ( is_string( $time ) ) { WP_CLI::warning( sprintf( 'Ignoring incorrectly registered cron event "%s".', $time ) ); continue; } foreach ( $hooks as $hook => $hook_events ) { foreach ( $hook_events as $sig => $data ) { $events[] = (object) array( 'hook' => $hook, 'time' => $time, 'sig' => $sig, 'args' => $data['args'], 'schedule' => $data['schedule'], 'interval' => Utils\get_flag_value( $data, 'interval' ), ); } } } $events = array_map( 'Cron_Event_Command::format_event', $events ); return $events; } /** * Fetches an array of scheduled cron events selected by the user. * * @param array $args A list of event names * @param array $assoc_args An associative list of CLI parameters * * @return array|WP_Error An array of objects, or a WP_Error object is there are no events scheduled. */ protected static function get_selected_cron_events( $args, $assoc_args ) { $due_now = Utils\get_flag_value( $assoc_args, 'due-now' ); $all = Utils\get_flag_value( $assoc_args, 'all' ); /** * @var string $exclude */ $exclude = Utils\get_flag_value( $assoc_args, 'exclude' ); if ( empty( $args ) && ! $due_now && ! $all ) { WP_CLI::error( 'Please specify one or more cron events, or use --due-now/--all.' ); } if ( ! empty( $args ) && $all ) { WP_CLI::error( 'Please either specify cron events, or use --all.' ); } if ( $due_now && $all ) { WP_CLI::error( 'Please use either --due-now or --all.' ); } $events = self::get_cron_events( $due_now ); if ( is_wp_error( $events ) ) { return $events; } $hooks = wp_list_pluck( $events, 'hook' ); foreach ( $args as $hook ) { if ( ! in_array( $hook, $hooks, true ) ) { WP_CLI::error( sprintf( "Invalid cron event '%s'", $hook ) ); } } // Remove all excluded hooks. if ( ! empty( $exclude ) ) { $exclude = explode( ',', $exclude ); $events = array_filter( $events, function ( $event ) use ( $exclude ) { return ! in_array( $event->hook, $exclude, true ); } ); } // If --due-now is specified, take only the events that have 'now' as // their next_run_relative time. if ( $due_now ) { $due_events = array(); foreach ( $events as $event ) { if ( ! empty( $args ) && ! in_array( $event->hook, $args, true ) ) { continue; } if ( 'now' === $event->next_run_relative ) { $due_events[] = $event; } } $events = $due_events; } elseif ( ! $all ) { // If --all is not specified, take only the events that have been // given as $args. $due_events = array(); foreach ( $events as $event ) { if ( in_array( $event->hook, $args, true ) ) { $due_events[] = $event; } } $events = $due_events; } return $events; } /** * Converts a time interval into human-readable format. * * Similar to WordPress' built-in `human_time_diff()` but returns two time period chunks instead of just one. * * @param int $since An interval of time in seconds * @return string The interval in human readable format */ private static function interval( $since ) { if ( $since <= 0 ) { return 'now'; } $since = absint( $since ); // Array of time period chunks. $chunks = array( array( 60 * 60 * 24 * 365, 'year' ), array( 60 * 60 * 24 * 30, 'month' ), array( 60 * 60 * 24 * 7, 'week' ), array( 60 * 60 * 24, 'day' ), array( 60 * 60, 'hour' ), array( 60, 'minute' ), array( 1, 'second' ), ); $name = 'second'; $count = 0; $seconds = 1; // we only want to output two chunks of time here, eg: // x years, xx months // x days, xx hours // so there's only two bits of calculation below: $i = 0; $j = 0; // step one: the first chunk for ( $i = 0, $j = count( $chunks ); $i < $j; $i++ ) { $seconds = $chunks[ $i ][0]; $name = $chunks[ $i ][1]; // Finding the biggest chunk (if the chunk fits, break). $count = floor( $since / $seconds ); if ( floatval( 0 ) !== $count ) { break; } } // Set output var. $output = sprintf( '%d %s', $count, Utils\pluralize( $name, absint( $count ) ) ); // Step two: the second chunk. if ( $i + 1 < $j ) { $seconds2 = $chunks[ $i + 1 ][0]; $name2 = $chunks[ $i + 1 ][1]; $count2 = floor( ( $since - ( $seconds * $count ) ) / $seconds2 ); if ( floatval( 0 ) !== $count2 ) { // Add to output var. $output .= ' ' . sprintf( '%d %s', $count2, Utils\pluralize( $name2, absint( $count2 ) ) ); } } return $output; } private function get_formatter( &$assoc_args ) { return new \WP_CLI\Formatter( $assoc_args, $this->fields, 'event' ); } /** * Gets the actions (callbacks) registered for a specific hook. * * @param string $hook_name The name of the hook. * @return string A comma-separated list of action callbacks, or 'None' if no actions are registered. */ protected static function get_hook_actions( $hook_name ) { static $cache = array(); if ( isset( $cache[ $hook_name ] ) ) { return $cache[ $hook_name ]; } global $wp_filter; if ( ! isset( $wp_filter[ $hook_name ] ) ) { $cache[ $hook_name ] = 'None'; return $cache[ $hook_name ]; } $hook = $wp_filter[ $hook_name ]; // Get callbacks from the WP_Hook object (WordPress 4.7+) if ( $hook instanceof \WP_Hook ) { $callbacks = $hook->callbacks; } else { // Fallback for older WordPress versions $callbacks = $hook; } if ( empty( $callbacks ) ) { $cache[ $hook_name ] = 'None'; return $cache[ $hook_name ]; } ksort( $callbacks ); $actions = array(); // Iterate through all priorities foreach ( $callbacks as $priority => $priority_callbacks ) { foreach ( $priority_callbacks as $callback_info ) { if ( ! isset( $callback_info['function'] ) ) { continue; } $callback = $callback_info['function']; $actions[] = self::format_callback( $callback ); } } $result = empty( $actions ) ? 'None' : implode( ', ', $actions ); $cache[ $hook_name ] = $result; return $cache[ $hook_name ]; } /** * Formats a callback into a readable string. * * @param callable $callback The callback to format. * @return string A formatted string representing the callback. */ protected static function format_callback( $callback ) { if ( is_string( $callback ) ) { return $callback; } elseif ( is_array( $callback ) && count( $callback ) === 2 ) { $class = $callback[0]; $method = $callback[1]; if ( is_object( $class ) ) { $class_name = get_class( $class ); } else { $class_name = $class; } return $class_name . '::' . $method; } elseif ( $callback instanceof \Closure ) { return 'Closure'; } elseif ( is_object( $callback ) ) { return get_class( $callback ) . '::__invoke'; } // Fallback for unknown callback types return 'Unknown'; } }