class-cron-options-cpt.php 10.3 KB
Newer Older
1
<?php
2
3
4

namespace WP_Cron_Control_Revisited;

5
class Cron_Options_CPT extends Singleton {
6
7
8
9
10
11
12
	/**
	 * PLUGIN SETUP
	 */

	/**
	 * Class properties
	 */
Erick Hitter's avatar
Erick Hitter committed
13
14
	const LOCK = 'create-jobs';

15
16
	const POST_TYPE   = 'wpccr_events';
	const POST_STATUS = 'inherit';
17

18
19
	private $posts_to_clean = array();

20
21
	private $option_before_unscheduling = null;

22
23
24
	/**
	 * Register hooks
	 */
25
	protected function class_init() {
26
		// Data storage
27
		add_action( 'init', array( $this, 'register_post_type' ) );
28

Erick Hitter's avatar
Erick Hitter committed
29
30
31
		// Lock for post insertion, to guard against endless event creation when `wp_next_scheduled()` is misused
		Lock::prime_lock( self::LOCK );

32
33
34
		// Option interception
		add_filter( 'pre_option_cron', array( $this, 'get_option' ) );
		add_filter( 'pre_update_option_cron', array( $this, 'update_option' ), 10, 2 );
35
36
37
38
39
40
	}

	/**
	 * Register a private post type to store cron events
	 */
	public function register_post_type() {
41
		register_post_type( self::POST_TYPE, array(
42
43
44
45
46
			'label'               => 'Cron Events',
			'public'              => false,
			'rewrite'             => false,
			'export'              => false,
			'exclude_from_search' => true,
47
		) );
48
49
50

		// Clear caches for any manually-inserted posts, lest stale caches be used
		if ( ! empty( $this->posts_to_clean ) ) {
51
			foreach ( $this->posts_to_clean as $index => $post_to_clean ) {
52
				clean_post_cache( $post_to_clean );
53
				unset( $this->posts_to_clean[ $index ] );
54
55
			}
		}
56
57
58
59
60
	}

	/**
	 * PLUGIN FUNCTIONALITY
	 */
61
62
63
64

	/**
	 * Override cron option requests with data from CPT
	 */
65
	public function get_option() {
66
67
68
69
70
		$cron_array = array(
			'version' => 2, // Core versions the cron array; without this, events will continually requeue
		);

		// Get events to re-render as the cron option
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
		$page  = 1;

		do {
			$jobs_posts = $this->get_jobs( array(
				'post_type'        => self::POST_TYPE,
				'post_status'      => self::POST_STATUS,
				'suppress_filters' => false,
				'posts_per_page'   => 100,
				'paged'            => $page,
				'orderby'          => 'date',
				'order'            => 'ASC',
			) );

			// Nothing more to add
			if ( empty( $jobs_posts ) ) {
				break;
			}
88

89
			$page++;
90

91
92
93
94
			// Loop through results and built output Core expects
			if ( ! empty( $jobs_posts ) ) {
				foreach ( $jobs_posts as $jobs_post ) {
					$timestamp = strtotime( $jobs_post->post_date_gmt );
95

96
97
98
99
					$job_args = maybe_unserialize( $jobs_post->post_content_filtered );
					if ( ! is_array( $job_args ) ) {
						continue;
					}
100

101
102
103
					$action   = $job_args['action'];
					$instance = $job_args['instance'];
					$args     = $job_args['args'];
104

105
106
107
108
					$cron_array[ $timestamp ][ $action ][ $instance ] = array(
						'schedule' => $args['schedule'],
						'args'     => $args['args'],
					);
109

110
111
112
113
114
					if ( isset( $args['interval'] ) ) {
						$cron_array[ $timestamp ][ $action ][ $instance ]['interval'] = $args['interval'];
					}

				}
115
			}
116
		} while( true );
117

118
		uksort( $cron_array, 'strnatcasecmp' );
119

120
121
122
123
124
125
126
		// If we're unscheduling an event, hold onto the previous value so we can identify what's unscheduled
		if ( $this->is_unscheduling() ) {
			$this->option_before_unscheduling = $cron_array;
		} else {
			$this->option_before_unscheduling = null;
		}

127
128
129
130
		return $cron_array;
	}

