Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.68% covered (success)
93.68%
89 / 95
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Notices
93.62% covered (success)
93.62%
88 / 94
44.44% covered (danger)
44.44%
4 / 9
22.13
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
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
 skipped_notice
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 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, 'skipped_notice' ] );
29        add_action( 'admin_notices', [ self::class, 'deferred_notice' ] );
30        add_action( 'admin_notices', [ self::class, 'deleted_notice' ] );
31        add_action( 'admin_notices', [ self::class, 'failed_notice' ] );
32        add_action( 'admin_notices', [ self::class, 'error_notice' ] );
33    }
34
35    /**
36     * "Audio was requested for N posts." notice after a Generate Audio bulk action.
37     */
38    public static function generated_notice(): void {
39        $count = self::get_query_count( 'beyondwords_bulk_generated' );
40
41        if ( null === $count ) {
42            return;
43        }
44
45        $message = sprintf(
46            /* translators: %d is replaced with the number of posts processed */
47            _n(
48                'Audio was requested for %d post.',
49                'Audio was requested for %d posts.',
50                $count,
51                'speechkit'
52            ),
53            $count
54        );
55        ?>
56        <div id="beyondwords-bulk-edit-notice-generated" class="notice notice-info is-dismissible">
57            <p><?php echo esc_html( $message ); ?></p>
58        </div>
59        <?php
60    }
61
62    /**
63     * "N posts were skipped" notice after a Generate Audio bulk action.
64     *
65     * Skipped posts had nothing to generate — an ineligible post status, an
66     * existing recording with autoregeneration off, or a concurrent create.
67     */
68    public static function skipped_notice(): void {
69        $count = self::get_query_count( 'beyondwords_bulk_skipped' );
70
71        if ( null === $count ) {
72            return;
73        }
74
75        $message = sprintf(
76            /* translators: %d is replaced with the number of posts that were skipped */
77            _n(
78                '%d post was skipped because no audio change was needed.',
79                '%d posts were skipped because no audio change was needed.',
80                $count,
81                'speechkit'
82            ),
83            $count
84        );
85        ?>
86        <div id="beyondwords-bulk-edit-notice-skipped" class="notice notice-info is-dismissible">
87            <p><?php echo esc_html( $message ); ?></p>
88        </div>
89        <?php
90    }
91
92    /**
93     * "N posts still need audio" notice after a bulk action hit the off-VIP sync cap.
94     *
95     * Deferred posts already have their generate flag set, so re-running the
96     * action completes them.
97     */
98    public static function deferred_notice(): void {
99        $count = self::get_query_count( 'beyondwords_bulk_deferred' );
100
101        if ( null === $count ) {
102            return;
103        }
104
105        $message = sprintf(
106            /* translators: %d is replaced with the number of posts not yet processed */
107            _n(
108                '%d post still needs audio — run Generate audio again to continue.',
109                '%d posts still need audio — run Generate audio again to continue.',
110                $count,
111                'speechkit'
112            ),
113            $count
114        );
115        ?>
116        <div id="beyondwords-bulk-edit-notice-deferred" class="notice notice-warning is-dismissible">
117            <p><?php echo esc_html( $message ); ?></p>
118        </div>
119        <?php
120    }
121
122    /**
123     * "Audio was deleted for N posts." notice after a Delete Audio bulk action.
124     */
125    public static function deleted_notice(): void {
126        $count = self::get_query_count( 'beyondwords_bulk_deleted' );
127
128        if ( null === $count ) {
129            return;
130        }
131
132        $message = sprintf(
133            /* translators: %d is replaced with the number of posts processed */
134            _n(
135                'Audio was deleted for %d post.',
136                'Audio was deleted for %d posts.',
137                $count,
138                'speechkit'
139            ),
140            $count
141        );
142        ?>
143        <div id="beyondwords-bulk-edit-notice-deleted" class="notice notice-info is-dismissible">
144            <p><?php echo esc_html( $message ); ?></p>
145        </div>
146        <?php
147    }
148
149    /**
150     * "N posts failed." notice after a bulk action where some items errored.
151     */
152    public static function failed_notice(): void {
153        $count = self::get_query_count( 'beyondwords_bulk_failed' );
154
155        if ( null === $count ) {
156            return;
157        }
158
159        $message = sprintf(
160            /* translators: %d is replaced with the number of posts that failed */
161            _n(
162                '%d post failed, check for errors in the BeyondWords column below.',
163                '%d posts failed, check for errors in the BeyondWords column below.',
164                $count,
165                'speechkit'
166            ),
167            $count
168        );
169        ?>
170        <div id="beyondwords-bulk-edit-notice-failed" class="notice notice-error is-dismissible">
171            <p><?php echo esc_html( $message ); ?></p>
172        </div>
173        <?php
174    }
175
176    /**
177     * Top-level error notice after a bulk action that threw.
178     */
179    public static function error_notice(): void {
180        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
181        if ( ! self::verify_result_nonce() || ! isset( $_GET['beyondwords_bulk_error'] ) ) {
182            return;
183        }
184
185        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
186        $message = sanitize_text_field( wp_unslash( $_GET['beyondwords_bulk_error'] ) );
187
188        if ( '' === $message ) {
189            return;
190        }
191        ?>
192        <div id="beyondwords-bulk-edit-notice-error" class="notice notice-error is-dismissible">
193            <p><?php echo esc_html( $message ); ?></p>
194        </div>
195        <?php
196    }
197
198    /**
199     * Verify the result nonce in bulk-action redirects, dying via `wp_nonce_ays()` on tamper.
200     *
201     * Returns false (without exiting) when the nonce param is absent so callers
202     * can short-circuit normal page loads cheaply.
203     */
204    private static function verify_result_nonce(): bool {
205        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
206        if ( ! isset( $_GET['beyondwords_bulk_edit_result_nonce'] ) ) {
207            return false;
208        }
209
210        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
211        if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['beyondwords_bulk_edit_result_nonce'] ) ), 'beyondwords_bulk_edit_result' ) ) {
212            wp_nonce_ays( '' );
213        }
214
215        return true;
216    }
217
218    /**
219     * Read a positive integer count from `$_GET[$key]` after verifying the result nonce.
220     *
221     * @return int|null `null` when nonce missing, count param missing, or count not positive.
222     */
223    private static function get_query_count( string $key ): ?int {
224        if ( ! self::verify_result_nonce() ) {
225            return null;
226        }
227
228        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
229        if ( ! isset( $_GET[ $key ] ) ) {
230            return null;
231        }
232
233        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
234        $count = (int) sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
235
236        return $count > 0 ? $count : null;
237    }
238}