PluginProbe
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher / trunk
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher vtrunk
5.3.4 5.3.3 5.3.2 5.3.1 5.3.0 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.1.8 5.1.9 5.2.0 5.2.1 5.2.10 5.2.11 5.2.12 5.2.13 5.2.14 5.2.15 5.2.16 5.2.17 5.2.18 5.2.2 5.2.3 All 118 releases
wp-scheduled-posts / includes / API / PostPanel.php

PostPanel.php in SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher trunk, at includes/API/PostPanel.php

546 lines 21.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPSP\API;
4
5 /**
6 * Post Panel REST API
7 *
8 * After saving, fires `schedulepress_after_free_settings_save` so the
9 * Pro plugin (and any other extension) can handle their own fields
10 * without touching this endpoint.
11 *
12 * Endpoint: POST /wp-json/wp-scheduled-posts/v1/post-panel/{post_id}
13 * Endpoint: GET /wp-json/wp-scheduled-posts/v1/post-panel/{post_id}
14 *
15 * @since 5.3.0
16 */
17 class PostPanel {
18
19 /**
20 * Singleton instance.
21 *
22 * @var self|null
23 */
24 protected static $instance = null;
25
26 private function __construct() {
27 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
28 }
29
30 /**
31 * Register REST routes.
32 */
33 public function register_routes() {
34 $namespace = WPSP_PLUGIN_SLUG . '/v1';
35 $route = '/post-panel/(?P<post_id>\d+)';
36
37 register_rest_route( $namespace, $route, [
38 'methods' => \WP_REST_Server::CREATABLE,
39 'callback' => [ $this, 'save_settings' ],
40 'permission_callback' => [ $this, 'permission_check' ],
41 'args' => [
42 'post_id' => [
43 'required' => true,
44 'validate_callback' => function ( $param ) { return is_numeric( $param ); },
45 'sanitize_callback' => 'absint',
46 ],
47 'schedule_date' => [
48 'required' => false,
49 'type' => 'string',
50 'sanitize_callback' => 'sanitize_text_field',
51 'default' => '',
52 ],
53 'is_scheduled' => [
54 'required' => false,
55 'type' => 'boolean',
56 'default' => false,
57 ],
58 ],
59 ] );
60
61 register_rest_route( $namespace, $route, [
62 'methods' => \WP_REST_Server::READABLE,
63 'callback' => [ $this, 'get_settings' ],
64 'permission_callback' => [ $this, 'permission_check' ],
65 ] );
66
67 // "Publish future post immediately" action endpoint. Moved from the Pro
68 // plugin so the feature works in Free. Used by the post-panel buttons.
69 register_rest_route( $namespace, '/update-settings/(?P<post_id>\d+)', [
70 'methods' => \WP_REST_Server::CREATABLE,
71 'callback' => [ $this, 'publish_immediately' ],
72 'permission_callback' => [ $this, 'permission_check' ],
73 'args' => [
74 'post_id' => [
75 'required' => true,
76 'validate_callback' => function ( $param ) { return is_numeric( $param ); },
77 'sanitize_callback' => 'absint',
78 ],
79 ],
80 ] );
81
82 // Turning "publish future post immediately" back off. Without this the
83 // intent is sticky and invisible: it survives every later save, and the
84 // buttons that set it are hidden once the post reaches 'publish'.
85 register_rest_route( $namespace, '/update-settings/(?P<post_id>\d+)', [
86 'methods' => \WP_REST_Server::DELETABLE,
87 'callback' => [ $this, 'clear_publish_immediately' ],
88 'permission_callback' => [ $this, 'permission_check' ],
89 'args' => [
90 'post_id' => [
91 'required' => true,
92 'validate_callback' => function ( $param ) { return is_numeric( $param ); },
93 'sanitize_callback' => 'absint',
94 ],
95 ],
96 ] );
97 }
98
99 /**
100 * Permission callback – user must be able to edit the specific post.
101 *
102 * @param \WP_REST_Request $request
103 * @return bool|\WP_Error
104 */
105 public function permission_check( \WP_REST_Request $request ) {
106 $post_id = (int) $request->get_param( 'post_id' );
107 if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
108 return new \WP_Error(
109 'rest_forbidden',
110 __( 'You do not have permission to edit this post.', 'wp-scheduled-posts' ),
111 [ 'status' => 403 ]
112 );
113 }
114 return true;
115 }
116
117 /**
118 * GET handler – return current scheduling state.
119 *
120 * @param \WP_REST_Request $request
121 * @return \WP_REST_Response
122 */
123 public function get_settings( \WP_REST_Request $request ) {
124 $post_id = (int) $request->get_param( 'post_id' );
125 $post = get_post( $post_id );
126
127 if ( ! $post ) {
128 return new \WP_REST_Response( [
129 'success' => false,
130 'message' => __( 'Post not found.', 'wp-scheduled-posts' ),
131 ], 404 );
132 }
133
134 // The stored value is the post_date the intent was recorded against.
135 // includes/functions.php only keeps forcing 'publish' while the two
136 // still match, so a stale row is not active state and is not reported.
137 $prevent_future_post = get_post_meta( $post_id, 'prevent_future_post', true );
138 $is_preventing = ! empty( $prevent_future_post )
139 && $prevent_future_post === $post->post_date;
140
141 return new \WP_REST_Response( [
142 'success' => true,
143 'data' => [
144 'schedule_date' => $post->post_status === 'future' ? $post->post_date : '',
145 'post_status' => $post->post_status,
146 'prevent_future_post' => $is_preventing,
147 'prevent_future_post_date' => $is_preventing ? $prevent_future_post : '',
148 ],
149 ], 200 );
150 }
151
152 /**
153 * POST handler – save free-tier fields then fire hook for extensions.
154 *
155 * @param \WP_REST_Request $request
156 * @return \WP_REST_Response
157 */
158 public function save_settings( \WP_REST_Request $request ) {
159 $post_id = (int) $request->get_param( 'post_id' );
160 $schedule_date = $request->get_param( 'schedule_date' );
161 $is_scheduled = (bool) $request->get_param( 'is_scheduled' );
162
163 $post = get_post( $post_id );
164 if ( ! $post ) {
165 return new \WP_REST_Response( [
166 'success' => false,
167 'message' => __( 'Post not found.', 'wp-scheduled-posts' ),
168 ], 404 );
169 }
170
171 // ── Free feature: schedule_date ───────────────────────────────────────
172 if ( $is_scheduled && ! empty( $schedule_date ) ) {
173 $post_date = date( 'Y-m-d H:i:s', strtotime( $schedule_date ) );
174 $post_date_gmt = get_gmt_from_date( $post_date );
175
176 wp_update_post( [
177 'ID' => $post_id,
178 'post_date' => $post_date,
179 'post_date_gmt' => $post_date_gmt,
180 'post_status' => 'future',
181 'edit_date' => true,
182 ] );
183 }
184
185 /**
186 * Fires after the Free plugin has saved its own post-panel fields.
187 *
188 * Pro plugin and any third-party extension should hook here to process
189 * their own fields (unpublish_on, republish_on, advanced scheduling, …).
190 * Do NOT handle free-tier fields (e.g. schedule_date) inside this hook.
191 *
192 * @since 5.3.0
193 *
194 * @param int $post_id The post ID.
195 * @param \WP_REST_Request $request The full REST request object.
196 * Extensions can read any additional
197 * params they need directly from it.
198 */
199 do_action( 'schedulepress_after_free_settings_save', $post_id, $request );
200
201 return new \WP_REST_Response( [
202 'success' => true,
203 'message' => __( 'Settings saved successfully.', 'wp-scheduled-posts' ),
204 ], 200 );
205 }
206
207 /**
208 * POST handler – immediately publish a scheduled (future) post.
209 *
210 * Backs the "Publish future post immediately" controls in the post panel.
211 * Moved from the Pro plugin so the feature is available in Free.
212 *
213 * @param \WP_REST_Request $request
214 * @return \WP_REST_Response
215 */
216 public function publish_immediately( \WP_REST_Request $request ) {
217 $post_id = (int) $request->get_param( 'post_id' );
218 $post = get_post( $post_id );
219
220 if ( ! $post ) {
221 return new \WP_REST_Response( [
222 'success' => false,
223 'message' => __( 'Post not found.', 'wp-scheduled-posts' ),
224 ], 404 );
225 }
226
227 $use_current_date = $this->is_flag_set( $request->get_param( 'publish_immediately_current_date' ) );
228 $use_future_date = $this->is_flag_set( $request->get_param( 'publish_immediately_future_date' ) );
229
230 // Exactly one action has to be named. Neither flag meant nothing ran and
231 // the route still answered "Post published successfully", and both flags
232 // meant two conflicting writes with only the last one surviving.
233 if ( $use_current_date === $use_future_date ) {
234 return new \WP_REST_Response( [
235 'success' => false,
236 'message' => __( 'Choose exactly one of publish_immediately_current_date or publish_immediately_future_date.', 'wp-scheduled-posts' ),
237 ], 400 );
238 }
239
240 $result = $use_current_date
241 ? $this->handle_post_published( $post_id )
242 : $this->handle_post_publish_on_future_date( $post_id );
243
244 if ( is_wp_error( $result ) ) {
245 $status = $result->get_error_code() === 'wpsp_not_future_dated' ? 400 : 500;
246 return new \WP_REST_Response( [
247 'success' => false,
248 'message' => $result->get_error_message(),
249 ], $status );
250 }
251
252 return new \WP_REST_Response( [
253 'success' => true,
254 'message' => __( 'Post published successfully.', 'wp-scheduled-posts' ),
255 'data' => [
256 'post_status' => get_post_status( $post_id ),
257 ],
258 ], 200 );
259 }
260
261 /**
262 * Whether a request flag was actually set.
263 *
264 * The panel sends a JSON boolean, but the same route is reachable with form
265 * encoded input where it arrives as the string "true"/"1".
266 *
267 * @param mixed $value
268 * @return bool
269 */
270 private function is_flag_set( $value ) {
271 return $value === true || $value === 'true' || $value === 1 || $value === '1';
272 }
273
274 /**
275 * DELETE handler – turn "publish future post immediately" back off.
276 *
277 * Deletes the prevent_future_post meta and, when the post is still dated in
278 * the future, returns it to 'future' so WordPress schedules it again. That
279 * is the actual undo: leaving the post published while dropping the meta
280 * would keep it visible with a date it has not reached.
281 *
282 * @param \WP_REST_Request $request
283 * @return \WP_REST_Response
284 */
285 public function clear_publish_immediately( \WP_REST_Request $request ) {
286 $post_id = (int) $request->get_param( 'post_id' );
287 $post = get_post( $post_id );
288
289 if ( ! $post ) {
290 return new \WP_REST_Response( [
291 'success' => false,
292 'message' => __( 'Post not found.', 'wp-scheduled-posts' ),
293 ], 404 );
294 }
295
296 // Require an active intent, using the same rule the GET handler reports
297 // it by. Without this precondition the route would reschedule any
298 // published future-dated post, including one this feature never touched.
299 $prevent_future_post = get_post_meta( $post_id, 'prevent_future_post', true );
300 $is_active = ! empty( $prevent_future_post )
301 && $prevent_future_post === $post->post_date;
302
303 if ( ! $is_active ) {
304 // A stale row is not active state, but it should not be left behind.
305 if ( '' !== $prevent_future_post
306 && ! delete_post_meta( $post_id, 'prevent_future_post' ) ) {
307 return new \WP_REST_Response( [
308 'success' => false,
309 'message' => __( 'Could not clear the stored publishing intent.', 'wp-scheduled-posts' ),
310 ], 500 );
311 }
312
313 // Idempotent: nothing to turn off, and the post status is untouched.
314 return new \WP_REST_Response( [
315 'success' => true,
316 'message' => __( 'Immediate publishing was not active for this post.', 'wp-scheduled-posts' ),
317 'data' => [
318 'post_status' => $post->post_status,
319 'prevent_future_post' => false,
320 'rescheduled' => false,
321 ],
322 ], 200 );
323 }
324
325 if ( ! delete_post_meta( $post_id, 'prevent_future_post' ) ) {
326 return new \WP_REST_Response( [
327 'success' => false,
328 'message' => __( 'Could not clear the stored publishing intent.', 'wp-scheduled-posts' ),
329 ], 500 );
330 }
331
332 $rescheduled = false;
333 if ( $post->post_status === 'publish' && strtotime( $post->post_date_gmt ) > time() ) {
334 $updated = wp_update_post( [
335 'ID' => $post_id,
336 'post_status' => 'future',
337 ], true );
338
339 if ( is_wp_error( $updated ) || ! $updated ) {
340 // Put the intent back. Leaving it deleted after a failed
341 // reschedule strands the post published on a date it has not
342 // reached, with nothing to re-assert that state on the next save
343 // and nothing left for the user to turn off.
344 $restored = update_post_meta( $post_id, 'prevent_future_post', $prevent_future_post );
345 $update_error = is_wp_error( $updated )
346 ? $updated->get_error_message()
347 : __( 'WordPress did not update the post.', 'wp-scheduled-posts' );
348
349 if ( false === $restored
350 && get_post_meta( $post_id, 'prevent_future_post', true ) !== $prevent_future_post ) {
351 return new \WP_REST_Response( [
352 'success' => false,
353 'message' => sprintf(
354 /* translators: %s is the post update error returned by WordPress. */
355 __( 'Could not reschedule the post, and could not restore the stored publishing intent. WordPress reported: %s', 'wp-scheduled-posts' ),
356 $update_error
357 ),
358 ], 500 );
359 }
360
361 return new \WP_REST_Response( [
362 'success' => false,
363 'message' => $update_error,
364 ], 500 );
365 }
366
367 $rescheduled = true;
368
369 // Let Pro (when active) reschedule its unpublish/republish cron jobs.
370 do_action( 'wpsp_pro_update_post', $post_id );
371 }
372
373 return new \WP_REST_Response( [
374 'success' => true,
375 'message' => $rescheduled
376 ? __( 'Post returned to its schedule.', 'wp-scheduled-posts' )
377 : __( 'Immediate publishing turned off.', 'wp-scheduled-posts' ),
378 'data' => [
379 'post_status' => get_post_status( $post_id ),
380 'prevent_future_post' => false,
381 'rescheduled' => $rescheduled,
382 ],
383 ], 200 );
384 }
385
386 /**
387 * Publish a post immediately using the current date/time.
388 *
389 * @param int $post_id
390 * @return true|\WP_Error
391 */
392 public function handle_post_published( $post_id ) {
393 if ( ! $post_id ) {
394 return new \WP_Error(
395 'wpsp_missing_post',
396 __( 'Post not found.', 'wp-scheduled-posts' )
397 );
398 }
399
400 // wp_update_post() returns 0 on failure unless the third argument asks
401 // for a WP_Error, so without it a failed publish was indistinguishable
402 // from a successful one.
403 $updated = wp_update_post( [
404 'ID' => $post_id,
405 'post_status' => 'publish',
406 'post_date' => current_time( 'mysql' ),
407 'post_date_gmt' => current_time( 'mysql', 1 ),
408 ], true );
409
410 if ( is_wp_error( $updated ) ) {
411 return $updated;
412 }
413
414 return true;
415 }
416
417 /**
418 * Publish a future-dated post immediately while preserving its future date.
419 *
420 * @param int $post_id
421 * @return true|\WP_Error
422 */
423 public function handle_post_publish_on_future_date( $post_id ) {
424 if ( ! $post_id ) {
425 return new \WP_Error(
426 'wpsp_missing_post',
427 __( 'Post not found.', 'wp-scheduled-posts' )
428 );
429 }
430
431 $post = get_post( $post_id );
432 if ( ! $post ) {
433 return new \WP_Error(
434 'wpsp_missing_post',
435 __( 'Post not found.', 'wp-scheduled-posts' )
436 );
437 }
438
439 // Only proceed if the post date is still in the future. This is a bad
440 // request rather than a server failure: there is no future date to
441 // publish ahead of.
442 $is_future_date = strtotime( $post->post_date_gmt ) > time();
443 if ( ! $is_future_date ) {
444 return new \WP_Error(
445 'wpsp_not_future_dated',
446 __( 'This post is not dated in the future.', 'wp-scheduled-posts' )
447 );
448 }
449
450 // Keep scalar copies. A post object may be backed by a mutable cache,
451 // so retaining the object alone is not a reliable rollback snapshot.
452 $prior_post = [
453 'post_status' => $post->post_status,
454 'post_date' => $post->post_date,
455 'post_date_gmt' => $post->post_date_gmt,
456 ];
457
458 // Bypass WordPress forcing 'future' status when the date is in the future.
459 // Scoped to this post so nothing else saved during the request is affected.
460 $filter_callback = function ( $data, $postarr ) use ( $post_id ) {
461 if ( (int) ( $postarr['ID'] ?? 0 ) === $post_id && $data['post_status'] === 'future' ) {
462 $data['post_status'] = 'publish';
463 }
464 return $data;
465 };
466 add_filter( 'wp_insert_post_data', $filter_callback, 10, 2 );
467
468 // Publish while preserving the scheduled date.
469 $updated = wp_update_post( [
470 'ID' => $post_id,
471 'post_status' => 'publish',
472 'post_date' => $post->post_date,
473 'post_date_gmt' => $post->post_date_gmt,
474 'edit_date' => true,
475 ], true );
476
477 remove_filter( 'wp_insert_post_data', $filter_callback );
478
479 // The intent is only persisted, and Pro only notified, once the post has
480 // actually been published. Recording it after a failed write would leave
481 // the meta forcing 'publish' on a post that never moved.
482 if ( is_wp_error( $updated ) ) {
483 return $updated;
484 }
485
486 // Persist the intent, otherwise the next save lets WordPress force the
487 // post back to 'future'. The filter in includes/functions.php re-asserts
488 // 'publish' for as long as this meta matches the post date.
489 $intent_date = get_post( $post_id )->post_date;
490 $meta_saved = update_post_meta( $post_id, 'prevent_future_post', $intent_date );
491
492 // update_post_meta() also returns false when the stored value was
493 // already identical. Read it back before treating false as a failure.
494 if ( false === $meta_saved
495 && get_post_meta( $post_id, 'prevent_future_post', true ) !== $intent_date ) {
496 // Publishing succeeded but its guard could not be persisted. Put
497 // the post back exactly as it was so the endpoint never reports a
498 // durable immediate publish when the next save could undo it.
499 $rolled_back = wp_update_post( [
500 'ID' => $post_id,
501 'post_status' => $prior_post['post_status'],
502 'post_date' => $prior_post['post_date'],
503 'post_date_gmt' => $prior_post['post_date_gmt'],
504 'edit_date' => true,
505 ], true );
506
507 if ( is_wp_error( $rolled_back ) || ! $rolled_back ) {
508 $rollback_error = is_wp_error( $rolled_back )
509 ? $rolled_back->get_error_message()
510 : __( 'WordPress did not restore the post.', 'wp-scheduled-posts' );
511
512 return new \WP_Error(
513 'wpsp_intent_save_and_rollback_failed',
514 sprintf(
515 /* translators: %s is the post rollback error returned by WordPress. */
516 __( 'The publishing intent could not be saved, and the post could not be restored. WordPress reported: %s', 'wp-scheduled-posts' ),
517 $rollback_error
518 )
519 );
520 }
521
522 return new \WP_Error(
523 'wpsp_intent_save_failed',
524 __( 'The publishing intent could not be saved. The post was restored to its previous schedule.', 'wp-scheduled-posts' )
525 );
526 }
527
528 // Let Pro (when active) reschedule its unpublish/republish cron jobs.
529 do_action( 'wpsp_pro_update_post', $post_id );
530
531 return true;
532 }
533
534 /**
535 * Return singleton instance.
536 *
537 * @return self
538 */
539 public static function get_instance() {
540 if ( null === self::$instance ) {
541 self::$instance = new self();
542 }
543 return self::$instance;
544 }
545 }
546