Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.38% covered (success)
93.38%
141 / 151
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
BulkEdit
93.33% covered (success)
93.33%
140 / 150
50.00% covered (danger)
50.00%
4 / 8
37.41
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
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
5.01
 handle_bulk_delete_action
89.66% covered (success)
89.66%
26 / 29
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_error',
243            ],
244            $redirect
245        );
246
247        // Core routes custom bulk actions here after only a coarse edit_posts check,
248        // so guard per-post — else a crafted request could trigger billable generation.
249        $object_ids = array_values(
250            array_filter(
251                $object_ids,
252                static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
253            )
254        );
255
256        // Nothing editable selected: a clean no-op reporting a zero count.
257        if ( empty( $object_ids ) ) {
258            $redirect = add_query_arg( 'beyondwords_bulk_generated', 0, $redirect );
259            $redirect = add_query_arg( 'beyondwords_bulk_failed', 0, $redirect );
260
261            $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
262
263            return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
264        }
265
266        // Sync offloads to cron on VIP or runs a hard-capped synchronous batch
267        // off VIP; it also normalises and sorts the IDs, so no sort() here.
268        try {
269            $counts   = \BeyondWords\Post\Sync::bulk_generate_audio_for_posts( $object_ids );
270            $redirect = add_query_arg( 'beyondwords_bulk_generated', $counts['generated'], $redirect );
271            $redirect = add_query_arg( 'beyondwords_bulk_failed', $counts['failed'], $redirect );
272
273            if ( $counts['deferred'] > 0 ) {
274                $redirect = add_query_arg( 'beyondwords_bulk_deferred', $counts['deferred'], $redirect );
275            }
276        } catch ( \Exception $e ) {
277            $redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
278        }
279
280        $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
281
282        return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
283    }
284
285    /**
286     * Handle the "Delete audio" bulk action.
287     *
288     * @param string $redirect   Redirect URL the bulk handler will use.
289     * @param string $doaction   Selected bulk action.
290     * @param int[]  $object_ids Post IDs in the bulk selection.
291     */
292    public static function handle_bulk_delete_action( $redirect, $doaction, $object_ids ) {
293        if ( 'beyondwords_delete_audio' !== $doaction ) {
294            return $redirect;
295        }
296
297        $redirect = remove_query_arg(
298            [
299                'beyondwords_bulk_generated',
300                'beyondwords_bulk_deferred',
301                'beyondwords_bulk_deleted',
302                'beyondwords_bulk_failed',
303                'beyondwords_bulk_error',
304            ],
305            $redirect
306        );
307
308        // Core routes custom bulk actions here after only a coarse edit_posts check,
309        // so guard per-post — else a crafted request could wipe another author's audio.
310        $object_ids = array_values(
311            array_filter(
312                $object_ids,
313                static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
314            )
315        );
316
317        // Bail before the remote batch-delete so an empty selection can't hit the API.
318        if ( empty( $object_ids ) ) {
319            $redirect = add_query_arg( 'beyondwords_bulk_deleted', 0, $redirect );
320
321            $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
322
323            return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
324        }
325
326        sort( $object_ids );
327
328        try {
329            $result   = self::delete_audio_for_posts( $object_ids );
330            $redirect = add_query_arg( 'beyondwords_bulk_deleted', count( $result ), $redirect );
331        } catch ( \Exception $e ) {
332            $redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
333        }
334
335        $nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
336
337        return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
338    }
339}