Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.55% covered (success)
93.55%
145 / 155
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
BulkEdit
93.51% covered (success)
93.51%
144 / 154
50.00% covered (danger)
50.00%
4 / 8
38.40
0.00% covered (danger)
0.00%
0 / 1
 init
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 bulk_edit_custom_box
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 save_bulk_edit
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
12
 generate_audio_for_posts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 delete_audio_for_posts
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
5.58
 bulk_actions_edit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 handle_bulk_generate_action
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
6.01
 handle_bulk_delete_action
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
4.02
1<?php
2/**
3 * Bulk Edit handler for the posts list screen: generate/delete audio for many posts.
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 * Bulk-edit support for the BeyondWords column.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class BulkEdit {
22
23    /**
24     * Register WordPress hooks.
25     */
26    public static function init(): void {
27        add_action( 'bulk_edit_custom_box', [ self::class, 'bulk_edit_custom_box' ], 10, 2 );
28        add_action( 'wp_ajax_save_bulk_edit_beyondwords', [ self::class, 'save_bulk_edit' ] );
29
30        add_action(
31            'wp_loaded',
32            static function (): void {
33                $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
34
35                if ( ! is_array( $post_types ) ) {
36                    return;
37                }
38
39                foreach ( $post_types as $post_type ) {
40                    add_filter( "bulk_actions-edit-{$post_type}", [ self::class, 'bulk_actions_edit' ] );
41                    add_filter( "handle_bulk_actions-edit-{$post_type}", [ self::class, 'handle_bulk_delete_action' ], 10, 3 );
42                    add_filter( "handle_bulk_actions-edit-{$post_type}", [ self::class, 'handle_bulk_generate_action' ], 10, 3 );
43                }
44            }
45        );
46    }
47
48    /**
49     * Render the bulk-edit fieldset for the BeyondWords column.
50     *
51     * @param string $column_name Column slug being rendered.
52     * @param string $post_type   Current post-type screen.
53     */
54    public static function bulk_edit_custom_box( $column_name, $post_type ): void {
55        if ( 'beyondwords' !== $column_name ) {
56            return;
57        }
58
59        if ( ! in_array( $post_type, \BeyondWords\Settings\Utils::get_compatible_post_types(), true ) ) {
60            return;
61        }
62
63        wp_nonce_field( 'beyondwords_bulk_edit_nonce', 'beyondwords_bulk_edit' );
64        ?>
65        <fieldset class="inline-edit-col-right">
66            <div class="inline-edit-col">
67                <div class="inline-edit-group wp-clearfix">
68                    <label class="alignleft">
69                        <span class="title"><?php esc_html_e( 'BeyondWords', 'speechkit' ); ?></span>
70                        <select name="beyondwords_generate_audio">
71                            <option value="-1"><?php esc_html_e( '— No change —', 'speechkit' ); ?></option>
72                            <option value="generate"><?php esc_html_e( 'Generate audio', 'speechkit' ); ?></option>
73                            <option value="delete"><?php esc_html_e( 'Delete audio', 'speechkit' ); ?></option>
74                        </select>
75                    </label>
76                </div>
77            </div>
78        </fieldset>
79        <?php
80    }
81
82    /**
83     * AJAX handler (`wp_ajax_save_bulk_edit_beyondwords`) for the inline bulk edit.
84     *
85     * The nonce is CSRF protection only; any logged-in user can reach a
86     * `wp_ajax_*` callback, so capabilities must additionally gate the action.
87     */
88    public static function save_bulk_edit(): void {
89        // phpcs:disable WordPress.Security.NonceVerification.Missing
90        if (
91            ! isset( $_POST['beyondwords_bulk_edit_nonce'] )
92            || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['beyondwords_bulk_edit_nonce'] ) ), 'beyondwords_bulk_edit' )
93        ) {
94            wp_nonce_ays( '' );
95        }
96
97        if ( ! current_user_can( 'edit_posts' ) ) {
98            wp_send_json_error(
99                [ 'message' => __( 'Sorry, you are not allowed to bulk edit BeyondWords audio.', 'speechkit' ) ],
100                403
101            );
102        }
103
104        if ( ! isset( $_POST['beyondwords_bulk_edit'] ) || ! isset( $_POST['post_ids'] ) || ! is_array( $_POST['post_ids'] ) ) {
105            wp_send_json_error(
106                [ 'message' => __( 'Missing bulk-edit action or selected posts.', 'speechkit' ) ],
107                400
108            );
109        }
110
111        $post_ids = array_filter( array_map( 'absint', wp_unslash( $_POST['post_ids'] ) ) );
112        $action   = sanitize_text_field( wp_unslash( $_POST['beyondwords_bulk_edit'] ) );
113        // phpcs:enable WordPress.Security.NonceVerification.Missing
114
115        if ( 'generate' !== $action && 'delete' !== $action ) {
116            wp_send_json_error(
117                [ 'message' => __( 'Unrecognised bulk-edit action.', 'speechkit' ) ],
118                400
119            );
120        }
121
122        // Only operate on posts the current user is actually allowed to edit, so a
123        // crafted request cannot mutate or delete audio on out-of-reach posts.
124        $post_ids = array_values(
125            array_filter(
126                $post_ids,
127                static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
128            )
129        );
130
131        // A capable user may have selected only posts they cannot edit; treat that
132        // as a clean no-op rather than the delete helper's empty-batch error.
133        if ( empty( $post_ids ) ) {
134            wp_send_json_success( [] );
135        }
136
137        try {
138            $updated_post_ids = ( 'generate' === $action )
139                ? self::generate_audio_for_posts( $post_ids )
140                : self::delete_audio_for_posts( $post_ids );
141        } catch ( \Exception $e ) {
142            wp_send_json_error(
143                [ 'message' => $e->getMessage() ],
144                500
145            );
146        }
147
148        wp_send_json_success( $updated_post_ids );
149    }
150
151    /**
152     * Mark each post for audio generation, skipping ones that already have content.
153     *
154     * @param int[]|null $post_ids Posts to process.
155     *
156     * @return int[] IDs of posts updated.
157     */
158    public static function generate_audio_for_posts( ?array $post_ids ): array {
159        if ( ! is_array( $post_ids ) ) {
160            return [];
161        }
162
163        $updated_post_ids = [];
164
165        foreach ( $post_ids as $post_id ) {
166            if ( ! get_post_meta( $post_id, 'beyondwords_content_id', true ) ) {
167                update_post_meta( $post_id, 'beyondwords_generate_audio', '1' );
168            }
169            $updated_post_ids[] = $post_id;
170        }
171
172        return $updated_post_ids;
173    }
174
175    /**
176     * Delete BeyondWords audio for each post and clear the related post meta.
177     *
178     * @param int[]|null $post_ids Posts to process.
179     *
180     * @return int[] IDs of posts updated.
181     *
182     * @throws \Exception When the BeyondWords API does not return a deletable batch.
183     */
184    public static function delete_audio_for_posts( ?array $post_ids ): array {
185        if ( ! is_array( $post_ids ) ) {
186            return [];
187        }
188
189        $response = \BeyondWords\Post\Sync::batch_delete_audio_for_posts( $post_ids );
190
191        if ( ! $response ) {
192            throw new \Exception(
193                esc_html__( 'Error while bulk deleting audio. Please contact support with reference BULK-NO-RESPONSE.', 'speechkit' )
194            );
195        }
196
197        $keys             = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' );
198        $updated_post_ids = [];
199
200        foreach ( $response as $post_id ) {
201            foreach ( $keys as $key ) {
202                delete_post_meta( $post_id, $key );
203            }
204            $updated_post_ids[] = $post_id;
205        }
206
207        return $updated_post_ids;
208    }
209
210    /**
211     * Add BeyondWords actions to the bulk-action dropdown.
212     *
213     * @param array<string,string> $bulk_array Existing bulk actions.
214     *
215     * @return array<string,string>
216     */
217    public static function bulk_actions_edit( $bulk_array ) {
218        $bulk_array['beyondwords_generate_audio'] = __( 'Generate audio', 'speechkit' );
219        $bulk_array['beyondwords_delete_audio']   = __( 'Delete audio', 'speechkit' );
220
221        return $bulk_array;
222    }
223
224    /**
225     * Handle the "Generate audio" bulk action.
226     *
227     * @param string $redirect   Redirect URL the bulk handler will use.
228     * @param string $doaction   Selected bulk action.
229     * @param int[]  $object_ids Post IDs in the bulk selection.
230     */
231    public static function handle_bulk_generate_action( $redirect, $doaction, $object_ids ) {
232        if ( 'beyondwords_generate_audio' !== $doaction ) {
233            return $redirect;
234        }
235
236        $redirect = remove_query_arg(
237            [
238                'beyondwords_bulk_generated',
239                'beyondwords_bulk_deferred',
240                'beyondwords_bulk_deleted',
241                'beyondwords_bulk_failed',
242                'beyondwords_bulk_skipped',
243                'beyondwords_bulk_error',
244            ],
245            $redirect
246        );
247
248        // Core routes custom bulk actions here after only a coarse edit_posts check,
249        // so guard per-post — else a crafted request could trigger billable generation.
250        $object_ids = array_values(
251            array_filter(
252                $object_ids,
253                static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
254            )
255        );
256
257        // Nothing editable selected: a clean no-op reporting a zero count.
258        if ( empty( $object_ids ) ) {
259            $redirect = add_query_arg( 'beyondwords_bulk_generated', 0, $redirect );
260            $redirect = add_query_arg( 'beyondwords_bulk_failed', 0, $redirect );
261
262            $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
263
264            return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
265        }
266
267        // Sync offloads to cron on VIP or runs a hard-capped synchronous batch
268        // off VIP; it also normalises and sorts the IDs, so no sort() here.
269        try {
270            $counts   = \BeyondWords\Post\Sync::bulk_generate_audio_for_posts( $object_ids );
271            $redirect = add_query_arg( 'beyondwords_bulk_generated', $counts['generated'], $redirect );
272            $redirect = add_query_arg( 'beyondwords_bulk_failed', $counts['failed'], $redirect );
273
274            if ( $counts['skipped'] > 0 ) {
275                $redirect = add_query_arg( 'beyondwords_bulk_skipped', $counts['skipped'], $redirect );
276            }
277
278            if ( $counts['deferred'] > 0 ) {
279                $redirect = add_query_arg( 'beyondwords_bulk_deferred', $counts['deferred'], $redirect );
280            }
281        } catch ( \Exception $e ) {
282            $redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
283        }
284
285        $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
286
287        return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
288    }
289
290    /**
291     * Handle the "Delete audio" bulk action.
292     *
293     * @param string $redirect   Redirect URL the bulk handler will use.
294     * @param string $doaction   Selected bulk action.
295     * @param int[]  $object_ids Post IDs in the bulk selection.
296     */
297    public static function handle_bulk_delete_action( $redirect, $doaction, $object_ids ) {
298        if ( 'beyondwords_delete_audio' !== $doaction ) {
299            return $redirect;
300        }
301
302        $redirect = remove_query_arg(
303            [
304                'beyondwords_bulk_generated',
305                'beyondwords_bulk_deferred',
306                'beyondwords_bulk_deleted',
307                'beyondwords_bulk_failed',
308                'beyondwords_bulk_skipped',
309                'beyondwords_bulk_error',
310            ],
311            $redirect
312        );
313
314        // Core routes custom bulk actions here after only a coarse edit_posts check,
315        // so guard per-post — else a crafted request could wipe another author's audio.
316        $object_ids = array_values(
317            array_filter(
318                $object_ids,
319                static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
320            )
321        );
322
323        // Bail before the remote batch-delete so an empty selection can't hit the API.
324        if ( empty( $object_ids ) ) {
325            $redirect = add_query_arg( 'beyondwords_bulk_deleted', 0, $redirect );
326
327            $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
328
329            return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
330        }
331
332        sort( $object_ids );
333
334        try {
335            $result   = self::delete_audio_for_posts( $object_ids );
336            $redirect = add_query_arg( 'beyondwords_bulk_deleted', count( $result ), $redirect );
337        } catch ( \Exception $e ) {
338            $redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
339        }
340
341        $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
342
343        return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
344    }
345}