	/**
131
	 * Handle requests to update the cron option
132
133
134
135
	 *
	 * By returning $old_value, `cron` option won't be updated
	 */
	public function update_option( $new_value, $old_value ) {
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
		if ( $this->is_unscheduling() ) {
			$this->unschedule_job( $new_value, $this->option_before_unscheduling );
		} else {
			$this->convert_option( $new_value );
		}

		return $old_value;
	}

	/**
	 * Delete jobs that are unscheduled using `wp_unschedule_event()`
	 */
	private function unschedule_job( $new_value, $old_value ) {
		$jobs = $this->find_unscheduled_jobs( $new_value, $old_value );

		foreach ( $jobs as $job ) {
			$this->delete_job( $job['timestamp'], $job['action'], $job['instance'] );
		}
	}

	/**
	 * Save cron events in CPT
	 */
	private function convert_option( $new_value ) {
160
		if ( is_array( $new_value ) && ! empty( $new_value ) ) {
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
			$events = collapse_events_array( $new_value );

			foreach ( $events as $event ) {
				$job_exists = $this->job_exists( array(
					'name'             => sprintf( '%s-%s-%s', $event['timestamp'], md5( $event['action'] ), $event['instance'] ),
					'post_type'        => self::POST_TYPE,
					'post_status'      => self::POST_STATUS,
					'suppress_filters' => false,
					'posts_per_page'   => 1,
				) );

				if ( ! $job_exists ) {
					// Build minimum information needed to create a post
					$job_post = array(
						'post_title'            => sprintf( '%s | %s | %s', $event['timestamp'], $event['action'], $event['instance'] ),
						'post_name'             => sprintf( '%s-%s-%s', $event['timestamp'], md5( $event['action'] ), $event['instance'] ),
						'post_content_filtered' => maybe_serialize( array(
							'action'   => $event['action'],
							'instance' => $event['instance'],
							'args'     => $event['args'],
						) ),
						'post_date'             => date( 'Y-m-d H:i:s', $event['timestamp'] ),
						'post_date_gmt'         => date( 'Y-m-d H:i:s', $event['timestamp'] ),
						'post_type'             => self::POST_TYPE,
						'post_status'           => self::POST_STATUS,
					);
187

188
					$this->create_job( $job_post );
189
190
191
192
193
194
195
196
197
				}
			}
		}
	}

	/**
	 * PLUGIN UTILITY METHODS
	 */

198
199
200
201
202
203
204
205
206
207
208
209
	/**
	 * Retrieve list of jobs, respecting whether or not the CPT is registered
	 */
	private function get_jobs( $args ) {
		// If called before `init`, we need to query directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
			return get_posts( $args );
		} else {
			global $wpdb;

			$orderby = 'date' === $args['orderby'] ? 'post_date' : $args['orderby'];

210
211
212
213
214
215
216
217
			if ( isset( $args['paged'] ) ) {
				$paged  = max( 0, $args['paged'] - 1 );
				$offset = $paged * $args['posts_per_page'];
			} else {
				$offset = 0;
			}

			return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s ORDER BY %s %s LIMIT %d,%d;", $args['post_type'], $args['post_status'], $orderby, $args['order'], $offset, $args['posts_per_page'] ), 'OBJECT' );
218
219
220
		}
	}

221
222
223
224
225
226
227
228
229
230
	/**
	 * Check if a job post exists, respecting Core's loading order
	 */
	private function job_exists( $job_post ) {
		// If called before `init`, we need to insert directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
			$exists = get_posts( $job_post );
		} else {
			global $wpdb;

231
			$exists = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND post_status = %s LIMIT 1;", $job_post['name'], self::POST_TYPE, self::POST_STATUS ) );
232
233
		}

234
		return empty( $exists ) ? false : array_shift( $exists );
235
236
237
238
239
	}

	/**
	 * Create a job post, respecting whether or not Core is ready for CPTs
	 */
