<?php
/**
* Cron_Pixie class file.
*
* @package Cron_Pixie
*/
namespace Cron_Pixie;
use WP_Error;
/**
* Cron_Pixie class.
*/
class Cron_Pixie {
/**
* The context for our nonce.
*
* @var string
*/
const NONCE_CONTEXT = 'cron-pixie';
/**
* The key used for saving settings in the database.
*
* @var string
*/
const SETTINGS_KEY = 'cron_pixie_settings';
/**
* Often used plugin info.
*
* @var array<string, string>
*/
private array $plugin_meta;
/**
* Cron_Pixie constructor.
*
* Bare minimum to set up cron schedule for example events.
*
* @param array<string, string> $plugin_meta Plugin's meta data.
*/
public function __construct( array $plugin_meta = array() ) {
if ( empty( $plugin_meta ) ) {
return;
}
$this->plugin_meta = $plugin_meta;
// Add a schedule of our own for testing.
add_filter( 'cron_schedules', array( $this, 'filter_cron_schedules' ) );
// Process the "passed" event so that we can reschedule for the past again.
add_action( 'cron_pixie_passed_event', array( $this, 'reschedule_passed_event' ) );
}
/**
* Init the plugin, if being loaded in the right context.
*
* Registers all action and filter hooks if user can use widget.
*
* @return void
*/
public function init(): void {
// Using the plugin in the network admin dashboard makes no sense.
if ( is_network_admin() ) {
return;
}
// Usage of the plugin is restricted to Administrators.
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Add the widget during dashboard set up.
add_action( 'wp_dashboard_setup', array( $this, 'add_dashboard_widget' ) );
// Enqueue the CSS & JS scripts.
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
// AJAX handlers.
add_action( 'wp_ajax_cron_pixie_get_schedules', array( $this, 'ajax_get_schedules' ) );
add_action( 'wp_ajax_cron_pixie_update_event', array( $this, 'ajax_update_event' ) );
add_action( 'wp_ajax_cron_pixie_update_example_events_setting', array( $this, 'ajax_update_example_events_setting' ) );
add_action( 'wp_ajax_cron_pixie_update_auto_refresh_setting', array( $this, 'ajax_update_auto_refresh_setting' ) );
add_action( 'wp_ajax_cron_pixie_update_search_query_setting', array( $this, 'ajax_update_search_query_setting' ) );
}
/**
* Registers the widget and content callback.
*/
public function add_dashboard_widget(): void {
wp_add_dashboard_widget(
$this->plugin_meta['slug'],
$this->plugin_meta['name'],
array( $this, 'dashboard_widget_content' )
);
}
/**
* Provides the initial content for the widget.
*/
public function dashboard_widget_content(): void {
?>
<!-- Main content -->
<div id="cron-pixie-main"></div>
<?php
}
/**
* Enqueues the JS scripts when the main dashboard page is loading.
*
* @param string $hook_page Current page hook.
*/
public function enqueue_scripts( string $hook_page ): void {
if ( 'index.php' !== $hook_page ) {
return;
}
$script_handle = $this->plugin_meta['slug'] . '-main';
wp_enqueue_style(
$script_handle,
plugin_dir_url( $this->plugin_meta['file'] ) . 'css/main.css',
array(),
$this->plugin_meta['version']
);
wp_enqueue_script(
$script_handle,
plugin_dir_url( $this->plugin_meta['file'] ) . 'js/main.js',
array(),
$this->plugin_meta['version'],
true // Load JS in footer so that templates in DOM can be referenced.
);
$query = $this->get_user_setting( 'query' );
$query = is_string( $query ) && ! empty( $query ) ? $query : '';
// Add initial data to CronPixie JS object so it can be rendered without fetch.
// Also add translatable strings for JS as well as reference settings.
$data = array(
'strings' => array(
'no_events' => _x( '(none)', 'no event to show', 'wp-cron-pixie' ),
'due' => _x( 'due', 'label for when cron event date', 'wp-cron-pixie' ),
'now' => _x( 'now', 'cron event is due now', 'wp-cron-pixie' ),
'passed' => _x( 'passed', 'cron event is over due', 'wp-cron-pixie' ),
'weeks_abrv' => _x( 'w', 'displayed in interval', 'wp-cron-pixie' ),
'days_abrv' => _x( 'd', 'displayed in interval', 'wp-cron-pixie' ),
'hours_abrv' => _x( 'h', 'displayed in interval', 'wp-cron-pixie' ),
'minutes_abrv' => _x( 'm', 'displayed in interval', 'wp-cron-pixie' ),
'seconds_abrv' => _x( 's', 'displayed in interval', 'wp-cron-pixie' ),
'run_now' => _x( 'Run event now.', 'Tooltip for run now icon', 'wp-cron-pixie' ),
'refresh' => _x( 'Refresh Now', 'Tooltip for refresh now icon', 'wp-cron-pixie' ),
'schedules' => _x( 'Schedules', 'Title for list of schedules', 'wp-cron-pixie' ),
'search' => _x( 'Search', 'Title for search box', 'wp-cron-pixie' ),
'example_events' => _x(
'Example Events',
'Label for Example Events checkbox',
'wp-cron-pixie'
),
'example_events_tooltip' => _x(
'Include some example events in the cron schedule',
'Tooltip for Example Events checkbox',
'wp-cron-pixie'
),
'auto_refresh' => _x( 'Auto Refresh', 'Label for Auto Refresh checkbox', 'wp-cron-pixie' ),
'auto_refresh_tooltip' => _x(
'Refresh the display of cron events every 5 seconds',
'Tooltip for Auto Refresh checkbox',
'wp-cron-pixie'
),
),
'admin_url' => untrailingslashit( admin_url() ),
'nonce' => wp_create_nonce( self::NONCE_CONTEXT ),
'timer_period' => 5, // How often should display be updated, in seconds.
'data' => array(
'schedules' => $this->get_schedules( $query ),
),
'query' => $query,
'example_events' => (bool) $this->get_setting( 'example_events' ),
'auto_refresh' => (bool) $this->get_user_setting( 'auto_refresh' ),
'version' => $this->plugin_meta['version'],
);
wp_localize_script( $script_handle, 'CronPixie', $data );
}
/**
* Returns list of cron schedules.
*
* @param string $query Search string.
*
* @return array<array<string, mixed>>
*/
private function get_schedules( string $query = '' ): array {
// If we're getting schedules, widget should be visible.
// Therefore we may or may not want our example events available.
$this->manage_example_events();
// Get list of schedules.
$schedules = wp_get_schedules();
// Append a "Once Only" schedule.
$schedules['once'] = array(
'display' => __( 'Once Only', 'wp-cron-pixie' ),
);
// Get list of jobs assigned to schedules.
// Using "private" function is really naughty, but it's the best option compared to querying db/options.
$cron_array = _get_cron_array();
// Consistent timestamp for seconds until due.
$now = time();
// Add child cron events to schedules.
foreach ( $cron_array as $timestamp => $jobs ) {
foreach ( $jobs as $hook => $events ) {
foreach ( $events as $event ) {
$event['hook'] = $hook;
$event['timestamp'] = $timestamp;
$event['seconds_due'] = $timestamp - $now;
// The cron array also includes events without a recurring schedule.
$scheduled = empty( $event['schedule'] ) ? 'once' : $event['schedule'];
$schedules[ $scheduled ]['events'][] = $event;
}
}
}
// We need to change the associative array (map) into an indexed one (set) for easier use in collection.
// As we're looping over all the schedules, this is a good time to filter them too.
// And if any schedles are empty, might as well drop them too.
$set = array();
if ( empty( $schedules ) ) {
return $set;
}
foreach ( $schedules as $name => $schedule ) {
if ( empty( $schedule['events'] ) ) {
continue;
}
// Include entire schedule?
$matched = self::filter_schedule( $schedule, $name, $query );
// Just include select events from the schedule?
if ( ! $matched && ! empty( $schedule['events'] ) ) {
$schedule['events'] = self::filter_events( $schedule['events'], $query );
$matched = ! empty( $schedule['events'] );
}
// Nothing matched, skip entire schedule.
if ( ! $matched ) {
continue;
}
$schedule['name'] = $name;
$set[] = $schedule;
}
return $set;
}
/**
* Does schedule info match on query string?
*
* @param array<mixed, mixed> $schedule Schedule data.
* @param string $schedule_name Schedule's name.
* @param string $query Search string.
*
* @return bool
*/
private static function filter_schedule(
$schedule,
$schedule_name,
string $query = ''
): bool {
// Empty query string, accept all scedules.
if ( empty( $query ) ) {
return true;
}
// Schedule name contains string, shortcut out.
if ( false !== stripos( $schedule_name, $query ) ) {
return true;
}
// Check top level values for matches, but not events.
foreach ( $schedule as $value ) {
$found = false;
if ( is_string( $value ) ) {
$found = false !== stripos( $value, $query );
} elseif ( is_int( $value ) ) {
$found = false !== stripos( sprintf( '%d', $value ), $query );
} elseif ( is_float( $value ) ) {
$found = stripos( sprintf( '%0.0f', $value ), $query );
}
if ( $found ) {
return true;
}
}
return false;
}
/**
* Return events that have details that match on the query string.
*
* @param array<mixed, mixed> $events Events data.
* @param string $query Search string.
*
* @return array<mixed, mixed>
*/
private static function filter_events( $events, string $query = '' ): array {
// Empty query string, accept all events.
if ( empty( $query ) || empty( $events ) || ! is_array( $events ) ) {
return array();
}
// Re-indexed filtered events to ensure they are used as list rather than object.
return array_values(
array_filter(
$events,
function ( $event, $key ) use ( $query ) {
return self::filter_event( $event, $key, $query );
},
ARRAY_FILTER_USE_BOTH
)
);
}
/**
* Does event info match on query string?
*
* @param mixed $event Events data.
* @param int|string $key Array key.
* @param string $query Search string.
*
* @return bool
*/
private static function filter_event( $event, $key, string $query = '' ): bool {
// Empty query string, accept event.
if ( empty( $query ) ) {
return true;
}
if ( is_string( $event ) ) {
return false !== stripos( $event, $query );
} elseif ( is_int( $event ) ) {
return false !== stripos( sprintf( '%d', $event ), $query );
} elseif ( is_float( $event ) ) {
return false !== stripos( sprintf( '%0.0f', $event ), $query );
} elseif ( is_array( $event ) ) {
// For event args, check associative array keys too.
if ( 'args' === $key ) {
foreach ( array_keys( $event ) as $idx => $value ) {
$found = self::filter_event( $value, $idx, $query );
if ( $found ) {
return true;
}
}
}
foreach ( $event as $idx => $value ) {
$found = self::filter_event( $value, $idx, $query );
if ( $found ) {
return true;
}
}
}
return false;
}
/**
* Send a response to ajax request, as JSON.
*
* @param mixed $response Data to be returned.
*/
private function ajax_return( $response = true ): void {
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response );
}
wp_send_json_success( $response );
}
/**
* Displays a JSON encoded list of cron schedules.
*/
public function ajax_get_schedules(): void {
check_ajax_referer( self::NONCE_CONTEXT );
if ( ! isset( $_REQUEST['query'] ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-get-schedules-query-setting-missing-value',
__( 'No value given for the search query string to filter schedules.', 'wp-cron-pixie' )
)
);
exit;
}
$query = is_string( $_REQUEST['query'] ) && ! empty( $_REQUEST['query'] ) ? $_REQUEST['query'] : '';
$this->ajax_return( $this->get_schedules( $query ) );
}
/**
* Run a cron event now rather than later.
*/
public function ajax_update_event(): void {
check_ajax_referer( self::NONCE_CONTEXT );
if ( ! isset( $_REQUEST['event'] ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'No event given to be run now.', 'wp-cron-pixie' )
)
);
exit;
}
$event = ! empty( $_REQUEST['event'] ) && is_string( $_REQUEST['event'] ) ? $_REQUEST['event'] : '';
if ( empty( $event ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'Type invalid for JSON data given for event to be run now.', 'wp-cron-pixie' )
)
);
exit;
}
$event = json_decode( stripcslashes( $event ), true );
$this->ajax_return( $this->update_event( $event ) );
}
/**
* Update an event.
*
* @param mixed $event Event data to be updated.
*
* @return bool|WP_Error
*/
private function update_event( $event ) {
if ( ! is_array( $event ) ) {
return new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'JSON data did not decode to an array for given for event to be run now.', 'wp-cron-pixie' )
);
}
if ( empty( $event['hook'] ) || ! is_string( $event['hook'] ) ) {
return new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'No hook value given for event to be run now.', 'wp-cron-pixie' )
);
}
if ( ! isset( $event['args'] ) ) {
return new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'No args value given for event to be run now.', 'wp-cron-pixie' )
);
}
if ( ! isset( $event['schedule'] ) ) {
return new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'No schedule value given for event to be run now.', 'wp-cron-pixie' )
);
}
if ( ! isset( $event['timestamp'] ) ) {
return new WP_Error(
'cron-pixie-update-event-missing-value',
__( 'No timestamp value given for event to be run now.', 'wp-cron-pixie' )
);
}
$event['args'] = empty( $event['args'] ) ? array() : $event['args'];
$now = time();
$schedule = wp_get_schedule( $event['hook'], $event['args'] );
$timestamp = wp_next_scheduled( $event['hook'], $event['args'] );
// If not expecting a schedule, but cron says it's on one, do nothing.
if ( 'false' === $event['schedule'] && ! empty( $schedule ) ) {
return new WP_Error(
'cron-pixie-update-event-scheduled-single',
__( 'The single event is also in a schedule.', 'wp-cron-pixie' )
);
}
// If expecting a schedule, but cron says it's not on one, do nothing.
if ( 'false' !== strtolower( $event['schedule'] ) && empty( $schedule ) ) {
return new WP_Error(
'cron-pixie-update-event-schedule-missing',
__( 'The scheduled event is not scheduled.', 'wp-cron-pixie' )
);
}
// We only want to reschedule an event if it already exists and is in the future.
if ( false !== $timestamp && $now < $timestamp ) {
$unscheduled = wp_unschedule_event( $timestamp, $event['hook'], $event['args'], true );
if ( is_wp_error( $unscheduled ) ) {
$this->ajax_return( $unscheduled );
}
if ( 'false' === strtolower( $event['schedule'] ) ) {
$scheduled = wp_schedule_single_event( $event['timestamp'], $event['hook'], $event['args'], true );
} else {
$scheduled = wp_schedule_event( $event['timestamp'], $event['schedule'], $event['hook'], $event['args'], true );
}
if ( is_wp_error( $scheduled ) ) {
return $scheduled;
}
}
// Tell cron system to have a go at running due events.
spawn_cron();
return true;
}
/**
* Update the setting for whether example events should be included in the cron.
*/
public function ajax_update_example_events_setting(): void {
check_ajax_referer( self::NONCE_CONTEXT );
if ( ! isset( $_REQUEST['example_events'] ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-update-example-events-setting-missing-value',
__( 'No value given for whether Example Events should be included in cron.', 'wp-cron-pixie' )
)
);
exit;
}
$example_events = ! empty( $_REQUEST['example_events'] ) && is_string( $_REQUEST['example_events'] );
$this->ajax_return( $this->update_example_events_setting( $example_events ) );
}
/**
* Update the setting for whether example events should be included in the cron.
*
* @param bool $example_events Example events setting.
*
* @return bool|WP_Error
*/
private function update_example_events_setting( bool $example_events ) {
$settings = get_option( self::SETTINGS_KEY );
if ( is_array( $settings ) ) {
$settings['example_events'] = $example_events;
} else {
$settings = array( 'example_events' => $example_events );
}
if ( ! update_option( self::SETTINGS_KEY, $settings ) ) {
return new WP_Error(
'cron-pixie-update-example-events-setting-update-settings',
__( 'Could not update settings.', 'wp-cron-pixie' )
);
}
// As we've potentially changed whether example events are needed,
// proactively add or remove them.
$this->manage_example_events();
return true;
}
/**
* Update the setting for whether the display should auto refresh.
*/
public function ajax_update_auto_refresh_setting(): void {
check_ajax_referer( self::NONCE_CONTEXT );
if ( ! isset( $_REQUEST['auto_refresh'] ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-update-auto-refresh-setting-missing-value',
__( 'No value given for whether the display should auto refresh.', 'wp-cron-pixie' )
)
);
exit;
}
$auto_refresh = ! empty( $_REQUEST['auto_refresh'] ) && is_string( $_REQUEST['auto_refresh'] );
$this->ajax_return( $this->update_auto_refresh_setting( $auto_refresh ) );
}
/**
* Update the setting for whether the display should auto refresh.
*
* @param bool $auto_refresh Auto refresh setting.
*
* @return bool|WP_Error
*/
private function update_auto_refresh_setting( bool $auto_refresh ) {
$settings = get_user_meta( get_current_user_id(), self::SETTINGS_KEY, true );
if ( is_array( $settings ) ) {
$settings['auto_refresh'] = $auto_refresh;
} else {
$settings = array( 'auto_refresh' => $auto_refresh );
}
if ( ! update_user_meta( get_current_user_id(), self::SETTINGS_KEY, $settings ) ) {
return new WP_Error(
'cron-pixie-update-auto-refresh-setting-update-settings',
__( 'Could not update settings.', 'wp-cron-pixie' )
);
}
return true;
}
/**
* Update the setting for the search query string to filter schedules.
*/
public function ajax_update_search_query_setting(): void {
check_ajax_referer( self::NONCE_CONTEXT );
if ( ! isset( $_REQUEST['query'] ) ) {
$this->ajax_return(
new WP_Error(
'cron-pixie-update-search-query-setting-missing-value',
__( 'No value given for the search query string to filter schedules.', 'wp-cron-pixie' )
)
);
exit;
}
$query = is_string( $_REQUEST['query'] ) && ! empty( $_REQUEST['query'] ) ? $_REQUEST['query'] : '';
$this->ajax_return( $this->update_search_query_setting( $query ) );
}
/**
* Update the setting for the search query string to filter schedules.
*
* @param string $query Query string setting.
*
* @return bool|WP_Error
*/
private function update_search_query_setting( string $query ) {
$settings = get_user_meta( get_current_user_id(), self::SETTINGS_KEY, true );
if ( is_array( $settings ) ) {
$settings['query'] = $query;
} else {
$settings = array( 'query' => $query );
}
if ( ! update_user_meta( get_current_user_id(), self::SETTINGS_KEY, $settings ) ) {
return new WP_Error(
'cron-pixie-update-search-query-setting-update-settings',
__( 'Could not update settings.', 'wp-cron-pixie' )
);
}
return true;
}
/**
* Adds an "every_minute" schedule to the Schedules list.
*
* @param array<string, array<string, mixed>> $schedules Current schedules.
*
* @return array<string, array<string, mixed>>
*/
public function filter_cron_schedules( array $schedules = array() ): array {
$schedules['every_minute'] = array(
'interval' => 60,
'display' => __( 'Every Minute', 'wp-cron-pixie' ),
);
return $schedules;
}
/**
* Reschedules the passed event when due so that it is due in the past again.
*
* @param mixed $wibble Args for the event.
*/
public function reschedule_passed_event( $wibble ): void {
$args = array( 'wibble' => $wibble );
// Remove the event that has already been missed (2 minutes over due).
// Elsewhere the event will be recreated if example events are turned on.
if ( wp_next_scheduled( 'cron_pixie_passed_event', $args ) ) {
wp_clear_scheduled_hook( 'cron_pixie_passed_event', $args );
}
}
/**
* Create or remove example events based on setting.
*/
private function manage_example_events(): void {
if ( $this->get_setting( 'example_events' ) ) {
$this->create_example_events();
} else {
$this->remove_example_events();
}
}
/**
* Creates test cron events in the cron schedule if they do not already exist.
*/
private function create_example_events(): void {
$args = array( 'wibble' => 'wobble' );
// Create an event that has already been missed (2 minutes overdue).
if ( ! wp_next_scheduled( 'cron_pixie_passed_event', $args ) ) {
wp_schedule_event( time() - 120, 'every_minute', 'cron_pixie_passed_event', $args );
}
// Create an event that is just coming up (initially 30 seconds until due).
if ( ! wp_next_scheduled( 'cron_pixie_future_event', $args ) ) {
wp_schedule_event( time() + 30, 'every_minute', 'cron_pixie_future_event', $args );
}
// Create a single event that is in the future (initially 5 minutes until due).
if ( ! wp_next_scheduled( 'cron_pixie_single_event', $args ) ) {
wp_schedule_single_event( time() + 300, 'cron_pixie_single_event', $args );
}
}
/**
* Remove test cron events from the cron schedule if they already exist.
*/
private function remove_example_events(): void {
$args = array( 'wibble' => 'wobble' );
// Remove the event that has already been missed (2 minutes over due).
if ( wp_next_scheduled( 'cron_pixie_passed_event', $args ) ) {
wp_clear_scheduled_hook( 'cron_pixie_passed_event', $args );
}
// Remove the event that is just coming up (initially 30 seconds until due).
if ( wp_next_scheduled( 'cron_pixie_future_event', $args ) ) {
wp_clear_scheduled_hook( 'cron_pixie_future_event', $args );
}
// Remove the single event that is in the future (initially 5 minutes until due).
if ( wp_next_scheduled( 'cron_pixie_single_event', $args ) ) {
wp_clear_scheduled_hook( 'cron_pixie_single_event', $args );
}
}
/**
* Get a single setting based on its key name.
*
* @param string $key Setting key.
*
* @return mixed defaults to false if not found
*/
private function get_setting( string $key ) {
$value = false;
$settings = get_option( self::SETTINGS_KEY );
if ( ! empty( $settings ) && is_array( $settings ) && isset( $settings[ $key ] ) ) {
$value = $settings[ $key ];
}
return $value;
}
/**
* Get a single user setting based on its key name.
*
* @param string $key Setting key.
*
* @return mixed defaults to false if not found
*/
private function get_user_setting( string $key ) {
$value = false;
$settings = get_user_meta( get_current_user_id(), self::SETTINGS_KEY, true );
if ( ! empty( $settings ) && is_array( $settings ) && isset( $settings[ $key ] ) ) {
$value = $settings[ $key ];
}
return $value;
}
}