PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / posts-list / class-bulk-edit.php

class-bulk-edit.php in BeyondWords – AI audio for publishers 7.0.0, at src/posts-list/class-bulk-edit.php

346 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
10 declare( strict_types = 1 );
11
12 namespace BeyondWords\PostsList;
13
14 defined( '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 */
21 class 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 }
346