Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
75 / 80
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Notices
93.67% covered (success)
93.67%
74 / 79
50.00% covered (danger)
50.00%
4 / 8
20.10
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 generated_notice
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 deferred_notice
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 deleted_notice
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 failed_notice
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 error_notice
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 verify_result_nonce
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_query_count
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Admin notices for the BeyondWords bulk-edit actions.
4 *
5 * @package BeyondWords\PostsList
6 * @since   3.0.0
7 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
8 */
9
10declare( strict_types = 1 );
11
12namespace BeyondWords\PostsList;
13
14defined( 'ABSPATH' ) || exit;
15
16/**
17 * One notice per bulk-action result, all nonce-gated against direct URL fiddling.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class Notices {
22
23    /**
24     * Register WordPress hooks.
25     */
26    public static function init(): void {
27        add_action( 'admin_notices', [ self::class, 'generated_notice' ] );
28        add_action( 'admin_notices', [ self::class, 'deferred_notice' ] );
29        add_action( 'admin_notices', [ self::class, 'deleted_notice' ] );
30        add_action( 'admin_notices', [ self::class, 'failed_notice' ] );
31        add_action( 'admin_notices', [ self::class, 'error_notice' ] );
32    }
33
34    /**
35     * "Audio was requested for N posts." notice after a Generate Audio bulk action.
36     */
37    public static function generated_notice(): void {
38        $count = self::get_query_count( 'beyondwords_bulk_generated' );
39
40        if ( null === $count ) {
41            return;
42        }
43
44        $message = sprintf(
45            /* translators: %d is replaced with the number of posts processed */
46            _n(
47                'Audio was requested for %d post.',
48                'Audio was requested for %d posts.',
49                $count,
50                'speechkit'
51            ),
52            $count
53        );
54        ?>
55        <div id="beyondwords-bulk-edit-notice-generated" class="notice notice-info is-dismissible">
56            <p><?php echo esc_html( $message ); ?></p>
57        </div>
58        <?php
59    }
60
61    /**
62     * "N posts still need audio" notice after a bulk action hit the off-VIP sync cap.
63     *
64     * Deferred posts already have their generate flag set, so re-running the
65     * action completes them.
66     */
67    public static function deferred_notice(): void {
68        $count = self::get_query_count( 'beyondwords_bulk_deferred' );
69
70        if ( null === $count ) {
71            return;
72        }
73
74        $message = sprintf(
75            /* translators: %d is replaced with the number of posts not yet processed */
76            _n(
77                '%d post still needs audio — run Generate audio again to continue.',
78                '%d posts still need audio — run Generate audio again to continue.',
79                $count,
80                'speechkit'
81            ),
82            $count
83        );
84        ?>
85        <div id="beyondwords-bulk-edit-notice-deferred" class="notice notice-warning is-dismissible">
86            <p><?php echo esc_html( $message ); ?></p>
87        </div>
88        <?php
89    }
90
91    /**
92     * "Audio was deleted for N posts." notice after a Delete Audio bulk action.
93     */
94    public static function deleted_notice(): void {
95        $count = self::get_query_count( 'beyondwords_bulk_deleted' );
96
97        if ( null === $count ) {
98            return;
99        }
100
101        $message = sprintf(
102            /* translators: %d is replaced with the number of posts processed */
103            _n(
104                'Audio was deleted for %d post.',
105                'Audio was deleted for %d posts.',
106                $count,
107                'speechkit'
108            ),
109            $count
110        );
111        ?>
112        <div id="beyondwords-bulk-edit-notice-deleted" class="notice notice-info is-dismissible">
113            <p><?php echo esc_html( $message ); ?></p>
114        </div>
115        <?php
116    }
117
118    /**
119     * "N posts failed." notice after a bulk action where some items errored.
120     */
121    public static function failed_notice(): void {
122        $count = self::get_query_count( 'beyondwords_bulk_failed' );
123
124        if ( null === $count ) {
125            return;
126        }
127
128        $message = sprintf(
129            /* translators: %d is replaced with the number of posts that were skipped */
130            _n(
131                '%d post failed, check for errors in the BeyondWords column below.',
132                '%d posts failed, check for errors in the BeyondWords column below.',
133                $count,
134                'speechkit'
135            ),
136            $count
137        );
138        ?>
139        <div id="beyondwords-bulk-edit-notice-failed" class="notice notice-error is-dismissible">
140            <p><?php echo esc_html( $message ); ?></p>
141        </div>
142        <?php
143    }
144
145    /**
146     * Top-level error notice after a bulk action that threw.
147     */
148    public static function error_notice(): void {
149        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
150        if ( ! self::verify_result_nonce() || ! isset( $_GET['beyondwords_bulk_error'] ) ) {
151            return;
152        }
153
154        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
155        $message = sanitize_text_field( wp_unslash( $_GET['beyondwords_bulk_error'] ) );
156
157        if ( '' === $message ) {
158            return;
159        }
160        ?>
161        <div id="beyondwords-bulk-edit-notice-error" class="notice notice-error is-dismissible">
162            <p><?php echo esc_html( $message ); ?></p>
163        </div>
164        <?php
165    }
166
167    /**
168     * Verify the result nonce in bulk-action redirects, dying via `wp_nonce_ays()` on tamper.
169     *
170     * Returns false (without exiting) when the nonce param is absent so callers
171     * can short-circuit normal page loads cheaply.
172     */
173    private static function verify_result_nonce(): bool {
174        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
175        if ( ! isset( $_GET['beyondwords_bulk_edit_result_nonce'] ) ) {
176            return false;
177        }
178
179        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
180        if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['beyondwords_bulk_edit_result_nonce'] ) ), 'beyondwords_bulk_edit_result' ) ) {
181            wp_nonce_ays( '' );
182        }
183
184        return true;
185    }
186
187    /**
188     * Read a positive integer count from `$_GET[$key]` after verifying the result nonce.
189     *
190     * @return int|null `null` when nonce missing, count param missing, or count not positive.
191     */
192    private static function get_query_count( string $key ): ?int {
193        if ( ! self::verify_result_nonce() ) {
194            return null;
195        }
196
197        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
198        if ( ! isset( $_GET[ $key ] ) ) {
199            return null;
200        }
201
202        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
203        $count = (int) sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
204
205        return $count > 0 ? $count : null;
206    }
207}