240
	private function create_job( $job_post ) {
Erick Hitter's avatar
Erick Hitter committed
241
242
243
244
245
		// Limit how many events to insert at once
		if ( ! Lock::check_lock( self::LOCK, 5 ) ) {
			return false;
		}

246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
		// If called before `init`, we need to insert directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
			wp_insert_post( $job_post );
		} else {
			global $wpdb;

			// Additional data needed to manually create a post
			$job_post = wp_parse_args( $job_post, array(
				'post_author'       => 0,
				'comment_status'    => 'closed',
				'ping_status'       => 'closed',
				'post_parent'       => 0,
				'post_modified'     => current_time( 'mysql' ),
				'post_modified_gmt' => current_time( 'mysql', true ),
			) );

			// Some sanitization in place of `sanitize_post()`, which we can't use this early
			foreach ( array( 'post_title', 'post_name', 'post_content_filtered' ) as $field ) {
				$job_post[ $field ] = sanitize_text_field( $job_post[ $field ] );
			}

			// Duplicate some processing performed in `wp_insert_post()`
			$charset = $wpdb->get_col_charset( $wpdb->posts, 'post_title' );
			if ( 'utf8' === $charset ) {
				$job_post['post_title'] = wp_encode_emoji( $job_post['post_title'] );
			}

			$job_post = wp_unslash( $job_post );

			// Set this so it isn't empty, even though it serves us no purpose
276
			$job_post['guid'] = esc_url( add_query_arg( self::POST_TYPE, $job_post['post_name'], home_url( '/' ) ) );
277
278
279
280
281
282
283
284
285

			// Create the post
			$inserted = $wpdb->insert( $wpdb->posts, $job_post );

			// Clear caches for new posts once the post type is registered
			if ( $inserted ) {
				$this->posts_to_clean[] = $wpdb->insert_id;
			}
		}
Erick Hitter's avatar
Erick Hitter committed
286
287
288

		// Allow more events to be created
		Lock::free_lock( self::LOCK );
289
290
	}

291
292
293
294
295
296
297
298
299
	/**
	 * Remove an event's CPT entry
	 *
	 * @param $timestamp  int     Unix timestamp
	 * @param $action     string  name of action used when the event is registered (unhashed)
	 * @param $instance   string  md5 hash of the event's arguments array, which Core uses to index the `cron` option
	 *
	 * @return bool
	 */
300
301
	private function delete_job( $timestamp, $action, $instance ) {
		$job = $this->job_exists( array(
302
			'name'             => sprintf( '%s-%s-%s', $timestamp, md5( $action ), $instance ),
303
304
			'post_type'        => self::POST_TYPE,
			'post_status'      => self::POST_STATUS,
305
306
307
308
			'suppress_filters' => false,
			'posts_per_page'   => 1,
		) );

309
		if ( ! $job ) {
310
311
312
			return false;
		}

313
314
315
316
317
318
319
320
321
322
323
324
		// If called before `init`, we need to delete directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
			wp_delete_post( $job->ID, true );
		} else {
			global $wpdb;

			$wpdb->delete( $wpdb->postmeta, array( 'post_id' => $job->ID, ) );
			$wpdb->delete( $wpdb->posts, array( 'ID' => $job->ID, ) );

			$this->posts_to_clean[] = $job->ID;
		}

325
326
		return true;
	}
327
328
329
330
331
332
333
334
335
336
337
338
339
340

	/**
	 * Determine if current request is a call to `wp_unschedule_event()`
	 */
	private function is_unscheduling() {
		return false !== array_search( 'wp_unschedule_event', wp_debug_backtrace_summary( __CLASS__, null, false ) );
	}

	/**
	 * Identify jobs unscheduled using `wp_unschedule_event()` by comparing current value with previous
	 */
	private function find_unscheduled_jobs( $new, $old ) {
		$differences = array();

341
		$old = collapse_events_array( $old );
342

343
344
345
346
347
348
349
350
351
352
353
		foreach ( $old as $event ) {
			$timestamp = $event['timestamp'];
			$action    = $event['action'];
			$instance  = $event['instance'];

			if ( ! isset( $new[ $timestamp ][ $action ][ $instance ] ) ) {
				$differences[] = array(
					'timestamp' => $timestamp,
					'action'    => $action,
					'instance'  => $instance,
				);
354
355
356
357
358
			}
		}

		return $differences;
	}
359
360
361
}

Cron_Options_CPT::instance();