config = $config; $this->workflow = $workflow; } /** * Register hooks. * * @return void */ public function register_hooks() { add_action( 'init', array( $this, 'register' ), 11 ); add_filter( 'display_post_states', array( $this, 'display_post_states' ), 10, 2 ); add_action( 'admin_notices', array( $this, 'orphan_notice' ) ); add_action( 'admin_init', array( $this, 'handle_orphan_dismissal' ) ); } /** * The custom statuses across all workflows, as [ status => label ]. * * @return array */ public function custom_statuses() { $out = array(); foreach ( $this->config->all() as $workflow ) { if ( empty( $workflow['steps'] ) ) { continue; } foreach ( $workflow['steps'] as $step ) { if ( empty( $step['status'] ) || in_array( $step['status'], self::CORE, true ) ) { continue; } $out[ $step['status'] ] = isset( $step['label'] ) ? $step['label'] : ucwords( str_replace( array( 'ostew_', '_' ), array( '', ' ' ), $step['status'] ) ); } } return $out; } /** * Statuses this install once configured and no longer does, as [ status => label ]. * * Content can still be sitting in one of these. Registering them anyway is what * keeps that content visible, queryable and actionable after a step is removed. * * @return array */ public function orphan_statuses() { $current = $this->custom_statuses(); $out = array(); foreach ( $this->config->known_statuses() as $status ) { if ( isset( $current[ $status ] ) || in_array( $status, self::CORE, true ) ) { continue; } $out[ $status ] = sprintf( /* translators: %s: humanised status name. */ __( '%s (removed from workflow)', 'ost-editorial-workflow' ), ucwords( str_replace( array( 'ostew_', '_' ), array( '', ' ' ), $status ) ) ); } return $out; } /** * Register each custom status, plus any orphaned one still held by content. * * @return void */ public function register() { $current = $this->custom_statuses(); // Remember the current set so a later removal can be reconciled. $this->config->remember_statuses( array_keys( $current ) ); foreach ( $current + $this->orphan_statuses() as $status => $label ) { register_post_status( $status, array( 'label' => $label, 'public' => false, 'internal' => false, 'protected' => true, 'exclude_from_search' => true, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, // Built directly (not _n_noop) because $label is dynamic (already // translated from the workflow config); the count markup is static. 'label_count' => array( 'singular' => $label . ' (%s)', 'plural' => $label . ' (%s)', 'context' => null, 'domain' => 'ost-editorial-workflow', ), ) ); } } /** * How many posts currently hold each of the given statuses. * * Statuses with no content are omitted from the result. * * @param string[] $statuses Status keys. * @return array [ status => count ] */ public function count_posts_in_statuses( array $statuses ) { $out = array(); foreach ( $this->breakdown_posts_in_statuses( $statuses ) as $row ) { if ( ! isset( $out[ $row['status'] ] ) ) { $out[ $row['status'] ] = 0; } $out[ $row['status'] ] += $row['total']; } return $out; } /** * Posts holding the given statuses, broken down by status and post type. * * Rows with a zero count are omitted. * * @param string[] $statuses Status keys. * @return array[] Each row: [ 'status' => string, 'post_type' => string, 'total' => int ]. */ public function breakdown_posts_in_statuses( array $statuses ) { global $wpdb; $statuses = array_values( array_unique( array_filter( $statuses ) ) ); if ( empty( $statuses ) ) { return array(); } $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); /* * A direct query is deliberate here. The point of this method is to find * content whose status may no longer be registered, which is exactly what * the WordPress query APIs will not return. The table name comes from * $wpdb, the placeholder list is generated from a count, and every value * goes through prepare(), so nothing external reaches the SQL. */ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $sql = $wpdb->prepare( "SELECT post_status, post_type, COUNT(*) AS total FROM {$wpdb->posts} WHERE post_status IN ( {$placeholders} ) AND post_type NOT IN ( 'revision', 'nav_menu_item' ) GROUP BY post_status, post_type", $statuses ); $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:enable $out = array(); foreach ( (array) $rows as $row ) { $total = (int) $row['total']; if ( $total > 0 ) { $out[] = array( 'status' => (string) $row['post_status'], 'post_type' => (string) $row['post_type'], 'total' => $total, ); } } return $out; } const DISMISS_META = '_ostew_orphans_dismissed'; /** * Is the current screen one where the stranded-content warning belongs? * * The warning is deliberately not shown across the whole admin. It appears on * the Dashboard, on this plugin's own screens, and on the post lists where the * affected content actually is — the places where someone can act on it. * * @return bool */ private function notice_screen_is_relevant() { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); if ( ! $screen ) { return false; } if ( 'dashboard' === $screen->id ) { return true; } if ( false !== strpos( (string) $screen->id, 'ost-editorial-workflow' ) ) { return true; } if ( 'edit' === $screen->base && in_array( (string) $screen->post_type, $this->config->managed_post_types(), true ) ) { return true; } return false; } /** * A fingerprint of the current stranded set, so a dismissal applies to this * situation only and the warning returns if something new gets stranded. * * @param array $rows Breakdown rows. * @return string */ private function orphan_fingerprint( array $rows ) { $parts = array(); foreach ( $rows as $row ) { $parts[] = $row['status'] . ':' . $row['post_type'] . ':' . $row['total']; } sort( $parts ); return md5( implode( '|', $parts ) ); } /** * Record that this user has dismissed the warning for the current situation. * * @return void */ public function handle_orphan_dismissal() { if ( ! isset( $_GET['ostew_dismiss_orphans'] ) ) { return; } $fingerprint = sanitize_key( wp_unslash( $_GET['ostew_dismiss_orphans'] ) ); if ( ! wp_verify_nonce( isset( $_GET['_wpnonce'] ) ? sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ) : '', 'ostew_dismiss_orphans' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } update_user_meta( get_current_user_id(), self::DISMISS_META, $fingerprint ); wp_safe_redirect( remove_query_arg( array( 'ostew_dismiss_orphans', '_wpnonce' ) ) ); exit; } /** * Warn an administrator when content is stranded in a removed status, and * forget statuses that nothing holds any more. * * @return void */ public function orphan_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! $this->notice_screen_is_relevant() ) { return; } $orphans = $this->orphan_statuses(); if ( empty( $orphans ) ) { return; } $rows = $this->breakdown_posts_in_statuses( array_keys( $orphans ) ); // Nothing holds these any more — stop registering them. $held = wp_list_pluck( $rows, 'status' ); $empty = array_diff( array_keys( $orphans ), $held ); if ( ! empty( $empty ) ) { $this->config->forget_statuses( $empty ); } if ( empty( $rows ) ) { return; } // Respect a dismissal, until the stranded set itself changes. $fingerprint = $this->orphan_fingerprint( $rows ); if ( get_user_meta( get_current_user_id(), self::DISMISS_META, true ) === $fingerprint ) { return; } echo '

'; esc_html_e( 'Editorial Workflow: content is sitting in a status that is no longer part of any workflow.', 'ost-editorial-workflow' ); echo '

'; esc_html_e( 'The status is still registered so the content stays reachable. Move these items to a status in the current workflow, and the warning will clear itself.', 'ost-editorial-workflow' ); printf( ' %2$s', esc_url( wp_nonce_url( add_query_arg( 'ostew_dismiss_orphans', $fingerprint ), 'ostew_dismiss_orphans' ) ), esc_html__( 'Dismiss this warning', 'ost-editorial-workflow' ) ); echo '

'; } /** * Show the workflow status as a post-state label in admin lists. * * @param array $states Post states. * @param \WP_Post $post Post. * @return array */ public function display_post_states( $states, $post ) { if ( ! $post instanceof \WP_Post ) { return $states; } $custom = $this->custom_statuses() + $this->orphan_statuses(); if ( isset( $custom[ $post->post_status ] ) ) { $states[ 'ostew_' . $post->post_status ] = $custom[ $post->post_status ]; } return $states; } }