config = $config; $this->workflow = $workflow; $this->statuses = $statuses; $this->audit = $audit; } /** * Human-readable reasons a configuration change would strand content. * * A status that is about to leave the configuration while posts still hold it * is refused rather than saved, because those posts would lose every action. * * @param array $proposed Proposed workflow definitions. * @return string[] Empty when the change is safe. */ private function stranding_errors( $proposed ) { $removed = $this->config->statuses_removed_by( $proposed ); if ( empty( $removed ) ) { return array(); } $errors = array(); foreach ( $this->statuses->count_posts_in_statuses( $removed ) as $status => $total ) { $errors[] = sprintf( /* translators: 1: status key, 2: number of items. */ _n( 'Status "%1$s" is being removed, but %2$d item still has it. Move that item to another status first.', 'Status "%1$s" is being removed, but %2$d items still have it. Move those items to another status first.', $total, 'ost-editorial-workflow' ), $status, $total ); } return $errors; } /** * Register hooks. * * @return void */ public function register_hooks() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_post_ostew_export', array( $this, 'handle_export' ) ); add_action( 'admin_post_ostew_import', array( $this, 'handle_import' ) ); add_action( 'admin_post_ostew_reset', array( $this, 'handle_reset' ) ); add_action( 'admin_post_ostew_audit_export', array( $this, 'handle_audit_export' ) ); } /** * Add the top-level menu. * * @return void */ public function add_menu() { add_menu_page( __( 'Editorial Workflow', 'ost-editorial-workflow' ), __( 'Editorial Workflow', 'ost-editorial-workflow' ), 'manage_options', self::MENU_SLUG, array( $this, 'render' ), 'dashicons-randomize', 58 ); } /** * Register the notification settings. * * @return void */ public function register_settings() { register_setting( self::SETTING_GRP, self::SETTING, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => array(), ) ); } /** * Sanitize the settings array. * * @param mixed $input Raw input. * @return array */ public function sanitize_settings( $input ) { $input = is_array( $input ) ? $input : array(); return array( 'from_name' => isset( $input['from_name'] ) ? sanitize_text_field( $input['from_name'] ) : '', 'from_email' => isset( $input['from_email'] ) ? sanitize_email( $input['from_email'] ) : '', 'notifications_enabled' => empty( $input['notifications_enabled'] ) ? 0 : 1, ); } /** * Current tab. * * @return string */ private function tab() { $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return in_array( $tab, array( 'overview', 'notifications', 'tools' ), true ) ? $tab : 'overview'; } /** * Render the settings page. * * @return void */ public function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } $tab = $this->tab(); $base = admin_url( 'admin.php?page=' . self::MENU_SLUG ); ?>

