PluginProbe
Edit Flow / 0.11.0
Edit Flow v0.11.0
0.11.1 0.11.0 0.7.2 0.7.3 0.7.4 0.7.5 0.7.6 0.8 0.8.1 0.8.2 0.9 0.9.1 0.9.2 0.9.3 0.9.4 0.9.5 0.9.6 0.9.7 0.9.8 0.9.9 trunk 0.1.5 0.10.0 0.10.1 0.10.2 All 44 releases
edit-flow / modules / custom-status / custom-status.php

custom-status.php in Edit Flow 0.11.0, at modules/custom-status/custom-status.php

2,475 lines 92.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Custom Status module for Edit Flow.
4 *
5 * Custom statuses make it simple to define the different stages in your publishing workflow.
6 *
7 * @todo Improve the copy.
8 * @todo Thoroughly test what happens when the default post statuses 'Draft' and 'Pending Review' no longer exist.
9 * @todo Ensure all of the form processing uses our messages functionality.
10 *
11 * @package EditFlow
12 */
13
14 if ( ! class_exists( 'EF_Custom_Status' ) ) {
15
16 /**
17 * Custom Status module class.
18 *
19 * Allows defining custom post statuses to create structured publishing workflows.
20 */
21 class EF_Custom_Status extends EF_Module {
22
23 /**
24 * The module object.
25 *
26 * @var object
27 */
28 public $module;
29
30 /**
31 * Cache for custom statuses.
32 *
33 * @var array
34 */
35 private $custom_statuses_cache = [];
36
37 /**
38 * Taxonomy name used to store all our custom statuses.
39 *
40 * @var string
41 */
42 // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
43 const taxonomy_key = 'post_status';
44
45 /**
46 * Register the module with Edit Flow but don't do anything else.
47 */
48 public function __construct() {
49
50 $this->module_url = $this->get_module_url( __FILE__ );
51 // Register the module with Edit Flow.
52 $args = [
53 'title' => __( 'Custom Statuses', 'edit-flow' ),
54 'short_description' => __( 'Create custom post statuses to define the stages of your workflow.', 'edit-flow' ),
55 'extended_description' => __( 'Create your own post statuses to add structure your publishing workflow. You can change existing or add new ones anytime, and drag and drop to change their order.', 'edit-flow' ),
56 'module_url' => $this->module_url,
57 'img_url' => $this->module_url . 'lib/custom_status_s128.png',
58 'slug' => 'custom-status',
59 'default_options' => [
60 'enabled' => 'on',
61 'default_status' => 'pitch',
62 'always_show_dropdown' => 'off',
63 'post_types' => [
64 'post' => 'on',
65 'page' => 'on',
66 ],
67 ],
68 'post_type_support' => 'ef_custom_statuses', // This has been plural in all of our docs.
69 'configure_page_cb' => 'print_configure_view',
70 'configure_link_text' => __( 'Edit Statuses', 'edit-flow' ),
71 'messages' => [
72 'status-added' => __( 'Post status created.', 'edit-flow' ),
73 'status-missing' => __( "Post status doesn't exist.", 'edit-flow' ),
74 'default-status-changed' => __( 'Default post status has been changed.', 'edit-flow' ),
75 'term-updated' => __( 'Post status updated.', 'edit-flow' ),
76 'status-deleted' => __( 'Post status deleted.', 'edit-flow' ),
77 'status-position-updated' => __( 'Status order updated.', 'edit-flow' ),
78 'status-migrated' => __( 'Posts migrated successfully.', 'edit-flow' ),
79 ],
80 'autoload' => false,
81 'settings_help_tab' => [
82 'id' => 'ef-custom-status-overview',
83 'title' => __( 'Overview', 'edit-flow' ),
84 'content' => __( '<p>Edit Flow’s custom statuses allow you to define the most important stages of your editorial workflow. Out of the box, WordPress only offers “Draft” and “Pending Review” as post states. With custom statuses, you can create your own post states like “In Progress”, “Pitch”, or “Waiting for Edit” and keep or delete the originals. You can also drag and drop statuses to set the best order for your workflow.</p><p>Custom statuses are fully integrated into the rest of Edit Flow and the WordPress admin. On the calendar and story budget, you can filter your view to see only posts of a specific post state. Furthermore, email notifications can be sent to a specific group of users when a post changes state.</p>', 'edit-flow' ),
85 ],
86 'settings_help_sidebar' => __( '<p><strong>For more information:</strong></p><p><a href="https://editflow.org/features/custom-statuses/">Custom Status Documentation</a></p><p><a href="https://wordpress.org/support/plugin/edit-flow/">Edit Flow Forum</a></p><p><a href="https://github.com/Automattic/Edit-Flow">Edit Flow on GitHub</a></p>', 'edit-flow' ),
87 ];
88 $this->module = EditFlow()->register_module( 'custom_status', $args );
89 }
90
91 /**
92 * Initialize the EF_Custom_Status class if the module is active.
93 */
94 public function init() {
95 global $edit_flow;
96
97 // Load WP-CLI commands.
98 if ( defined( 'WP_CLI' ) && WP_CLI ) {
99 require_once __DIR__ . '/lib/class-cli.php';
100 }
101
102 // Register custom statuses as a taxonomy.
103 $this->register_custom_statuses();
104
105 // Register our settings.
106 add_action( 'admin_init', [ $this, 'register_settings' ] );
107
108 if ( ! $this->disable_custom_statuses_for_post_type() ) {
109 // Load CSS and JS resources that we probably need in the admin page.
110 add_action( 'admin_enqueue_scripts', [ $this, 'action_admin_enqueue_scripts' ] );
111
112 // Assets for block editor UI.
113 add_action( 'enqueue_block_editor_assets', [ $this, 'load_scripts_for_block_editor' ] );
114
115 // Assets for iframed block editor and editor UI.
116 add_action( 'enqueue_block_editor_assets', [ $this, 'load_styles_for_block_editor' ] );
117 }
118
119 add_action( 'admin_notices', [ $this, 'no_js_notice' ] );
120 add_action( 'admin_print_scripts', [ $this, 'post_admin_header' ] );
121
122 // Add custom statuses to the post states.
123 add_filter( 'display_post_states', [ $this, 'add_status_to_post_states' ], 10, 2 );
124
125 // Methods for handling the actions of creating, making default, and deleting post stati.
126 add_action( 'admin_init', [ $this, 'handle_add_custom_status' ] );
127 add_action( 'admin_init', [ $this, 'handle_edit_custom_status' ] );
128 add_action( 'admin_init', [ $this, 'handle_make_default_custom_status' ] );
129 add_action( 'admin_init', [ $this, 'handle_delete_custom_status' ] );
130 add_action( 'admin_init', [ $this, 'handle_migrate_status' ] );
131 add_action( 'wp_ajax_update_status_positions', [ $this, 'handle_ajax_update_status_positions' ] );
132 add_action( 'wp_ajax_inline_save_status', [ $this, 'ajax_inline_save_status' ] );
133
134 // These seven-ish methods are hacks for fixing bugs in WordPress core.
135 add_action( 'admin_init', [ $this, 'check_timestamp_on_publish' ] );
136 add_filter( 'wp_insert_post_data', [ $this, 'fix_custom_status_timestamp' ], 10, 2 );
137 add_filter( 'wp_insert_post_data', [ $this, 'maybe_keep_post_name_empty' ], 10, 2 );
138 add_filter( 'wp_insert_post_data', [ $this, 'update_post_date_on_publish_from_custom_status' ], 10, 2 );
139 add_action( 'rest_api_init', [ $this, 'register_rest_api_filters' ] );
140 add_filter( 'pre_wp_unique_post_slug', [ $this, 'fix_unique_post_slug' ], 10, 6 );
141 add_filter( 'preview_post_link', [ $this, 'fix_preview_link_part_one' ] );
142 add_filter( 'post_link', [ $this, 'fix_preview_link_part_two' ], 10, 3 );
143 add_filter( 'page_link', [ $this, 'fix_preview_link_part_two' ], 10, 3 );
144 add_filter( 'post_type_link', [ $this, 'fix_preview_link_part_two' ], 10, 3 );
145 add_filter( 'preview_post_link', [ $this, 'fix_preview_link_part_three' ], 11, 2 );
146 add_action( 'template_redirect', [ $this, 'fix_preview_template' ] );
147 add_filter( 'get_sample_permalink', [ $this, 'fix_get_sample_permalink' ], 10, 5 );
148 add_filter( 'get_sample_permalink_html', [ $this, 'fix_get_sample_permalink_html' ], 10, 5 );
149 add_filter( 'post_row_actions', [ $this, 'fix_post_row_actions' ], 10, 2 );
150 add_filter( 'page_row_actions', [ $this, 'fix_post_row_actions' ], 10, 2 );
151
152 // Pagination for custom post statuses when previewing posts.
153 add_filter( 'wp_link_pages_link', [ $this, 'modify_preview_link_pagination_url' ], 10, 2 );
154 }
155
156 /**
157 * Create the default set of custom statuses the first time the module is loaded
158 *
159 * @since 0.7
160 */
161 public function install() {
162
163 $default_terms = [
164 [
165 'term' => __( 'Pitch', 'edit-flow' ),
166 'args' => [
167 'slug' => 'pitch',
168 'description' => __( 'Idea proposed; waiting for acceptance.', 'edit-flow' ),
169 'position' => 1,
170 ],
171 ],
172 [
173 'term' => __( 'Assigned', 'edit-flow' ),
174 'args' => [
175 'slug' => 'assigned',
176 'description' => __( 'Post idea assigned to writer.', 'edit-flow' ),
177 'position' => 2,
178 ],
179 ],
180 [
181 'term' => __( 'In Progress', 'edit-flow' ),
182 'args' => [
183 'slug' => 'in-progress',
184 'description' => __( 'Writer is working on the post.', 'edit-flow' ),
185 'position' => 3,
186 ],
187 ],
188 [
189 'term' => __( 'Draft', 'edit-flow' ),
190 'args' => [
191 'slug' => 'draft',
192 'description' => __( 'Post is a draft; not ready for review or publication.', 'edit-flow' ),
193 'position' => 4,
194 ],
195 ],
196 [
197 'term' => __( 'Pending Review', 'edit-flow' ),
198 'args' => [
199 'slug' => 'pending',
200 'description' => __( 'Post needs to be reviewed by an editor.', 'edit-flow' ),
201 'position' => 5,
202 ],
203 ],
204 ];
205
206 // Okay, now add the default statuses to the db if they don't already exist.
207 foreach ( $default_terms as $term ) {
208 if ( ! term_exists( $term['term'], self::taxonomy_key ) ) {
209 $this->add_custom_status( $term['term'], $term['args'] );
210 }
211 }
212 }
213
214 /**
215 * Upgrade our data in case we need to.
216 *
217 * @since 0.7
218 *
219 * @param string $previous_version Previous plugin version.
220 */
221 public function upgrade( $previous_version ) {
222 global $edit_flow;
223
224 // Upgrade path to v0.7.
225 if ( version_compare( $previous_version, '0.7', '<' ) ) {
226 // Migrate dropdown visibility option.
227 $dropdown_visible = get_option( 'edit_flow_status_dropdown_visible' );
228 if ( $dropdown_visible ) {
229 $dropdown_visible = 'on';
230 } else {
231 $dropdown_visible = 'off';
232 }
233 $edit_flow->update_module_option( $this->module->name, 'always_show_dropdown', $dropdown_visible );
234 delete_option( 'edit_flow_status_dropdown_visible' );
235 // Migrate default status option.
236 $default_status = get_option( 'edit_flow_custom_status_default_status' );
237 if ( $default_status ) {
238 $edit_flow->update_module_option( $this->module->name, 'default_status', $default_status );
239 }
240 delete_option( 'edit_flow_custom_status_default_status' );
241
242 // Technically we've run this code before so we don't want to auto-install new data.
243 $edit_flow->update_module_option( $this->module->name, 'loaded_once', true );
244 }
245 // Upgrade path to v0.7.4.
246 if ( version_compare( $previous_version, '0.7.4', '<' ) ) {
247 // Custom status descriptions become base64_encoded, instead of maybe json_encoded.
248 $this->upgrade_074_term_descriptions( self::taxonomy_key );
249 }
250 }
251
252 /**
253 * Makes the call to register_post_status to register the user's custom statuses.
254 *
255 * Also unregisters draft and pending, in case the user doesn't want them.
256 */
257 public function register_custom_statuses() {
258 global $wp_post_statuses;
259
260 if ( $this->disable_custom_statuses_for_post_type() ) {
261 return;
262 }
263
264 // Register new taxonomy so that we can store all our fancy new custom statuses (or is it stati?).
265 if ( ! taxonomy_exists( self::taxonomy_key ) ) {
266 $args = [
267 'hierarchical' => false,
268 'update_count_callback' => '_update_post_term_count',
269 'label' => false,
270 'query_var' => false,
271 'rewrite' => false,
272 'show_ui' => false,
273 ];
274 register_taxonomy( self::taxonomy_key, 'post', $args );
275 }
276
277 if ( function_exists( 'register_post_status' ) ) {
278 // Users can delete draft and pending statuses if they want, so let's get rid of them.
279 // They'll get re-added if the user hasn't "deleted" them.
280 unset( $wp_post_statuses['draft'] );
281 unset( $wp_post_statuses['pending'] );
282
283 $custom_statuses = $this->get_custom_statuses();
284
285 // Unfortunately, register_post_status() doesn't accept a post type argument,
286 // so we have to register the post statuses for all post types.
287 // This results in all post statuses for a post type appearing at the top
288 // of manage posts if there is a post with the status.
289 foreach ( $custom_statuses as $status ) {
290 register_post_status( $status->slug, [
291 'label' => $status->name,
292 'protected' => true,
293 '_builtin' => false,
294 // phpcs:ignore WordPress.WP.I18n.InterpolatedVariableSingular,WordPress.WP.I18n.InterpolatedVariablePlural -- Status name is user-defined and dynamic.
295 'label_count' => _n_noop( "{$status->name} <span class='count'>(%s)</span>", "{$status->name} <span class='count'>(%s)</span>", 'edit-flow' ),
296 ] );
297 }
298 }
299 }
300
301 /**
302 * Whether custom post statuses should be disabled for this post type.
303 *
304 * Used to stop custom statuses from being registered for post types that don't support them.
305 *
306 * @since 0.7.5
307 *
308 * @param string|null $post_type The post type to check, or null to auto-detect.
309 * @return bool
310 */
311 public function disable_custom_statuses_for_post_type( $post_type = null ) {
312 global $pagenow;
313
314 // Only allow deregistering on 'edit.php' and 'post.php'.
315 if ( ! in_array( $pagenow, [ 'edit.php', 'post.php', 'post-new.php' ] ) ) {
316 return false;
317 }
318
319 if ( is_null( $post_type ) ) {
320 $post_type = $this->get_current_post_type();
321 }
322
323 if ( $post_type && ! in_array( $post_type, $this->get_post_types_for_module( $this->module ) ) ) {
324 return true;
325 }
326
327 return false;
328 }
329
330 /**
331 * Enqueue Javascript resources that we need in the admin:
332 * - Primary use of Javascript is to manipulate the post status dropdown on Edit Post and Manage Posts
333 * - jQuery Sortable plugin is used for drag and dropping custom statuses
334 * - We have other custom code for Quick Edit and JS niceties
335 */
336 public function action_admin_enqueue_scripts() {
337 // Load Javascript we need to use on the configuration views (jQuery Sortable and Quick Edit).
338 if ( $this->is_whitelisted_settings_view( $this->module->name ) ) {
339 wp_enqueue_script( 'jquery-ui-sortable' );
340 wp_enqueue_script( 'edit-flow-custom-status-configure', $this->module_url . 'lib/custom-status-configure.js', [ 'jquery', 'jquery-ui-sortable', 'edit-flow-settings-js' ], EDIT_FLOW_VERSION, true );
341
342 wp_localize_script( 'edit-flow-custom-status-configure', '__ef_localize_custom_status_configure', [
343 'delete_status_string' => __( 'Are you sure you want to delete the post status? All posts with this status will be assigned to the default status.', 'edit-flow' ),
344 ] );
345 }
346
347 // Custom javascript to modify the post status dropdown where it shows up.
348 if ( $this->is_whitelisted_page() ) {
349 wp_enqueue_script( 'edit_flow-custom_status', $this->module_url . 'lib/custom-status.js', [ 'jquery', 'post' ], EDIT_FLOW_VERSION, true );
350 wp_localize_script('edit_flow-custom_status', '__ef_localize_custom_status', [
351 'no_change' => esc_html__( '&mdash; No Change &mdash;', 'edit-flow' ),
352 'published' => esc_html__( 'Published', 'edit-flow' ),
353 'private' => esc_html__( 'Private', 'edit-flow' ),
354 'save_as' => esc_html__( 'Save as', 'edit-flow' ),
355 'save' => esc_html__( 'Save', 'edit-flow' ),
356 'edit' => esc_html__( 'Edit', 'edit-flow' ),
357 'ok' => esc_html__( 'OK', 'edit-flow' ),
358 'cancel' => esc_html__( 'Cancel', 'edit-flow' ),
359 ] );
360 }
361 }
362
363 /**
364 * Load scripts for the block editor.
365 */
366 public function load_scripts_for_block_editor() {
367 global $post;
368
369 $asset_file = EDIT_FLOW_ROOT . '/build/custom-status-block.asset.php';
370 $asset = file_exists( $asset_file ) ? require $asset_file : [
371 'dependencies' => [],
372 'version' => EDIT_FLOW_VERSION,
373 ];
374
375 wp_enqueue_script(
376 'edit-flow-block-custom-status-script',
377 EDIT_FLOW_URL . 'build/custom-status-block.js',
378 $asset['dependencies'],
379 $asset['version'],
380 true
381 );
382
383 $custom_statuses = apply_filters( 'ef_custom_status_list', $this->get_custom_statuses(), $post );
384
385 wp_localize_script( 'edit-flow-block-custom-status-script', 'EditFlowCustomStatuses', array_values( $custom_statuses ) );
386 }
387
388 /**
389 * Load styles for the block editor.
390 */
391 public function load_styles_for_block_editor() {
392 $asset_file = EDIT_FLOW_ROOT . '/build/custom-status-block.asset.php';
393 $asset = file_exists( $asset_file ) ? require $asset_file : [
394 'dependencies' => [],
395 'version' => EDIT_FLOW_VERSION,
396 ];
397
398 wp_enqueue_style(
399 'edit-flow-block-custom-status-styles',
400 EDIT_FLOW_URL . 'build/custom-status-block.css',
401 [],
402 $asset['version']
403 );
404 }
405
406 /**
407 * Displays a notice to users if they have JS disabled
408 * Javascript is needed for custom statuses to be fully functional
409 */
410 public function no_js_notice() {
411 if ( $this->is_whitelisted_page() ) :
412 ?>
413 <style type="text/css">
414 /* Hide post status dropdown by default in case of JS issues **/
415 label[for=post_status],
416 #post-status-display,
417 #post-status-select,
418 #publish {
419 display: none;
420 }
421 </style>
422 <div class="update-nag hide-if-js">
423 <?php _e( '<strong>Note:</strong> Your browser does not support JavaScript or has JavaScript disabled. You will not be able to access or change the post status.', 'edit-flow' ); ?>
424 </div>
425 <?php
426 endif;
427 }
428
429 /**
430 * Check whether custom status stuff should be loaded on this page.
431 *
432 * @todo Migrate this to the base module class.
433 *
434 * @return bool Whether the page is whitelisted.
435 */
436 public function is_whitelisted_page() {
437 global $pagenow;
438
439 if ( ! in_array( $this->get_current_post_type(), $this->get_post_types_for_module( $this->module ) ) ) {
440 return false;
441 }
442
443 $post_type_obj = get_post_type_object( $this->get_current_post_type() );
444
445 if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) {
446 return false;
447 }
448
449 // Only add the script to Edit Post and Edit Page pages.
450 return in_array( $pagenow, [ 'post.php', 'edit.php', 'post-new.php', 'page.php', 'edit-pages.php', 'page-new.php' ] );
451 }
452
453 /**
454 * Adds all necessary javascripts to make custom statuses work.
455 *
456 * @todo Support private and future posts on edit.php view.
457 */
458 public function post_admin_header() {
459 global $post, $edit_flow, $pagenow, $current_user;
460
461 if ( $this->disable_custom_statuses_for_post_type() ) {
462 return;
463 }
464
465 // Get current user.
466 wp_get_current_user();
467
468 // Only add the script to Edit Post and Edit Page pages.
469 if ( $this->is_whitelisted_page() ) {
470 $custom_statuses = $this->get_custom_statuses();
471
472 // $selected can be empty, but must be set because it's used as a JS variable.
473 $selected = '';
474 $selected_name = '';
475
476 if ( ! empty( $post ) ) {
477 // Get the status of the current post.
478 if ( 0 == $post->ID || 'auto-draft' == $post->post_status || 'edit.php' == $pagenow ) {
479 // @todo Check to make sure that the default exists.
480 $selected = $this->get_default_custom_status()->slug;
481 } else {
482 $selected = $post->post_status;
483 }
484
485 // Get the label of current status.
486 foreach ( $custom_statuses as $status ) {
487 if ( $status->slug == $selected ) {
488 $selected_name = $status->name;
489 }
490 }
491 }
492
493 $custom_statuses = apply_filters( 'ef_custom_status_list', $custom_statuses, $post );
494
495 // All right, we want to set up the JS var which contains all custom statuses.
496 $all_statuses = [];
497
498 // The default statuses from WordPress.
499 $all_statuses[] = [
500 'name' => __( 'Published', 'edit-flow' ),
501 'slug' => 'publish',
502 'description' => '',
503 ];
504 $all_statuses[] = [
505 'name' => __( 'Privately Published', 'edit-flow' ),
506 'slug' => 'private',
507 'description' => '',
508 ];
509 $all_statuses[] = [
510 'name' => __( 'Scheduled', 'edit-flow' ),
511 'slug' => 'future',
512 'description' => '',
513 ];
514
515 // Load the custom statuses.
516 foreach ( $custom_statuses as $status ) {
517 $all_statuses[] = [
518 'name' => esc_js( $status->name ),
519 'slug' => esc_js( $status->slug ),
520 'description' => esc_js( $status->description ),
521 ];
522 }
523
524 $always_show_dropdown = ( 'on' == $this->module->options->always_show_dropdown ) ? 1 : 0;
525
526 $post_type_obj = get_post_type_object( $this->get_current_post_type() );
527
528 // Now, let's print the JS vars.
529 ?>
530 <script type="text/javascript">
531 var custom_statuses = <?php echo wp_json_encode( $all_statuses ); ?>;
532 var ef_default_custom_status = '<?php echo esc_js( $this->get_default_custom_status()->slug ); ?>';
533 var current_status = '<?php echo esc_js( $selected ); ?>';
534 var current_status_name = '<?php echo esc_js( $selected_name ); ?>';
535 var status_dropdown_visible = <?php echo esc_js( $always_show_dropdown ); ?>;
536 var current_user_can_publish_posts = <?php echo current_user_can( $post_type_obj->cap->publish_posts ) ? 1 : 0; ?>;
537 var current_user_can_edit_published_posts = <?php echo current_user_can( $post_type_obj->cap->edit_published_posts ) ? 1 : 0; ?>;
538 </script>
539
540 <?php
541 }
542 }
543
544 /**
545 * Adds a new custom status as a term in the wp_terms table.
546 *
547 * Basically a wrapper for the wp_insert_term class.
548 *
549 * The arguments decide how the term is handled based on the $args parameter.
550 * The following is a list of the available overrides and the defaults.
551 *
552 * 'description'. There is no default. If exists, will be added to the database
553 * along with the term. Expected to be a string.
554 *
555 * 'slug'. Expected to be a string. There is no default.
556 *
557 * @param int|string $term The status to add or update.
558 * @param array|string $args Change the values of the inserted term.
559 * @return array|WP_Error The Term ID and Term Taxonomy ID.
560 */
561 public function add_custom_status( $term, $args = [] ) {
562 $slug = ( ! empty( $args['slug'] ) ) ? $args['slug'] : sanitize_title( $term );
563 unset( $args['slug'] );
564 $encoded_description = $this->get_encoded_description( $args );
565 $response = wp_insert_term( $term, self::taxonomy_key, [
566 'slug' => $slug,
567 'description' => $encoded_description,
568 ] );
569
570 // Reset our internal object cache.
571 $this->custom_statuses_cache = [];
572
573 return $response;
574 }
575
576 /**
577 * Update an existing custom status.
578 *
579 * @param int $status_id ID for the status.
580 * @param array $args Any arguments to be updated.
581 * @return object|WP_Error Newly updated status object.
582 */
583 public function update_custom_status( $status_id, $args = [] ) {
584 global $edit_flow;
585
586 $old_status = $this->get_custom_status_by( 'id', $status_id );
587 if ( ! $old_status || is_wp_error( $old_status ) ) {
588 return new WP_Error( 'invalid', __( "Custom status doesn't exist.", 'edit-flow' ) );
589 }
590
591 // Prevent user from changing draft name or slug.
592 if ( 'draft' === $old_status->slug
593 && (
594 ( isset( $args['name'] ) && $args['name'] !== $old_status->name )
595 ||
596 ( isset( $args['slug'] ) && $args['slug'] !== $old_status->slug )
597 ) ) {
598 return new WP_Error( 'invalid', __( 'Changing the name and slug of "Draft" is not allowed', 'edit-flow' ) );
599 }
600
601 // If the name was changed, we need to change the slug.
602 if ( isset( $args['name'] ) && $args['name'] != $old_status->name ) {
603 $args['slug'] = sanitize_title( $args['name'] );
604 }
605
606 // Reassign posts to new status slug if the slug changed and isn't restricted.
607 if ( isset( $args['slug'] ) && $args['slug'] != $old_status->slug && ! $this->is_restricted_status( $old_status->slug ) ) {
608 $new_status = $args['slug'];
609 $this->reassign_post_status( $old_status->slug, $new_status );
610
611 $default_status = $this->get_default_custom_status()->slug;
612 if ( $old_status->slug == $default_status ) {
613 $edit_flow->update_module_option( $this->module->name, 'default_status', $new_status );
614 }
615 }
616 // We're encoding metadata that isn't supported by default in the term's description field.
617 $args_to_encode = [];
618 $args_to_encode['description'] = ( isset( $args['description'] ) ) ? $args['description'] : $old_status->description;
619 $args_to_encode['position'] = ( isset( $args['position'] ) ) ? $args['position'] : $old_status->position;
620 $encoded_description = $this->get_encoded_description( $args_to_encode );
621 $args['description'] = $encoded_description;
622
623 $updated_status_array = wp_update_term( $status_id, self::taxonomy_key, $args );
624
625 // Reset our internal object cache after the update, not before.
626 // This ensures get_custom_status_by() returns fresh data.
627 $this->custom_statuses_cache = [];
628
629 $updated_status = $this->get_custom_status_by( 'id', $updated_status_array['term_id'] );
630
631 return $updated_status;
632 }
633
634 /**
635 * Deletes a custom status from the wp_terms table.
636 *
637 * Partly a wrapper for the wp_delete_term function.
638 * BUT, also reassigns posts that currently have the deleted status assigned.
639 *
640 * @param int $status_id ID of the status to delete.
641 * @param array $args Arguments for wp_delete_term.
642 * @param string $reassign Status ID to reassign posts to.
643 * @return bool|WP_Error True on success, WP_Error on failure.
644 */
645 public function delete_custom_status( $status_id, $args = [], $reassign = '' ) {
646 global $edit_flow;
647 // Reassign posts to alternate status.
648
649 // Get slug for the old status.
650 $old_status = $this->get_custom_status_by( 'id', $status_id )->slug;
651
652 if ( $reassign == $old_status ) {
653 return new WP_Error( 'invalid', __( 'Cannot reassign to the status you want to delete', 'edit-flow' ) );
654 }
655
656 if ( ! $this->is_restricted_status( $old_status ) && 'draft' !== $old_status ) {
657 $default_status = $this->get_default_custom_status()->slug;
658 // If new status in $reassign, use that for all posts of the old_status.
659 if ( ! empty( $reassign ) ) {
660 $new_status = $this->get_custom_status_by( 'id', $reassign )->slug;
661 } else {
662 $new_status = $default_status;
663 }
664 // Deleting default status.
665 if ( $old_status == $default_status && $this->get_custom_status_by( 'slug', 'draft' ) ) {
666 $new_status = 'draft';
667 $edit_flow->update_module_option( $this->module->name, 'default_status', $new_status );
668 }
669
670 $this->reassign_post_status( $old_status, $new_status );
671
672 $result = wp_delete_term( $status_id, self::taxonomy_key, $args );
673
674 // Reset our internal object cache after the delete, not before.
675 // This ensures subsequent calls to get_custom_statuses() return fresh data.
676 $this->custom_statuses_cache = [];
677
678 return $result;
679 } else {
680 return new WP_Error( 'restricted', __( 'Restricted status ', 'edit-flow' ) . '(' . $this->get_custom_status_by( 'id', $status_id )->name . ')' );
681 }
682 }
683
684 /**
685 * Get all custom statuses as an ordered array.
686 *
687 * @param array $args Optional arguments for filtering.
688 * @return array All of the statuses.
689 */
690 public function get_custom_statuses( $args = [] ) {
691 global $wp_post_statuses;
692
693 if ( $this->disable_custom_statuses_for_post_type() ) {
694 return $this->get_core_post_statuses();
695 }
696
697 // Internal object cache for repeat requests.
698 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Used for cache key generation only.
699 $arg_hash = md5( serialize( $args ) );
700 if ( ! empty( $this->custom_statuses_cache[ $arg_hash ] ) ) {
701 return $this->custom_statuses_cache[ $arg_hash ];
702 }
703
704 // Handle if the requested taxonomy doesn't exist.
705 $statuses = get_terms( [
706 'taxonomy' => self::taxonomy_key,
707 'hide_empty' => false,
708 ]);
709
710 if ( is_wp_error( $statuses ) || empty( $statuses ) ) {
711 $statuses = [];
712 }
713
714 // Expand and order the statuses.
715 $ordered_statuses = [];
716 $hold_to_end = [];
717 foreach ( $statuses as $key => $status ) {
718 // Unencode and set all of our pseudo term meta because we need the position if it exists.
719 // Create a new object to avoid modifying the original term object from WordPress's cache.
720 $unencoded_description = $this->get_unencoded_description( $status->description );
721 $defaults = [
722 'position' => false,
723 ];
724 $status = array_merge( $defaults, (array) $status );
725 if ( is_array( $unencoded_description ) ) {
726 $status = array_merge( $status, $unencoded_description );
727 }
728 $status = (object) $status;
729 // Only add the status to the ordered array if it has a set position and doesn't conflict with another key.
730 // Otherwise, hold it for later.
731 if ( $status->position && ! array_key_exists( $status->position, $ordered_statuses ) ) {
732 $ordered_statuses[ (int) $status->position ] = $status;
733 } else {
734 $hold_to_end[] = $status;
735 }
736 }
737 // Sort the items numerically by key.
738 ksort( $ordered_statuses, SORT_NUMERIC );
739 // Append all of the statuses that didn't have an existing position.
740 foreach ( $hold_to_end as $unpositioned_status ) {
741 $ordered_statuses[] = $unpositioned_status;
742 }
743
744 $this->custom_statuses_cache[ $arg_hash ] = $ordered_statuses;
745
746 return $ordered_statuses;
747 }
748
749 /**
750 * Returns the a single status object based on ID, title, or slug.
751 *
752 * @param string $field The field to search by ('id', 'slug', or 'name').
753 * @param string|int $value The value to search for.
754 * @return object|false The object for the matching status.
755 */
756 public function get_custom_status_by( $field, $value ) {
757
758 if ( ! in_array( $field, [ 'id', 'slug', 'name' ] ) ) {
759 return false;
760 }
761
762 if ( 'id' == $field ) {
763 $field = 'term_id';
764 }
765
766 $custom_statuses = $this->get_custom_statuses();
767 $custom_status = wp_filter_object_list( $custom_statuses, [ $field => $value ] );
768
769 if ( ! empty( $custom_status ) ) {
770 return array_shift( $custom_status );
771 } else {
772 return false;
773 }
774 }
775
776 /**
777 * Get the term object for the default custom post status.
778 *
779 * @return object Default post status object.
780 */
781 public function get_default_custom_status() {
782 $default_status = $this->get_custom_status_by( 'slug', $this->module->options->default_status );
783 if ( ! $default_status ) {
784 $custom_statuses = $this->get_custom_statuses();
785 $default_status = array_shift( $custom_statuses );
786 }
787 return $default_status;
788 }
789
790 /**
791 * Assign new statuses to posts using value provided or the default.
792 *
793 * @param string $old_status Slug for the old status.
794 * @param string $new_status Slug for the new status.
795 */
796 public function reassign_post_status( $old_status, $new_status = '' ) {
797 global $wpdb;
798
799 if ( empty( $new_status ) ) {
800 $new_status = $this->get_default_custom_status()->slug;
801 }
802
803 // Make the database call.
804 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk status reassignment requires direct query.
805 $result = $wpdb->update( $wpdb->posts, [ 'post_status' => $new_status ], [ 'post_status' => $old_status ], [ '%s' ] );
806 }
807
808 /**
809 * Get every status slug a post may validly be assigned: the core statuses plus all
810 * registered custom statuses. Used to validate migration/reassignment targets.
811 *
812 * @return string[] Valid status slugs.
813 */
814 public function get_all_valid_statuses() {
815 $core_statuses = [ 'publish', 'pending', 'draft', 'private', 'trash', 'future' ];
816 $custom_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
817
818 return array_values( array_unique( array_merge( $core_statuses, $custom_slugs ) ) );
819 }
820
821 /**
822 * Display our custom post statuses in post listings when needed.
823 *
824 * @param array $post_states An array of post display states.
825 * @param WP_Post $post The current post object.
826 *
827 * @return array Modified post states.
828 */
829 public function add_status_to_post_states( $post_states, $post ) {
830 if ( ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ), true ) ) {
831 // Return early if this post type doesn't support custom statuses.
832 return $post_states;
833 }
834
835 $post_status = get_post_status_object( get_post_status( $post->ID ) );
836
837 if ( ! is_object( $post_status ) ) {
838 return $post_states;
839 }
840
841 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce not needed for display filtering; value only used for comparison.
842 $filtered_status = isset( $_REQUEST['post_status'] ) ? $_REQUEST['post_status'] : '';
843 if ( $filtered_status === $post_status->name ) {
844 // No need to display the post status if a specific status was already requested.
845 return $post_states;
846 }
847
848 $statuses_to_ignore = [ 'future', 'trash', 'publish' ];
849 if ( in_array( $post_status->name, $statuses_to_ignore, true ) ) {
850 // Let WP core handle these more gracefully.
851 return $post_states;
852 }
853
854 // Add the post status to display. Will also ensure the same status isn't shown twice.
855 $post_states[ $post_status->name ] = $post_status->label;
856
857 return $post_states;
858 }
859
860 /**
861 * Determines whether the slug indicated belongs to a restricted status or not.
862 *
863 * @param string $slug Slug of the status.
864 * @return bool True if restricted, false if not.
865 */
866 public function is_restricted_status( $slug ) {
867
868 switch ( $slug ) {
869 case 'publish':
870 case 'private':
871 case 'future':
872 case 'new':
873 case 'inherit':
874 case 'auto-draft':
875 case 'trash':
876 $restricted = true;
877 break;
878
879 default:
880 $restricted = false;
881 break;
882 }
883 return $restricted;
884 }
885
886 /**
887 * Handles a form's POST request to add a custom status.
888 *
889 * @since 0.7
890 */
891 public function handle_add_custom_status() {
892 // Check that the current POST request is our POST request.
893 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified below.
894 if ( ! isset( $_POST['submit'], $_GET['page'], $_POST['action'] )
895 || $_GET['page'] != $this->module->settings_slug || 'add-new' != $_POST['action'] ) {
896 return;
897 }
898 // phpcs:enable
899
900 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
901 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'custom-status-add-nonce' ) ) {
902 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
903 }
904
905 if ( ! current_user_can( 'manage_options' ) ) {
906 wp_die( esc_html( $this->module->messages['invalid-permissions'] ) );
907 }
908
909 // Validate and sanitize the form data.
910 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with sanitize_text_field().
911 $status_name = isset( $_POST['status_name'] ) ? sanitize_text_field( trim( $_POST['status_name'] ) ) : '';
912 $status_slug = sanitize_title( $status_name );
913 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with wp_filter_nohtml_kses().
914 $status_description = isset( $_POST['status_description'] ) ? stripslashes( wp_filter_nohtml_kses( trim( $_POST['status_description'] ) ) ) : '';
915
916 /**
917 * Form validation:
918 * - Name is required and can't conflict with an existing name or slug.
919 * - Description is optional.
920 */
921 EditFlow()->settings->form_errors = [];
922 // Check if name field was filled in.
923 if ( empty( $status_name ) ) {
924 EditFlow()->settings->form_errors['name'] = __( 'Please enter a name for the status', 'edit-flow' );
925 }
926 // Check that the name isn't numeric.
927 if ( 0 != (int) $status_name ) {
928 EditFlow()->settings->form_errors['name'] = __( 'Please enter a valid, non-numeric name for the status.', 'edit-flow' );
929 }
930 // Check that the status name doesn't exceed 20 chars.
931 if ( strlen( $status_name ) > 20 ) {
932 EditFlow()->settings->form_errors['name'] = __( 'Status name cannot exceed 20 characters. Please try a shorter name.', 'edit-flow' );
933 }
934 // Check to make sure the status doesn't already exist as another term.
935 if ( term_exists( $status_slug, self::taxonomy_key ) ) {
936 EditFlow()->settings->form_errors['name'] = __( 'Status name conflicts with existing term. Please choose another.', 'edit-flow' );
937 }
938 // Check to make sure the name is not restricted.
939 if ( $this->is_restricted_status( strtolower( $status_slug ) ) ) {
940 EditFlow()->settings->form_errors['name'] = __( 'Status name is restricted. Please choose another name.', 'edit-flow' );
941 }
942
943 // If there were any form errors, kick out and return them.
944 if ( count( EditFlow()->settings->form_errors ) ) {
945 $_REQUEST['error'] = 'form-error';
946 return;
947 }
948
949 // Try to add the status.
950 $status_args = [
951 'description' => $status_description,
952 'slug' => $status_slug,
953 ];
954 $return = $this->add_custom_status( $status_name, $status_args );
955 if ( is_wp_error( $return ) ) {
956 /* translators: %s: error message */
957 wp_die( esc_html( sprintf( __( 'Could not add status: %s', 'edit-flow' ), $return->get_error_message() ) ) );
958 }
959 // Redirect if successful.
960 $redirect_url = $this->get_link( [ 'message' => 'status-added' ] );
961 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Admin page redirect with controlled URL.
962 wp_redirect( $redirect_url );
963 exit;
964 }
965
966 /**
967 * Handles a POST request to edit a custom status.
968 *
969 * @since 0.7
970 */
971 public function handle_edit_custom_status() {
972 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified below.
973 if ( ! isset( $_POST['submit'], $_GET['page'], $_GET['action'], $_GET['term-id'] )
974 || $_GET['page'] != $this->module->settings_slug || 'edit-status' != $_GET['action'] ) {
975 return;
976 }
977 // phpcs:enable
978
979 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
980 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'edit-status' ) ) {
981 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
982 }
983
984 if ( ! current_user_can( 'manage_options' ) ) {
985 wp_die( esc_html( $this->module->messages['invalid-permissions'] ) );
986 }
987
988 $existing_status = $this->get_custom_status_by( 'id', (int) $_GET['term-id'] );
989 if ( ! $existing_status ) {
990 wp_die( esc_html( $this->module->messages['status-missing'] ) );
991 }
992
993 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with sanitize_text_field().
994 $name = isset( $_POST['name'] ) ? sanitize_text_field( trim( $_POST['name'] ) ) : '';
995 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with wp_filter_nohtml_kses().
996 $description = isset( $_POST['description'] ) ? stripslashes( wp_filter_nohtml_kses( trim( $_POST['description'] ) ) ) : '';
997
998 /**
999 * Form validation for editing custom status.
1000 *
1001 * Details:
1002 * - 'name' is a required field and can't conflict with existing name or slug.
1003 * - 'description' is optional.
1004 */
1005 EditFlow()->settings->form_errors = [];
1006 // Check if name field was filled in.
1007 if ( empty( $name ) ) {
1008 EditFlow()->settings->form_errors['name'] = __( 'Please enter a name for the status', 'edit-flow' );
1009 }
1010 // Check that the name isn't numeric.
1011 if ( is_numeric( $name ) ) {
1012 EditFlow()->settings->form_errors['name'] = __( 'Please enter a valid, non-numeric name for the status.', 'edit-flow' );
1013 }
1014 // Check that the status name doesn't exceed 20 chars.
1015 if ( strlen( $name ) > 20 ) {
1016 EditFlow()->settings->form_errors['name'] = __( 'Status name cannot exceed 20 characters. Please try a shorter name.', 'edit-flow' );
1017 }
1018 // Check to make sure the status doesn't already exist as another term.
1019 $term_exists = term_exists( sanitize_title( $name ), self::taxonomy_key );
1020 if ( $term_exists && isset( $term_exists['term_id'] ) && $term_exists['term_id'] != $existing_status->term_id ) {
1021 EditFlow()->settings->form_errors['name'] = __( 'Status name conflicts with existing term. Please choose another.', 'edit-flow' );
1022 }
1023 // Check to make sure the status doesn't already exist.
1024 $search_status = $this->get_custom_status_by( 'slug', sanitize_title( $name ) );
1025 if ( $search_status && $search_status->term_id != $existing_status->term_id ) {
1026 EditFlow()->settings->form_errors['name'] = __( 'Status name conflicts with existing status. Please choose another.', 'edit-flow' );
1027 }
1028 // Check to make sure the name is not restricted.
1029 if ( $this->is_restricted_status( strtolower( sanitize_title( $name ) ) ) ) {
1030 EditFlow()->settings->form_errors['name'] = __( 'Status name is restricted. Please choose another name.', 'edit-flow' );
1031 }
1032
1033 // Kick out if there are any errors.
1034 if ( count( EditFlow()->settings->form_errors ) ) {
1035 $_REQUEST['error'] = 'form-error';
1036 return;
1037 }
1038
1039 // Try to add the new post status.
1040 $args = [
1041 'name' => $name,
1042 'slug' => sanitize_title( $name ),
1043 'description' => $description,
1044 ];
1045 $return = $this->update_custom_status( $existing_status->term_id, $args );
1046 if ( is_wp_error( $return ) ) {
1047 wp_die( esc_html__( 'Error updating post status.', 'edit-flow' ) );
1048 }
1049
1050 $redirect_url = $this->get_link( [ 'message' => 'status-updated' ] );
1051 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Admin page redirect with controlled URL.
1052 wp_redirect( $redirect_url );
1053 exit;
1054 }
1055
1056 /**
1057 * Handles a GET request to make the identified status default.
1058 *
1059 * @since 0.7
1060 */
1061 public function handle_make_default_custom_status() {
1062 global $edit_flow;
1063
1064 // Check that the current GET request is our GET request.
1065 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified below.
1066 if ( ! isset( $_GET['page'], $_GET['action'], $_GET['term-id'], $_GET['nonce'] )
1067 || $_GET['page'] != $this->module->settings_slug || 'make-default' != $_GET['action'] ) {
1068 return;
1069 }
1070 // phpcs:enable
1071
1072 // Check for proper nonce.
1073 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1074 if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( $_GET['nonce'], 'make-default' ) ) {
1075 wp_die( esc_html__( 'Invalid nonce for submission.', 'edit-flow' ) );
1076 }
1077
1078 // Only allow users with the proper caps.
1079 if ( ! current_user_can( 'manage_options' ) ) {
1080 wp_die( esc_html__( 'Sorry, you do not have permission to edit custom statuses.', 'edit-flow' ) );
1081 }
1082
1083 $term_id = (int) $_GET['term-id'];
1084 $term = $this->get_custom_status_by( 'id', $term_id );
1085 if ( is_object( $term ) ) {
1086 $edit_flow->update_module_option( $this->module->name, 'default_status', $term->slug );
1087 // @todo How do we want to handle users who click the link from "Add New Status"?
1088 $redirect_url = $this->get_link( [ 'message' => 'default-status-changed' ] );
1089 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Admin page redirect with controlled URL.
1090 wp_redirect( $redirect_url );
1091 exit;
1092 } else {
1093 wp_die( esc_html__( 'Status doesn&#39;t exist.', 'edit-flow' ) );
1094 }
1095 }
1096
1097 /**
1098 * Handles a GET request to delete a specific term.
1099 *
1100 * @since 0.7
1101 */
1102 public function handle_delete_custom_status() {
1103 // Check that this GET request is our GET request.
1104 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified below.
1105 if ( ! isset( $_GET['page'], $_GET['action'], $_GET['term-id'], $_GET['nonce'] )
1106 || $_GET['page'] != $this->module->settings_slug || 'delete-status' != $_GET['action'] ) {
1107 return;
1108 }
1109 // phpcs:enable
1110
1111 // Check for proper nonce.
1112 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1113 if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( $_GET['nonce'], 'delete-status' ) ) {
1114 wp_die( esc_html__( 'Invalid nonce for submission.', 'edit-flow' ) );
1115 }
1116
1117 // Only allow users with the proper caps.
1118 if ( ! current_user_can( 'manage_options' ) ) {
1119 wp_die( esc_html__( 'Sorry, you do not have permission to edit custom statuses.', 'edit-flow' ) );
1120 }
1121
1122 // Check to make sure the status isn't already deleted.
1123 $term_id = (int) $_GET['term-id'];
1124 $term = $this->get_custom_status_by( 'id', $term_id );
1125 if ( ! $term ) {
1126 wp_die( esc_html__( 'Status does not exist.', 'edit-flow' ) );
1127 }
1128
1129 // Don't allow deletion of default status.
1130 if ( $term->slug == $this->get_default_custom_status()->slug ) {
1131 wp_die( esc_html__( 'Cannot delete default status.', 'edit-flow' ) );
1132 }
1133
1134 $return = $this->delete_custom_status( $term_id );
1135 if ( is_wp_error( $return ) ) {
1136 wp_die( esc_html( __( 'Could not delete the status: ', 'edit-flow' ) . $return->get_error_message() ) );
1137 }
1138
1139 $redirect_url = $this->get_link( [ 'message' => 'status-deleted' ] );
1140 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Admin page redirect with controlled URL.
1141 wp_redirect( $redirect_url );
1142 exit;
1143 }
1144
1145 /**
1146 * Handle a POST request to migrate posts between statuses.
1147 *
1148 * @since 0.9.10
1149 */
1150 public function handle_migrate_status() {
1151 // Check that this is our POST request.
1152 if ( ! isset( $_POST['action'] ) || 'migrate' !== $_POST['action'] ) {
1153 return;
1154 }
1155
1156 // Verify the page.
1157 if ( ! isset( $_GET['page'] ) || $_GET['page'] !== $this->module->settings_slug ) {
1158 return;
1159 }
1160
1161 // Check for proper nonce.
1162 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1163 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'custom-status-migrate-nonce' ) ) {
1164 wp_die( esc_html__( 'Invalid nonce for submission.', 'edit-flow' ) );
1165 }
1166
1167 // Only allow users with the proper caps.
1168 if ( ! current_user_can( 'manage_options' ) ) {
1169 wp_die( esc_html__( 'Sorry, you do not have permission to migrate posts.', 'edit-flow' ) );
1170 }
1171
1172 $from_status = isset( $_POST['migrate_from'] ) ? sanitize_key( $_POST['migrate_from'] ) : '';
1173 $to_status = isset( $_POST['migrate_to'] ) ? sanitize_key( $_POST['migrate_to'] ) : '';
1174
1175 // Validate inputs.
1176 if ( empty( $from_status ) || empty( $to_status ) ) {
1177 wp_die( esc_html__( 'Please select both a source and target status.', 'edit-flow' ) );
1178 }
1179
1180 if ( $from_status === $to_status ) {
1181 wp_die( esc_html__( 'Source and target status cannot be the same.', 'edit-flow' ) );
1182 }
1183
1184 // The target must be a real status, otherwise posts would be stranded in a status that does not exist.
1185 if ( ! in_array( $to_status, $this->get_all_valid_statuses(), true ) ) {
1186 wp_die( esc_html__( 'Please select a valid target status.', 'edit-flow' ) );
1187 }
1188
1189 // Perform the migration.
1190 $this->reassign_post_status( $from_status, $to_status );
1191
1192 // Clear caches.
1193 wp_cache_flush();
1194
1195 $redirect_url = $this->get_link(
1196 [
1197 'action' => 'migrate-status',
1198 'message' => 'status-migrated',
1199 ]
1200 );
1201 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Admin page redirect with controlled URL.
1202 wp_redirect( $redirect_url );
1203 exit;
1204 }
1205
1206 /**
1207 * Get count of posts with a specific status.
1208 *
1209 * @since 0.9.10
1210 *
1211 * @param string $status The status slug.
1212 * @return int The number of posts with this status.
1213 */
1214 public function get_post_count_for_status( $status ) {
1215 global $wpdb;
1216
1217 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Used for status migration count check.
1218 return (int) $wpdb->get_var(
1219 $wpdb->prepare(
1220 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s",
1221 $status
1222 )
1223 );
1224 }
1225
1226 /**
1227 * Generate a link to one of the custom status actions.
1228 *
1229 * @since 0.7
1230 *
1231 * @param array $args Optional. Action and any query args to add to the URL.
1232 * @return string Direct link to complete the action.
1233 */
1234 public function get_link( $args = [] ) {
1235 if ( ! isset( $args['action'] ) ) {
1236 $args['action'] = '';
1237 }
1238 if ( ! isset( $args['page'] ) ) {
1239 $args['page'] = $this->module->settings_slug;
1240 }
1241 // Add other things we may need depending on the action.
1242 switch ( $args['action'] ) {
1243 case 'make-default':
1244 case 'delete-status':
1245 $args['nonce'] = wp_create_nonce( $args['action'] );
1246 break;
1247 default:
1248 break;
1249 }
1250 return add_query_arg( $args, get_admin_url( null, 'admin.php' ) );
1251 }
1252
1253 /**
1254 * Handle an ajax request to update the order of custom statuses.
1255 *
1256 * @since 0.7
1257 */
1258 public function handle_ajax_update_status_positions() {
1259 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1260 if ( ! isset( $_POST['custom_status_sortable_nonce'] ) || ! wp_verify_nonce( $_POST['custom_status_sortable_nonce'], 'custom-status-sortable' ) ) {
1261 $this->print_ajax_response( 'error', esc_html( $this->module->messages['nonce-failed'] ) );
1262 return;
1263 }
1264
1265 if ( ! current_user_can( 'manage_options' ) ) {
1266 $this->print_ajax_response( 'error', esc_html( $this->module->messages['invalid-permissions'] ) );
1267 return;
1268 }
1269
1270 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Array is sanitized when processing each item.
1271 if ( ! isset( $_POST['status_positions'] ) || ! is_array( $_POST['status_positions'] ) ) {
1272 $this->print_ajax_response( 'error', esc_html__( 'Terms not set.', 'edit-flow' ) );
1273 return;
1274 }
1275
1276 // Update each custom status with its new position.
1277 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Values sanitized with absint() below.
1278 foreach ( $_POST['status_positions'] as $position => $term_id ) {
1279 // Have to add 1 to the position because the index started with zero.
1280 $args = [
1281 'position' => (int) $position + 1,
1282 ];
1283 $return = $this->update_custom_status( (int) $term_id, $args );
1284 // @todo check that this was a valid return
1285 }
1286 $this->print_ajax_response( 'success', $this->module->messages['status-position-updated'] );
1287 }
1288
1289 /**
1290 * Handle an Inline Edit POST request to update status values.
1291 *
1292 * @since 0.7
1293 */
1294 public function ajax_inline_save_status() {
1295 global $edit_flow;
1296
1297 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1298 if ( ! isset( $_POST['inline_edit'] ) || ! wp_verify_nonce( $_POST['inline_edit'], 'custom-status-inline-edit-nonce' ) ) {
1299 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
1300 }
1301
1302 if ( ! current_user_can( 'manage_options' ) ) {
1303 wp_die( esc_html( $this->module->messages['invalid-permissions'] ) );
1304 }
1305
1306 $term_id = isset( $_POST['status_id'] ) ? (int) $_POST['status_id'] : 0;
1307 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with sanitize_text_field(), sanitize_title(), and wp_filter_nohtml_kses().
1308 $status_name = isset( $_POST['name'] ) ? sanitize_text_field( trim( $_POST['name'] ) ) : '';
1309 $status_slug = isset( $_POST['name'] ) ? sanitize_title( trim( $_POST['name'] ) ) : '';
1310 $status_description = isset( $_POST['description'] ) ? stripslashes( wp_filter_nohtml_kses( trim( $_POST['description'] ) ) ) : '';
1311 // phpcs:enable
1312
1313 // Check if name field was filled in.
1314 if ( empty( $status_name ) ) {
1315 $change_error = new WP_Error( 'invalid', esc_html__( 'Please enter a name for the status.', 'edit-flow' ) );
1316 wp_die( esc_html( $change_error->get_error_message() ) );
1317 }
1318
1319 // Check that the name isn't numeric.
1320 if ( is_numeric( $status_name ) ) {
1321 $change_error = new WP_Error( 'invalid', esc_html__( 'Please enter a valid, non-numeric name for the status.', 'edit-flow' ) );
1322 wp_die( esc_html( $change_error->get_error_message() ) );
1323 }
1324
1325 // Check that the status name doesn't exceed 20 chars.
1326 if ( strlen( $status_name ) > 20 ) {
1327 $change_error = new WP_Error( 'invalid', esc_html__( 'Status name cannot exceed 20 characters. Please try a shorter name.', 'edit-flow' ) );
1328 wp_die( esc_html( $change_error->get_error_message() ) );
1329 }
1330
1331 // Check to make sure the name is not restricted.
1332 if ( $edit_flow->custom_status->is_restricted_status( strtolower( $status_name ) ) ) {
1333 $change_error = new WP_Error( 'invalid', esc_html__( 'Status name is restricted. Please chose another name.', 'edit-flow' ) );
1334 wp_die( esc_html( $change_error->get_error_message() ) );
1335 }
1336
1337 // Check to make sure the status doesn't already exist.
1338 if ( $this->get_custom_status_by( 'slug', $status_slug ) && ( $this->get_custom_status_by( 'id', $term_id )->slug != $status_slug ) ) {
1339 $change_error = new WP_Error( 'invalid', esc_html__( 'Status already exists. Please choose another name.', 'edit-flow' ) );
1340 wp_die( esc_html( $change_error->get_error_message() ) );
1341 }
1342
1343 // Check to make sure the status doesn't already exist as another term.
1344 $term_exists = term_exists( sanitize_title( $status_name ), self::taxonomy_key );
1345 if ( $term_exists && isset( $term_exists['term_id'] ) && $term_exists['term_id'] != $term_id ) {
1346 $change_error = new WP_Error( 'invalid', esc_html__( 'Status name conflicts with existing term. Please choose another.', 'edit-flow' ) );
1347 wp_die( esc_html( $change_error->get_error_message() ) );
1348 }
1349
1350 // Get status_name & status_description.
1351 $args = [
1352 'name' => $status_name,
1353 'description' => $status_description,
1354 'slug' => $status_slug,
1355 ];
1356 $return = $this->update_custom_status( $term_id, $args );
1357 if ( ! is_wp_error( $return ) ) {
1358 set_current_screen( 'edit-custom-status' );
1359 $wp_list_table = new EF_Custom_Status_List_Table();
1360 $wp_list_table->prepare_items();
1361 // single_row() echoes its own output; column_* callbacks are
1362 // responsible for escaping, as per WP_List_Table's contract.
1363 $wp_list_table->single_row( $return );
1364 wp_die();
1365 } else {
1366 /* translators: 1: the status's name */
1367 $change_error = new WP_Error( 'invalid', sprintf( __( 'Could not update the status: <strong>%s</strong>', 'edit-flow' ), esc_html( $status_name ) ) );
1368 wp_die( wp_kses( $change_error->get_error_message(), array( 'strong' => array() ) ) );
1369 }
1370 }
1371
1372 /**
1373 * Register settings for notifications so we can partially use the Settings API.
1374 *
1375 * We use the Settings API for form generation, but not saving.
1376 *
1377 * @since 0.7
1378 */
1379 public function register_settings() {
1380 add_settings_section( $this->module->options_group_name . '_general', false, '__return_false', $this->module->options_group_name );
1381 add_settings_field( 'post_types', __( 'Use on these post types:', 'edit-flow' ), [ $this, 'settings_post_types_option' ], $this->module->options_group_name, $this->module->options_group_name . '_general' );
1382 add_settings_field( 'always_show_dropdown', __( 'Always show dropdown:', 'edit-flow' ), [ $this, 'settings_always_show_dropdown_option' ], $this->module->options_group_name, $this->module->options_group_name . '_general' );
1383 }
1384
1385 /**
1386 * Choose the post types that should be displayed on the calendar.
1387 *
1388 * @since 0.7
1389 */
1390 public function settings_post_types_option() {
1391 global $edit_flow;
1392 $edit_flow->settings->helper_option_custom_post_type( $this->module );
1393 }
1394
1395 /**
1396 * Option for whether the status dropdown should always be shown.
1397 *
1398 * @since 0.7
1399 */
1400 public function settings_always_show_dropdown_option() {
1401 $options = [
1402 'off' => __( 'Disabled', 'edit-flow' ),
1403 'on' => __( 'Enabled', 'edit-flow' ),
1404 ];
1405 echo '<select id="always_show_dropdown" name="' . esc_attr( $this->module->options_group_name ) . '[always_show_dropdown]">';
1406 foreach ( $options as $value => $label ) {
1407 echo '<option value="' . esc_attr( $value ) . '"';
1408 echo selected( $this->module->options->always_show_dropdown, $value );
1409 echo '>' . esc_html( $label ) . '</option>';
1410 }
1411 echo '</select>';
1412 }
1413
1414 /**
1415 * Validate input from the end user.
1416 *
1417 * @since 0.7
1418 *
1419 * @param array $new_options The new options to validate.
1420 * @return array The validated options.
1421 */
1422 public function settings_validate( $new_options ) {
1423 // Whitelist validation for the post type options.
1424 if ( ! isset( $new_options['post_types'] ) ) {
1425 $new_options['post_types'] = [];
1426 }
1427 $new_options['post_types'] = $this->clean_post_type_options( $new_options['post_types'], $this->module->post_type_support );
1428
1429 // Whitelist validation for the 'always_show_dropdown' options.
1430 if ( ! isset( $new_options['always_show_dropdown'] ) || 'on' != $new_options['always_show_dropdown'] ) {
1431 $new_options['always_show_dropdown'] = 'off';
1432 }
1433
1434 return $new_options;
1435 }
1436
1437 // phpcs:disable:WordPress.Security.NonceVerification.Missing -- Disabling nonce verification because that is not available here, it's just rendering it. The actual save is done in helper_settings_validate_and_save and that's guarded well.
1438
1439 /**
1440 * Primary configuration page for custom status class.
1441 * Shows form to add new custom statuses on the left and a
1442 * WP_List_Table with the custom status terms on the right
1443 */
1444 public function print_configure_view() {
1445 // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- No verification required for unprivileged URL check.
1446 $action = isset( $_GET['action'] ) && in_array( $_GET['action'], [ 'edit-status', 'change-options', 'migrate-status' ] ) ? $_GET['action'] : '';
1447
1448 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No verification required for unprivileged URL check.
1449 $term_id = isset( $_GET['term-id'] ) ? absint( $_GET['term-id'] ) : false;
1450
1451 if ( $term_id && 'edit-status' === $action ) {
1452 // Check whether the term exists.
1453 $custom_status = $this->get_custom_status_by( 'id', $term_id );
1454
1455 if ( ! $custom_status ) {
1456 printf( '<div class="error"><p>%s</p></div>', esc_html( $this->module->messages['status-missing'] ) );
1457 return;
1458 }
1459
1460 $edit_status_link = $this->get_link( [
1461 'action' => 'edit-status',
1462 'term-id' => $term_id,
1463 ] );
1464
1465 $name = $custom_status->name;
1466 $description = $custom_status->description;
1467
1468 $is_nonce_valid = isset( $_POST['_wpnonce'] ) && wp_verify_nonce( wp_strip_all_tags( $_POST['_wpnonce'] ), 'edit-status' );
1469
1470 if ( $is_nonce_valid ) {
1471 $name = ( isset( $_POST['name'] ) ) ? wp_strip_all_tags( $_POST['name'] ) : $custom_status->name;
1472 $description = ( isset( $_POST['description'] ) ) ? wp_strip_all_tags( $_POST['description'] ) : $custom_status->description;
1473 }
1474
1475 include_once __DIR__ . '/views/edit-status.php';
1476 } else {
1477 $custom_status_list_table = new EF_Custom_Status_List_Table();
1478 $custom_status_list_table->prepare_items();
1479 include_once __DIR__ . '/views/configure.php';
1480 }
1481 }
1482
1483 /**
1484 * This is a hack! hack! hack! until core is fixed/better supports custom statuses.
1485 *
1486 * When publishing a post with a custom status, set the status to 'pending' temporarily.
1487 *
1488 * @see Works around this limitation: http://core.trac.wordpress.org/browser/tags/3.2.1/wp-includes/post.php#L2694
1489 * @see Original thread: http://wordpress.org/support/topic/plugin-edit-flow-custom-statuses-create-timestamp-problem
1490 * @see Core ticket: http://core.trac.wordpress.org/ticket/18362
1491 */
1492 public function check_timestamp_on_publish() {
1493 global $edit_flow, $pagenow, $wpdb;
1494
1495 if ( $this->disable_custom_statuses_for_post_type() ) {
1496 return;
1497 }
1498
1499 // Handles the transition to 'publish' on edit.php (bulk edit).
1500 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- The bulk-edit nonce is verified below before any write.
1501 if ( isset( $edit_flow ) && 'edit.php' === $pagenow && isset( $_REQUEST['bulk_edit'] ) ) {
1502 // Verify the bulk-edit nonce before touching any posts.
1503 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1504 if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( $_REQUEST['_wpnonce'], 'bulk-posts' ) ) {
1505 return;
1506 }
1507
1508 // For every post_id, set the post_status as 'pending' only when there's no timestamp set for $post_date_gmt.
1509 if ( isset( $_REQUEST['post'] ) && isset( $_REQUEST['_status'] ) && 'publish' == $_REQUEST['_status'] ) {
1510 $post_ids = array_map( 'intval', (array) $_REQUEST['post'] );
1511 foreach ( $post_ids as $post_id ) {
1512 // Only act on posts the current user is allowed to edit.
1513 if ( ! current_user_can( 'edit_post', $post_id ) ) {
1514 continue;
1515 }
1516 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core workaround for custom status timestamp.
1517 $wpdb->update( $wpdb->posts, [ 'post_status' => 'pending' ], [
1518 'ID' => $post_id,
1519 'post_date_gmt' => '0000-00-00 00:00:00',
1520 ] );
1521 clean_post_cache( $post_id );
1522 }
1523 }
1524 }
1525 // phpcs:enable
1526
1527 // Handles the transition to 'publish' on post.php.
1528 // phpcs:disable WordPress.Security.NonceVerification.Missing -- The post-edit nonce is verified below before any write.
1529 if ( isset( $edit_flow ) && 'post.php' == $pagenow && isset( $_POST['publish'] ) && isset( $_POST['post_ID'] ) ) {
1530 $post_id = (int) $_POST['post_ID'];
1531
1532 // Verify the post-edit nonce and capability before touching the post.
1533 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1534 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'update-post_' . $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
1535 return;
1536 }
1537
1538 // Set the post_status as 'pending' only when there's no timestamp set for $post_date_gmt.
1539 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core workaround for custom status timestamp.
1540 $ret = $wpdb->update( $wpdb->posts, [ 'post_status' => 'pending' ], [
1541 'ID' => $post_id,
1542 'post_date_gmt' => '0000-00-00 00:00:00',
1543 ] );
1544 clean_post_cache( $post_id );
1545 foreach ( [ 'aa', 'mm', 'jj', 'hh', 'mn' ] as $timeunit ) {
1546 if ( isset( $_POST[ $timeunit ] ) && ! empty( $_POST[ 'hidden_' . $timeunit ] ) && $_POST[ 'hidden_' . $timeunit ] != $_POST[ $timeunit ] ) {
1547 $edit_date = '1';
1548 break;
1549 }
1550 }
1551 if ( $ret && empty( $edit_date ) ) {
1552 add_filter( 'pre_post_date', [ $this, 'helper_timestamp_hack' ] );
1553 add_filter( 'pre_post_date_gmt', [ $this, 'helper_timestamp_hack' ] );
1554 }
1555 }
1556 // phpcs:enable WordPress.Security.NonceVerification.Missing
1557 }
1558
1559 /**
1560 * PHP < 5.3.x doesn't support anonymous functions.
1561 *
1562 * This helper is only used for the check_timestamp_on_publish method above.
1563 *
1564 * @since 0.7.3
1565 *
1566 * @return string The current time or empty string.
1567 */
1568 public function helper_timestamp_hack() {
1569 return ( 'pre_post_date' == current_filter() ) ? current_time( 'mysql' ) : '';
1570 }
1571
1572 /**
1573 * This is a hack! hack! hack! until core is fixed/better supports custom statuses.
1574 *
1575 * Normalize post_date_gmt if it isn't set to the past or the future.
1576 *
1577 * @since 0.6.5
1578 *
1579 * @see Works around this limitation: https://core.trac.wordpress.org/browser/tags/4.5.1/src/wp-includes/post.php#L3182
1580 * @see Original thread: http://wordpress.org/support/topic/plugin-edit-flow-custom-statuses-create-timestamp-problem
1581 * @see Core ticket: http://core.trac.wordpress.org/ticket/18362
1582 *
1583 * @param array $data An array of slashed, sanitized post data.
1584 * @param array $postarr An array of sanitized post data.
1585 * @return array Modified post data.
1586 */
1587 public function fix_custom_status_timestamp( $data, $postarr ) {
1588 global $edit_flow;
1589 // Don't run this if Edit Flow isn't active, or we're on some other page.
1590 if ( $this->disable_custom_statuses_for_post_type()
1591 || ! isset( $edit_flow ) ) {
1592 return $data;
1593 }
1594
1595 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1596
1597 // Post is scheduled or published? Ignoring.
1598 if ( ! in_array( $postarr['post_status'], $status_slugs ) ) {
1599 return $data;
1600 }
1601
1602 // phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- Not commented out code, just describes conditional logic.
1603 // If empty, keep empty.
1604 if ( empty( $postarr['post_date_gmt'] )
1605 || '0000-00-00 00:00:00' == $postarr['post_date_gmt'] ) {
1606 $data['post_date_gmt'] = '0000-00-00 00:00:00';
1607 }
1608
1609 return $data;
1610 }
1611
1612 /**
1613 * Update post_date to current time when publishing from a custom status.
1614 *
1615 * When a post with a custom status (like "Pitch" or "Assigned") is published,
1616 * the post_date should reflect the actual publication time, not the original
1617 * creation time. This matches WordPress core behavior for 'draft' and 'pending'.
1618 *
1619 * @since 0.10.0
1620 *
1621 * @see https://github.com/Automattic/Edit-Flow/issues/750
1622 *
1623 * @param array $data An array of slashed, sanitized post data.
1624 * @param array $postarr An array of sanitized post data.
1625 * @return array Modified post data with updated post_date if applicable.
1626 */
1627 public function update_post_date_on_publish_from_custom_status( $data, $postarr ) {
1628 // Only process when transitioning to 'publish' status.
1629 if ( 'publish' !== $data['post_status'] ) {
1630 return $data;
1631 }
1632
1633 // Must be an existing post (have an ID) for this to be a status transition.
1634 if ( empty( $postarr['ID'] ) ) {
1635 return $data;
1636 }
1637
1638 // Get the current post from the database to check its current status.
1639 $current_post = get_post( $postarr['ID'] );
1640 if ( ! $current_post ) {
1641 return $data;
1642 }
1643
1644 // If already published, scheduled, or private, don't change the date.
1645 $published_statuses = [ 'publish', 'future', 'private' ];
1646 if ( in_array( $current_post->post_status, $published_statuses, true ) ) {
1647 return $data;
1648 }
1649
1650 // If the post had an explicitly set GMT date (scheduled), don't change it.
1651 if ( ! empty( $current_post->post_date_gmt )
1652 && '0000-00-00 00:00:00' !== $current_post->post_date_gmt ) {
1653 return $data;
1654 }
1655
1656 // If the user is explicitly setting a different date in this update, respect it.
1657 // Compare the incoming post_date with the current post_date.
1658 if ( ! empty( $postarr['post_date'] ) && $postarr['post_date'] !== $current_post->post_date ) {
1659 return $data;
1660 }
1661
1662 // Update post_date to current time.
1663 $data['post_date'] = current_time( 'mysql' );
1664 $data['post_date_gmt'] = current_time( 'mysql', true );
1665
1666 return $data;
1667 }
1668
1669 /**
1670 * Register REST API filters for every post type that supports custom statuses.
1671 *
1672 * @since 0.10.4
1673 */
1674 public function register_rest_api_filters() {
1675 $post_types = $this->get_post_types_for_module( $this->module );
1676 foreach ( $post_types as $post_type ) {
1677 add_filter( "rest_prepare_{$post_type}", [ $this, 'fix_custom_status_rest_date_gmt' ], 10, 2 );
1678 }
1679 }
1680
1681 /**
1682 * Return null for date and date_gmt in REST responses for posts in a
1683 * custom status whose GMT date has not been explicitly set.
1684 *
1685 * WP core's WP_REST_Posts_Controller::prepare_item_for_response() only returns
1686 * null for date_gmt when a post's status is 'draft' or 'pending'. Custom
1687 * statuses fall through to the branch that converts post_date_gmt of
1688 * '0000-00-00 00:00:00' into a concrete ISO 8601 date derived from post_date.
1689 *
1690 * Even nulling date_gmt alone is insufficient: Gutenberg's
1691 * isEditedPostDateFloating selector hardcodes the status whitelist to
1692 * 'draft'/'auto-draft'/'pending', so for a custom status it short-circuits
1693 * to false and the Publish field falls back to formatting the concrete date.
1694 * Nulling date as well pushes Gutenberg's label renderer into its
1695 * "Immediately" branch (which triggers when date itself is null) without
1696 * needing to modify core.
1697 *
1698 * The saved post_date in the database is untouched: Gutenberg only sends
1699 * changed fields on save, so a nulled date in the response does not
1700 * propagate back unless the user actively edits the schedule.
1701 *
1702 * @since 0.10.4
1703 *
1704 * @see https://github.com/Automattic/edit-flow/issues/925
1705 *
1706 * @param \WP_REST_Response $response The response object.
1707 * @param \WP_Post $post Post object.
1708 * @return \WP_REST_Response
1709 */
1710 public function fix_custom_status_rest_date_gmt( $response, $post ) {
1711 if ( ! $response instanceof \WP_REST_Response ) {
1712 return $response;
1713 }
1714
1715 if ( '0000-00-00 00:00:00' !== $post->post_date_gmt ) {
1716 return $response;
1717 }
1718
1719 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1720 if ( ! in_array( $post->post_status, $status_slugs, true ) ) {
1721 return $response;
1722 }
1723
1724 $data = $response->get_data();
1725 if ( ! is_array( $data ) ) {
1726 return $response;
1727 }
1728
1729 if ( array_key_exists( 'date_gmt', $data ) ) {
1730 $data['date_gmt'] = null;
1731 }
1732 if ( array_key_exists( 'date', $data ) ) {
1733 $data['date'] = null;
1734 }
1735
1736 $response->set_data( $data );
1737
1738 return $response;
1739 }
1740
1741 /**
1742 * A new hack! hack! hack! until core better supports custom statuses.
1743 *
1744 * If the post_name is set, set it, otherwise keep it empty.
1745 *
1746 * @since 0.9.4
1747 *
1748 * @see https://github.com/Automattic/Edit-Flow/issues/523
1749 * @see https://github.com/Automattic/Edit-Flow/issues/633
1750 *
1751 * @param array $data An array of slashed, sanitized post data.
1752 * @param array $postarr An array of sanitized post data.
1753 * @return array Modified post data.
1754 */
1755 public function maybe_keep_post_name_empty( $data, $postarr ) {
1756 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1757
1758 // Ignore if it's not a post status and post type we support.
1759 if ( ! in_array( $data['post_status'], $status_slugs )
1760 || ! in_array( $data['post_type'], $this->get_post_types_for_module( $this->module ) ) ) {
1761 return $data;
1762 }
1763
1764 // If the post_name was intentionally set, set the post_name.
1765 if ( ! empty( $postarr['post_name'] ) ) {
1766 $data['post_name'] = sanitize_title( $postarr['post_name'] );
1767 return $data;
1768 }
1769
1770 // Otherwise, keep the post_name empty.
1771 $data['post_name'] = '';
1772
1773 return $data;
1774 }
1775
1776 /**
1777 * A new hack! hack! hack! until core better supports custom statuses.
1778 *
1779 * `wp_unique_post_slug` is used to set the `post_name`. When a custom status is used, WordPress will try
1780 * really hard to set `post_name`, and we leverage `wp_unique_post_slug` to prevent it being set.
1781 *
1782 * @since 0.9.4
1783 *
1784 * @see https://github.com/WordPress/WordPress/blob/396647666faebb109d9cd4aada7bb0c7d0fb8aca/wp-includes/post.php#L3932
1785 *
1786 * @param string|null $override_slug Short-circuit return value.
1787 * @param string $slug The desired slug.
1788 * @param int $post_ID Post ID.
1789 * @param string $post_status The post status.
1790 * @param string $post_type Post type.
1791 * @param int $post_parent Post parent ID.
1792 * @return string|null The override slug or null.
1793 */
1794 public function fix_unique_post_slug( $override_slug, $slug, $post_ID, $post_status, $post_type, $post_parent ) {
1795 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1796
1797 if ( ! in_array( $post_status, $status_slugs )
1798 || ! in_array( $post_type, $this->get_post_types_for_module( $this->module ) ) ) {
1799 return null;
1800 }
1801
1802 $post = get_post( $post_ID );
1803
1804 if ( empty( $post ) ) {
1805 return null;
1806 }
1807
1808 if ( $post->post_name ) {
1809 return $slug;
1810 }
1811
1812 return '';
1813 }
1814
1815
1816 /**
1817 * Another hack! hack! hack! until core better supports custom statuses.
1818 *
1819 * The preview link for an unpublished post should always be ?p=
1820 *
1821 * @since 0.7.4
1822 *
1823 * @param string $preview_link URL used for the post preview.
1824 * @return string Modified preview link.
1825 */
1826 public function fix_preview_link_part_one( $preview_link ) {
1827 global $pagenow;
1828
1829 $post = get_post( get_the_ID() );
1830
1831 // Only modify if we're using a pre-publish status on a supported custom post type.
1832 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1833 if ( ! $post
1834 || ! is_admin()
1835 || 'post.php' != $pagenow
1836 || ! in_array( $post->post_status, $status_slugs )
1837 || ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ) )
1838 || strpos( $preview_link, 'preview_id' ) !== false
1839 || 'sample' == $post->filter ) {
1840 return $preview_link;
1841 }
1842
1843 return $this->get_preview_link( $post );
1844 }
1845
1846 /**
1847 * Another hack! hack! hack! until core better supports custom statuses.
1848 *
1849 * The preview link for an unpublished post should always be ?p=
1850 * The code used to trigger a post preview doesn't also apply the 'preview_post_link' filter.
1851 * So we can't do a targeted filter. Instead, we can even more hackily filter get_permalink.
1852 *
1853 * @since 0.7.4
1854 *
1855 * @see http://core.trac.wordpress.org/ticket/19378
1856 *
1857 * @param string $permalink The post's permalink.
1858 * @param WP_Post|int $post The post in question.
1859 * @param bool $sample Is it a sample permalink.
1860 * @return string Modified permalink.
1861 */
1862 public function fix_preview_link_part_two( $permalink, $post, $sample ) {
1863 global $pagenow;
1864
1865 if ( is_int( $post ) ) {
1866 $post = get_post( $post );
1867 }
1868
1869 // Bail if $post is not a valid post object.
1870 if ( ! $post instanceof WP_Post ) {
1871 return $permalink;
1872 }
1873
1874 // Should we be doing anything at all?
1875 if ( ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ) ) ) {
1876 return $permalink;
1877 }
1878
1879 // Is this published?
1880 if ( in_array( $post->post_status, $this->published_statuses ) ) {
1881 return $permalink;
1882 }
1883
1884 // Are we overriding the permalink? Don't do anything.
1885 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1886 if ( isset( $_POST['action'] ) && 'sample-permalink' == $_POST['action'] ) {
1887 return $permalink;
1888 }
1889
1890 // Are we previewing the post from the normal post screen?
1891 if ( ( 'post.php' == $pagenow || 'post-new.php' == $pagenow )
1892 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1893 && ! isset( $_POST['wp-preview'] ) ) {
1894 return $permalink;
1895 }
1896
1897 // If it's a sample permalink, not a preview.
1898 if ( $sample ) {
1899 return $permalink;
1900 }
1901
1902 return $this->get_preview_link( $post );
1903 }
1904
1905 /**
1906 * Another hack! hack! hack! until core better supports custom statuses.
1907 *
1908 * The preview link for a saved unpublished post with a custom status returns a 'preview_nonce'
1909 * in it and needs to be removed when previewing it to return a viewable preview link.
1910 *
1911 * @since 0.9
1912 *
1913 * @see https://github.com/Automattic/Edit-Flow/issues/513
1914 *
1915 * @param string $preview_link URL used for the post preview.
1916 * @param WP_Post $query_args Post object.
1917 * @return string Modified preview link.
1918 */
1919 public function fix_preview_link_part_three( $preview_link, $query_args ) {
1920 $autosave = wp_get_post_autosave( $query_args->ID, get_current_user_id() );
1921 if ( $autosave ) {
1922 foreach ( array_intersect( array_keys( _wp_post_revision_fields( $query_args ) ), array_keys( _wp_post_revision_fields( $autosave ) ) ) as $field ) {
1923 if ( normalize_whitespace( $query_args->$field ) != normalize_whitespace( $autosave->$field ) ) {
1924 // Pass through, it's a personal preview.
1925 return $preview_link;
1926 }
1927 }
1928 }
1929 return remove_query_arg( [ 'preview_nonce' ], $preview_link );
1930 }
1931
1932 /**
1933 * Another hack! hack! hack! until core better supports custom statuses.
1934 *
1935 * Posts and pages with a custom status are kept with an empty `post_name`
1936 * (see `maybe_keep_post_name_empty()` and `fix_unique_post_slug()`). WordPress's
1937 * template hierarchy resolves slug-specific templates — `page-{slug}.php` and
1938 * `single-{post-type}-{slug}.php` — from `$post->post_name`, so an empty slug
1939 * silently degrades template selection when the post is previewed: only the
1940 * generic `page.php` / `single.php` fallback can match. The same empty slug also
1941 * breaks template-based conditional logic such as ACF location rules.
1942 *
1943 * Here we synthesise a slug from the title on the queried object in memory only,
1944 * for the duration of the preview request. Nothing is persisted: the stored
1945 * `post_name` stays empty, so none of the slug-emptying behaviour changes.
1946 *
1947 * @since 0.11.0
1948 *
1949 * @see https://github.com/Automattic/Edit-Flow/issues/933
1950 */
1951 public function fix_preview_template() {
1952 // Only singular requests resolve a slug-specific template from post_name.
1953 if ( ! is_singular() ) {
1954 return;
1955 }
1956
1957 $post = get_queried_object();
1958
1959 // Nothing to do unless we have a titled post whose slug is empty.
1960 if ( ! $post instanceof WP_Post
1961 || ! empty( $post->post_name )
1962 || empty( $post->post_title ) ) {
1963 return;
1964 }
1965
1966 // Only act on the pre-publish custom statuses and post types we support.
1967 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
1968 if ( ! in_array( $post->post_status, $status_slugs, true )
1969 || ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ), true ) ) {
1970 return;
1971 }
1972
1973 /**
1974 * Filters the slug synthesised in memory for template selection while
1975 * previewing a custom-status post. Return an empty string to leave the
1976 * slug empty and disable this behaviour for the post.
1977 *
1978 * @since 0.11.0
1979 *
1980 * @param string $post_name Slug derived from the post title.
1981 * @param WP_Post $post The post being previewed.
1982 */
1983 $post_name = apply_filters( 'ef_preview_template_post_name', sanitize_title( $post->post_title ), $post );
1984
1985 if ( '' === $post_name ) {
1986 return;
1987 }
1988
1989 // Set it on the queried object (used by the template hierarchy) and keep the
1990 // loop post and global in sync for template tags and conditional logic.
1991 $post->post_name = $post_name;
1992
1993 global $wp_query;
1994 if ( isset( $wp_query->post ) && $wp_query->post instanceof WP_Post && $wp_query->post->ID === $post->ID ) {
1995 $wp_query->post->post_name = $post_name;
1996 }
1997 if ( isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post && $GLOBALS['post']->ID === $post->ID ) {
1998 $GLOBALS['post']->post_name = $post_name;
1999 }
2000 }
2001
2002 /**
2003 * Fix get_sample_permalink. Previously the 'editable_slug' filter was leveraged
2004 * to correct the sample permalink a user could edit on post.php. Since 4.4.40
2005 * the `get_sample_permalink` filter was added which allows greater flexibility in
2006 * manipulating the slug. Critical for cases like editing the sample permalink on
2007 * hierarchical post types.
2008 *
2009 * @since 0.8.2
2010 *
2011 * @param string $permalink Sample permalink.
2012 * @param int $post_id Post ID.
2013 * @param string $title Post title.
2014 * @param string $name Post name (slug).
2015 * @param WP_Post $post Post object.
2016 * @return array Modified permalink array.
2017 */
2018 public function fix_get_sample_permalink( $permalink, $post_id, $title, $name, $post ) {
2019
2020 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
2021
2022 if ( ! in_array( $post->post_status, $status_slugs )
2023 || ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ) ) ) {
2024 return $permalink;
2025 }
2026
2027 remove_filter( 'get_sample_permalink', [ $this, 'fix_get_sample_permalink' ], 10, 5 );
2028
2029 $new_name = ! is_null( $name ) ? $name : $post->post_name;
2030 $new_title = ! is_null( $title ) ? $title : $post->post_title;
2031
2032 $post = get_post( $post_id );
2033 $status_before = $post->post_status;
2034 $post->post_status = 'draft';
2035
2036 $permalink = get_sample_permalink( $post, $title, sanitize_title( $new_name ? $new_name : $new_title, $post->ID ) );
2037
2038 $post->post_status = $status_before;
2039
2040 add_filter( 'get_sample_permalink', [ $this, 'fix_get_sample_permalink' ], 10, 5 );
2041
2042 return $permalink;
2043 }
2044
2045 /**
2046 * Hack to work around post status check in get_sample_permalink_html.
2047 *
2048 * The get_sample_permalink_html checks the status of the post and if it's
2049 * a draft generates a certain permalink structure.
2050 * We need to do the same work it's doing for custom statuses in order
2051 * to support this link.
2052 *
2053 * @see https://core.trac.wordpress.org/browser/tags/4.5.2/src/wp-admin/includes/post.php#L1296
2054 *
2055 * @since 0.8.2
2056 *
2057 * @param string $permalink Sample permalink HTML markup.
2058 * @param int $post_id Post ID.
2059 * @param string $new_title New sample permalink title.
2060 * @param string $new_slug New sample permalink slug.
2061 * @param WP_Post $post Post object.
2062 * @return string Modified sample permalink HTML.
2063 */
2064 public function fix_get_sample_permalink_html( $permalink, $post_id, $new_title, $new_slug, $post ) {
2065 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
2066
2067 if ( ! in_array( $post->post_status, $status_slugs )
2068 || ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ) ) ) {
2069 return $permalink;
2070 }
2071
2072 remove_filter( 'get_sample_permalink_html', [ $this, 'fix_get_sample_permalink_html' ], 10, 5 );
2073
2074 $post->post_status = 'draft';
2075 $sample_permalink_html = get_sample_permalink_html( $post, $new_title, $new_slug );
2076
2077 add_filter( 'get_sample_permalink_html', [ $this, 'fix_get_sample_permalink_html' ], 10, 5 );
2078
2079 return $sample_permalink_html;
2080 }
2081
2082
2083 /**
2084 * Fixes a bug where post-pagination doesn't work when previewing a post with a custom status.
2085 *
2086 * This filter only modifies output if `is_preview()` is true.
2087 * Used by `wp_link_pages_link` filter.
2088 *
2089 * @link https://github.com/Automattic/Edit-Flow/issues/192
2090 *
2091 * @param string $link The page number HTML output.
2092 * @param int $i Page number for paginated posts' page links.
2093 * @return string Modified link.
2094 */
2095 public function modify_preview_link_pagination_url( $link, $i ) {
2096 // Use the original $link when not in preview mode.
2097 if ( ! is_preview() ) {
2098 return $link;
2099 }
2100
2101 // Get an array of valid custom status slugs.
2102 $custom_statuses = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
2103
2104 // Apply original link filters from core `wp_link_pages()`.
2105 $r = apply_filters( 'wp_link_pages_args', [
2106 'link_before' => '',
2107 'link_after' => '',
2108 'pagelink' => '%',
2109 ]);
2110
2111 // _wp_link_page() && _ef_wp_link_page() produce an opening link tag ( <a href=".."> )
2112 // This is necessary to replicate core behavior:
2113 $link = $r['link_before'] . str_replace( '%', $i, $r['pagelink'] ) . $r['link_after'];
2114 $link = _ef_wp_link_page( $i, $custom_statuses ) . $link . '</a>';
2115
2116
2117 return $link;
2118 }
2119
2120 /**
2121 * Get the proper preview link for a post.
2122 *
2123 * @since 0.8
2124 *
2125 * @param WP_Post $post The post object.
2126 * @return string The preview URL.
2127 */
2128 private function get_preview_link( $post ) {
2129
2130 if ( 'page' == $post->post_type ) {
2131 $args = [
2132 'page_id' => $post->ID,
2133 ];
2134 } elseif ( 'post' == $post->post_type ) {
2135 $args = [
2136 'p' => $post->ID,
2137 'preview' => 'true',
2138 ];
2139 } else {
2140 $args = [
2141 'p' => $post->ID,
2142 'post_type' => $post->post_type,
2143 ];
2144 }
2145
2146 $args['preview_id'] = $post->ID;
2147 return add_query_arg( $args, home_url( '/' ) );
2148 }
2149
2150 /**
2151 * Another hack! hack! hack! until core better supports custom statuses.
2152 *
2153 * The preview link for an unpublished post should always be ?p=, even in the list table.
2154 *
2155 * @since 0.7.4
2156 *
2157 * @see http://core.trac.wordpress.org/ticket/19378
2158 *
2159 * @param array $actions An array of row action links.
2160 * @param WP_Post $post The post object.
2161 * @return array Modified row action links.
2162 */
2163 public function fix_post_row_actions( $actions, $post ) {
2164 global $pagenow;
2165
2166 // Only modify if we're using a pre-publish status on a supported custom post type.
2167 $status_slugs = wp_list_pluck( $this->get_custom_statuses(), 'slug' );
2168 if ( 'edit.php' != $pagenow
2169 || ! in_array( $post->post_status, $status_slugs )
2170 || ! in_array( $post->post_type, $this->get_post_types_for_module( $this->module ) ) ) {
2171 return $actions;
2172 }
2173
2174 // 'view' is only set if the user has permission to post
2175 if ( empty( $actions['view'] ) ) {
2176 return $actions;
2177 }
2178
2179 if ( 'page' == $post->post_type ) {
2180 $args = [
2181 'page_id' => $post->ID,
2182 ];
2183 } elseif ( 'post' == $post->post_type ) {
2184 $args = [
2185 'p' => $post->ID,
2186 ];
2187 } else {
2188 $args = [
2189 'p' => $post->ID,
2190 'post_type' => $post->post_type,
2191 ];
2192 }
2193 $args['preview'] = 'true';
2194 $preview_link = add_query_arg( $args, home_url( '/' ) );
2195
2196 /* translators: %s: post title */
2197 $actions['view'] = '<a href="' . esc_url( $preview_link ) . '" title="' . esc_attr( sprintf( __( 'Preview &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . __( 'Preview', 'edit-flow' ) . '</a>';
2198 return $actions;
2199 }
2200 }
2201
2202 }
2203
2204
2205 // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
2206
2207 /**
2208 * Custom Statuses uses WordPress' List Table API for generating the custom status management table
2209 *
2210 * @since 0.7
2211 */
2212 class EF_Custom_Status_List_Table extends WP_List_Table {
2213
2214 /**
2215 * Callback arguments for the list table.
2216 *
2217 * @var array
2218 */
2219 protected $callback_args;
2220
2221 /**
2222 * Default post status slug.
2223 *
2224 * @var string
2225 */
2226 protected $default_status;
2227
2228 /**
2229 * Construct the extended class.
2230 */
2231 public function __construct() {
2232
2233 parent::__construct( [
2234 'plural' => 'custom statuses',
2235 'singular' => 'custom status',
2236 'ajax' => true,
2237 ] );
2238 }
2239
2240 /**
2241 * Pull in the data we'll be displaying on the table
2242 *
2243 * @since 0.7
2244 */
2245 public function prepare_items() {
2246 global $edit_flow;
2247
2248 $columns = $this->get_columns();
2249 $hidden = [
2250 'position',
2251 ];
2252 $sortable = [];
2253 $this->_column_headers = [ $columns, $hidden, $sortable ];
2254
2255 $this->items = $edit_flow->custom_status->get_custom_statuses();
2256 $total_items = count( $this->items );
2257 $this->default_status = $edit_flow->custom_status->get_default_custom_status()->slug;
2258
2259 $this->set_pagination_args( [
2260 'total_items' => $total_items,
2261 'per_page' => $total_items,
2262 ] );
2263 }
2264
2265 /**
2266 * Message to be displayed when there are no custom statuses. Should never be displayed, but we'll customize it
2267 * just in case.
2268 *
2269 * @since 0.7
2270 */
2271 public function no_items() {
2272 _e( 'No custom statuses found.', 'edit-flow' );
2273 }
2274
2275 /**
2276 * Table shows (hidden) position, status name, status description, and the post count for each activated
2277 * post type
2278 *
2279 * @since 0.7
2280 *
2281 * @return array $columns Columns to be registered with the List Table
2282 */
2283 public function get_columns() {
2284 global $edit_flow;
2285
2286 $columns = [
2287 'position' => __( 'Position', 'edit-flow' ),
2288 'name' => __( 'Name', 'edit-flow' ),
2289 'description' => __( 'Description', 'edit-flow' ),
2290 ];
2291
2292 $post_types = get_post_types( '', 'objects' );
2293 $supported_post_types = $edit_flow->helpers->get_post_types_for_module( $edit_flow->custom_status->module );
2294 foreach ( $post_types as $post_type ) {
2295 if ( in_array( $post_type->name, $supported_post_types ) ) {
2296 $columns[ $post_type->name ] = $post_type->label;
2297 }
2298 }
2299
2300 return $columns;
2301 }
2302
2303 /**
2304 * Fallback column callback.
2305 * Primarily used to display post count for each post type.
2306 *
2307 * @since 0.7
2308 *
2309 * @param object $item Custom status as an object.
2310 * @param string $column_name Name of the column as registered in $this->prepare_items().
2311 * @return string $output What will be rendered.
2312 */
2313 public function column_default( $item, $column_name ) {
2314 global $edit_flow;
2315
2316 // Handle custom post counts for different post types.
2317 $post_types = get_post_types( '', 'names' );
2318 if ( in_array( $column_name, $post_types ) ) {
2319
2320 // @todo Cachify this.
2321 $post_count = wp_cache_get( "ef_custom_status_count_$column_name" );
2322 if ( false === $post_count ) {
2323 $posts = wp_count_posts( $column_name );
2324 $post_status = $item->slug;
2325 // To avoid error notices when changing the name of non-standard statuses.
2326 if ( isset( $posts->$post_status ) ) {
2327 $post_count = $posts->$post_status;
2328 } else {
2329 $post_count = 0;
2330 }
2331 // phpcs:ignore Squiz.PHP.CommentedOutCode.Found, Squiz.Commenting.InlineComment.InvalidEndChar -- Intentionally commented out caching line for future implementation.
2332 // wp_cache_set( "ef_custom_status_count_$column_name", $post_count );
2333 }
2334 $output = sprintf(
2335 '<a title="See all %1$ss saved as \'%2$s\'" href="%3$s">%4$s</a>',
2336 esc_attr( $column_name ),
2337 esc_attr( $item->name ),
2338 esc_url( $edit_flow->helpers->filter_posts_link( $item->slug, $column_name ) ),
2339 esc_html( $post_count )
2340 );
2341 return $output;
2342 }
2343 }
2344
2345 /**
2346 * Hidden column for storing the status position.
2347 *
2348 * @since 0.7
2349 *
2350 * @param object $item Custom status as an object.
2351 * @return string $output What will be rendered.
2352 */
2353 public function column_position( $item ) {
2354 return esc_html( $item->position );
2355 }
2356
2357 /**
2358 * Displayed column showing the name of the status.
2359 *
2360 * @since 0.7
2361 *
2362 * @param object $item Custom status as an object.
2363 * @return string $output What will be rendered.
2364 */
2365 public function column_name( $item ) {
2366 global $edit_flow;
2367
2368 $item_edit_link = esc_url( $edit_flow->custom_status->get_link( [
2369 'action' => 'edit-status',
2370 'term-id' => $item->term_id,
2371 ] ) );
2372
2373 $output = '<strong><a href="' . $item_edit_link . '">' . esc_html( $item->name ) . '</a>';
2374 if ( $item->slug == $this->default_status ) {
2375 $output .= ' - ' . __( 'Default', 'edit-flow' );
2376 }
2377 $output .= '</strong>';
2378
2379 // Don't allow for any of these status actions when adding a new custom status.
2380 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display check.
2381 if ( isset( $_GET['action'] ) && 'add' == $_GET['action'] ) {
2382 return $output;
2383 }
2384
2385 $actions = [];
2386 $actions['edit'] = "<a href='$item_edit_link'>" . __( 'Edit', 'edit-flow' ) . '</a>';
2387 $actions['inline hide-if-no-js'] = '<a href="#" class="editinline">' . __( 'Quick&nbsp;Edit', 'edit-flow' ) . '</a>';
2388 $actions['make_default'] = sprintf( '<a href="%1$s">' . __( 'Make&nbsp;Default', 'edit-flow' ) . '</a>', $edit_flow->custom_status->get_link( [
2389 'action' => 'make-default',
2390 'term-id' => $item->term_id,
2391 ] ) );
2392
2393 // Prevent deleting draft status.
2394 if ( 'draft' !== $item->slug && $item->slug !== $this->default_status ) {
2395 $actions['delete delete-status'] = sprintf( '<a href="%1$s">' . __( 'Delete', 'edit-flow' ) . '</a>', $edit_flow->custom_status->get_link( [
2396 'action' => 'delete-status',
2397 'term-id' => $item->term_id,
2398 ] ) );
2399 }
2400
2401 $output .= $this->row_actions( $actions, false );
2402 $output .= '<div class="hidden" id="inline_' . esc_attr( $item->term_id ) . '">';
2403 $output .= '<div class="name">' . esc_html( $item->name ) . '</div>';
2404 $output .= '<div class="description">' . esc_html( $item->description ) . '</div>';
2405 $output .= '</div>';
2406
2407 return $output;
2408 }
2409
2410 /**
2411 * Displayed column showing the description of the status.
2412 *
2413 * @since 0.7
2414 *
2415 * @param object $item Custom status as an object.
2416 * @return string $output What will be rendered.
2417 */
2418 public function column_description( $item ) {
2419 return esc_html( $item->description );
2420 }
2421
2422 /**
2423 * Prepare and echo a single custom status row.
2424 *
2425 * @since 0.7
2426 *
2427 * @param object $item Custom status as an object.
2428 */
2429 public function single_row( $item ) {
2430 static $alternate_class = '';
2431 $alternate_class = ( '' == $alternate_class ? ' alternate' : '' );
2432
2433 printf( '<tr id="term-%d" class="term-static%s">', (int) $item->term_id, esc_attr( $alternate_class ) );
2434 // single_row_columns() echoes its own output; the column_* callbacks are
2435 // responsible for escaping, as per WP_List_Table's contract.
2436 $this->single_row_columns( $item );
2437 echo '</tr>';
2438 }
2439
2440 /**
2441 * Hidden form used for inline editing functionality.
2442 *
2443 * @since 0.7
2444 */
2445 public function inline_edit() {
2446 global $edit_flow;
2447 ?>
2448 <form method="get" action=""><table style="display: none"><tbody id="inlineedit">
2449 <tr id="inline-edit" class="inline-edit-row" style="display: none"><td colspan="<?php echo esc_attr( $this->get_column_count() ); ?>" class="colspanchange">
2450 <fieldset><div class="inline-edit-col">
2451 <h4><?php _e( 'Quick Edit', 'edit-flow' ); ?></h4>
2452 <label>
2453 <span class="title"><?php _e( 'Name', 'edit-flow' ); ?></span>
2454 <span class="input-text-wrap"><input type="text" name="name" class="ptitle" value="" maxlength="20" /></span>
2455 </label>
2456 <label>
2457 <span class="title"><?php _e( 'Description', 'edit-flow' ); ?></span>
2458 <span class="input-text-wrap"><input type="text" name="description" class="pdescription" value="" /></span>
2459 </label>
2460 </div></fieldset>
2461 <p class="inline-edit-save submit">
2462 <a accesskey="c" href="#inline-edit" title="<?php _e( 'Cancel', 'edit-flow' ); ?>" class="cancel button-secondary alignleft"><?php _e( 'Cancel', 'edit-flow' ); ?></a>
2463 <?php $update_text = __( 'Update Status', 'edit-flow' ); ?>
2464 <a accesskey="s" href="#inline-edit" title="<?php echo esc_attr( $update_text ); ?>" class="save button-primary alignright"><?php echo esc_html( $update_text ); ?></a>
2465 <img class="waiting" style="display:none;" src="<?php echo esc_url( admin_url( 'images/wpspin_light.gif' ) ); ?>" alt="" />
2466 <span class="error" style="display:none;"></span>
2467 <?php wp_nonce_field( 'custom-status-inline-edit-nonce', 'inline_edit', false ); ?>
2468 <br class="clear" />
2469 </p>
2470 </td></tr>
2471 </tbody></table></form>
2472 <?php
2473 }
2474 }
2475