notices(); if ( 'notifications' === $tab ) { $this->render_notifications(); } elseif ( 'tools' === $tab ) { $this->render_tools(); } else { $this->render_overview(); } ?>
config->all() as $wf ) { echo '

' . esc_html( isset( $wf['label'] ) ? $wf['label'] : $wf['id'] ) . '

'; echo '

' . esc_html__( 'Applies to:', 'ost-editorial-workflow' ) . ' ' . esc_html( implode( ', ', (array) $wf['post_types'] ) ) . '

'; echo ''; echo ''; foreach ( $wf['steps'] as $step ) { $who = ''; if ( ! empty( $step['actor_roles'] ) ) { $who = implode( ', ', $step['actor_roles'] ); } elseif ( ! empty( $step['approver_roles'] ) ) { $who = implode( ', ', $step['approver_roles'] ); } $to = array(); if ( isset( $step['advance_to'] ) ) { $to[] = $step['advance_to']; } if ( isset( $step['conditional_to']['status'] ) ) { $to[] = $step['conditional_to']['status'] . ' ' . __( '(if flagged)', 'ost-editorial-workflow' ); } if ( isset( $step['on_approve'] ) ) { $to[] = __( 'approve → ', 'ost-editorial-workflow' ) . $step['on_approve']; } if ( isset( $step['on_reject'] ) ) { $to[] = __( 'send back → ', 'ost-editorial-workflow' ) . $step['on_reject']; } printf( '', esc_html( $step['status'] ), esc_html( $who ), esc_html( implode( '; ', $to ) ) ); } echo '
' . esc_html__( 'Status', 'ost-editorial-workflow' ) . '' . esc_html__( 'Who acts', 'ost-editorial-workflow' ) . '' . esc_html__( 'Advances to', 'ost-editorial-workflow' ) . '
%s%s%s
'; if ( ! empty( $wf['escalation'] ) ) { echo '

' . esc_html__( 'Escalation:', 'ost-editorial-workflow' ) . ' '; $bits = array(); foreach ( $wf['escalation'] as $e ) { $urgent = isset( $e['urgent']['sla_hours'] ) ? ' / ' . (int) $e['urgent']['sla_hours'] . 'h ' . __( 'urgent', 'ost-editorial-workflow' ) : ''; $bits[] = sprintf( '%s: %dh%s', $e['status'], (int) $e['sla_hours'], $urgent ); } echo esc_html( implode( ' · ', $bits ) ) . '

'; } echo '
'; } $next = wp_next_scheduled( Escalation::CRON_HOOK ); echo '

' . esc_html__( 'Escalation scan:', 'ost-editorial-workflow' ) . ' '; echo $next ? esc_html( sprintf( /* translators: %s: time. */ __( 'next run %s', 'ost-editorial-workflow' ), get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $next ), 'Y-m-d H:i' ) ) ) : esc_html__( 'not scheduled', 'ost-editorial-workflow' ); echo '

'; echo '

' . esc_html__( 'To change the chain itself, export the JSON under the Tools tab, edit it, and import it back.', 'ost-editorial-workflow' ) . '

'; } /** * Notifications tab. * * @return void */ private function render_notifications() { $opts = get_option( self::SETTING, array() ); ?>
config->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $action = admin_url( 'admin-post.php' ); ?>

config->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="ost-editorial-workflow-config.json"' ); echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } /** * Audit trail CSV download. * * @return void */ public function handle_audit_export() { check_admin_referer( 'ostew_audit_export' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'ost-editorial-workflow' ) ); } $csv = $this->audit->to_csv(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="ost-editorial-workflow-audit-' . gmdate( 'Y-m-d' ) . '.csv"' ); echo $csv; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } /** * Import handler. * * @return void */ public function handle_import() { check_admin_referer( 'ostew_import' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'ost-editorial-workflow' ) ); } // Not sanitized on purpose: it's raw JSON — sanitizing would corrupt it. It is // json_decode'd and run through Config::validate() before anything is saved. $raw = isset( $_POST['ostew_json'] ) ? wp_unslash( $_POST['ostew_json'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $decoded = json_decode( $raw, true ); $flag = 'imported'; if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { $flag = 'badjson'; } else { $errors = $this->config->validate( $decoded ); if ( ! empty( $errors ) ) { set_transient( 'ostew_import_errors', $errors, 60 ); $flag = 'invalid'; } else { $stranding = $this->stranding_errors( $decoded ); if ( ! empty( $stranding ) ) { set_transient( 'ostew_import_errors', $stranding, 60 ); $flag = 'occupied'; } else { $this->config->update( $decoded ); } } } wp_safe_redirect( add_query_arg( array( 'tab' => 'tools', 'ostew_msg' => $flag, ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } /** * Reset handler. * * @return void */ public function handle_reset() { check_admin_referer( 'ostew_reset' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'ost-editorial-workflow' ) ); } $stranding = $this->stranding_errors( $this->config->defaults() ); if ( ! empty( $stranding ) ) { set_transient( 'ostew_import_errors', $stranding, 60 ); wp_safe_redirect( add_query_arg( array( 'tab' => 'tools', 'ostew_msg' => 'occupied', ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } $this->config->reset(); wp_safe_redirect( add_query_arg( array( 'tab' => 'tools', 'ostew_msg' => 'reset', ), admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } /** * Admin notices for tool actions. * * @return void */ private function notices() { $msg = isset( $_GET['ostew_msg'] ) ? sanitize_key( wp_unslash( $_GET['ostew_msg'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $msg ) { return; } if ( 'imported' === $msg ) { echo '

' . esc_html__( 'Workflow configuration saved.', 'ost-editorial-workflow' ) . '

'; } elseif ( 'reset' === $msg ) { echo '

' . esc_html__( 'Reset to the default workflow.', 'ost-editorial-workflow' ) . '

'; } elseif ( 'badjson' === $msg ) { echo '

' . esc_html__( 'That was not valid JSON.', 'ost-editorial-workflow' ) . '

'; } elseif ( 'invalid' === $msg ) { $errors = get_transient( 'ostew_import_errors' ); delete_transient( 'ostew_import_errors' ); echo '

' . esc_html__( 'Configuration not saved — please fix:', 'ost-editorial-workflow' ) . '

'; } elseif ( 'occupied' === $msg ) { $errors = get_transient( 'ostew_import_errors' ); delete_transient( 'ostew_import_errors' ); echo '

' . esc_html__( 'Configuration not saved — it would leave content with no way forward:', 'ost-editorial-workflow' ) . '

'; } } }